Show a list of games in the library #64
|
@ -910,7 +910,7 @@ dependencies = [
|
||||||
"gdk4",
|
"gdk4",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib",
|
||||||
"glib-build-tools",
|
"glib-build-tools 0.16.3",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -1348,7 +1348,7 @@ dependencies = [
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"image",
|
"image",
|
||||||
"kifu-core",
|
"kifu-core",
|
||||||
"screenplay",
|
"sgf",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ macro_rules! define_config {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
pub enum ConfigOption {
|
pub enum ConfigOption {
|
||||||
$($name($struct)),+
|
$($name($struct)),+
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,15 +69,11 @@ pub struct CoreApp {
|
||||||
|
|
||||||
impl CoreApp {
|
impl CoreApp {
|
||||||
pub fn new(config_path: std::path::PathBuf) -> Self {
|
pub fn new(config_path: std::path::PathBuf) -> Self {
|
||||||
println!("config_path: {:?}", config_path);
|
|
||||||
let config = Config::from_path(config_path).expect("configuration to open");
|
let config = Config::from_path(config_path).expect("configuration to open");
|
||||||
|
|
||||||
let db_path: DatabasePath = config.get().unwrap();
|
let db_path: DatabasePath = config.get().unwrap();
|
||||||
let state = Arc::new(RwLock::new(AppState::new(db_path)));
|
let state = Arc::new(RwLock::new(AppState::new(db_path)));
|
||||||
|
|
||||||
println!("config: {:?}", config);
|
|
||||||
println!("games database: {:?}", state.read().unwrap().database.len());
|
|
||||||
|
|
||||||
Self { config, state }
|
Self { config, state }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,52 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sgf::{
|
use sgf::go::Game;
|
||||||
go::{Game, Rank},
|
|
||||||
Date,
|
|
||||||
};
|
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
pub struct GamePreviewElement {
|
pub struct GamePreviewElement {
|
||||||
pub date: Vec<Date>,
|
pub date: Vec<String>,
|
||||||
|
pub name: String,
|
||||||
pub black_player: String,
|
pub black_player: String,
|
||||||
pub black_rank: Option<Rank>,
|
|
||||||
pub white_player: String,
|
pub white_player: String,
|
||||||
pub white_rank: Option<Rank>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamePreviewElement {
|
impl GamePreviewElement {
|
||||||
pub fn new(game: &Game) -> GamePreviewElement {
|
pub fn new(game: &Game) -> GamePreviewElement {
|
||||||
|
let black_player = match game.info.black_player {
|
||||||
|
Some(ref black_player) => black_player.clone(),
|
||||||
|
None => "unknown".to_owned(),
|
||||||
|
};
|
||||||
|
let white_player = match game.info.white_player {
|
||||||
|
Some(ref white_player) => white_player.clone(),
|
||||||
|
None => "unknown".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let black_player = match game.info.black_rank {
|
||||||
|
Some(rank) => format!("{} ({})", black_player, rank.to_string()),
|
||||||
|
None => black_player,
|
||||||
|
};
|
||||||
|
|
||||||
|
let white_player = match game.info.white_rank {
|
||||||
|
Some(rank) => format!("{} ({})", white_player, rank.to_string()),
|
||||||
|
None => white_player,
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = match game.info.game_name {
|
||||||
|
Some(ref name) => name.clone(),
|
||||||
|
None => format!("{} vs. {}", black_player, white_player),
|
||||||
|
};
|
||||||
|
|
||||||
GamePreviewElement {
|
GamePreviewElement {
|
||||||
date: game.info.date.clone(),
|
date: game
|
||||||
black_player: game
|
|
||||||
.info
|
.info
|
||||||
.black_player
|
.date
|
||||||
.clone()
|
.iter()
|
||||||
.unwrap_or("black_player".to_owned()),
|
.map(|dt| dt.to_string())
|
||||||
black_rank: game.info.black_rank.clone(),
|
.collect::<Vec<String>>(),
|
||||||
white_player: game
|
name,
|
||||||
.info
|
black_player,
|
||||||
.white_player
|
white_player,
|
||||||
.clone()
|
|
||||||
.unwrap_or("white_player".to_owned()),
|
|
||||||
white_rank: game.info.white_rank.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
use crate::{
|
|
||||||
ui::types;
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use typeshare::typeshare;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub enum LaunchScreenView {
|
|
||||||
CreateGame(CreateGameView)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will be called when the Kifu application starts.
|
|
||||||
pub fn launch_screen() -> LaunchScreenView {
|
|
||||||
}
|
|
|
@ -4,9 +4,6 @@ pub use elements::{action::Action, game_preview::GamePreviewElement, menu::Menu}
|
||||||
mod playing_field;
|
mod playing_field;
|
||||||
pub use playing_field::{playing_field, PlayingFieldView};
|
pub use playing_field::{playing_field, PlayingFieldView};
|
||||||
|
|
||||||
// mod launch_screen;
|
|
||||||
// pub use launch_screen::{launch_screen, LaunchScreenView};
|
|
||||||
|
|
||||||
mod home;
|
mod home;
|
||||||
pub use home::{home, HomeView, HotseatPlayerElement, PlayerElement};
|
pub use home::{home, HomeView, HotseatPlayerElement, PlayerElement};
|
||||||
|
|
||||||
|
|
|
@ -12,11 +12,12 @@ screenplay = []
|
||||||
cairo-rs = { version = "0.17" }
|
cairo-rs = { version = "0.17" }
|
||||||
gio = { version = "0.17" }
|
gio = { version = "0.17" }
|
||||||
glib = { version = "0.17" }
|
glib = { version = "0.17" }
|
||||||
gtk = { version = "0.6", package = "gtk4" }
|
gtk = { version = "0.6", package = "gtk4", features = ["v4_8"] }
|
||||||
image = { version = "0.24" }
|
image = { version = "0.24" }
|
||||||
kifu-core = { path = "../core" }
|
kifu-core = { path = "../core" }
|
||||||
tokio = { version = "1.26", features = [ "full" ] }
|
tokio = { version = "1.26", features = [ "full" ] }
|
||||||
screenplay = { path = "../../screenplay" }
|
# screenplay = { path = "../../screenplay" }
|
||||||
|
sgf = { path = "../../sgf" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
glib-build-tools = "0.17"
|
glib-build-tools = "0.17"
|
||||||
|
|
|
@ -1 +1,7 @@
|
||||||
{"Me":{"name":"Savanni","rank":{"Kyu":10}},"DatabasePath":"../core/fixtures/five_games"}
|
{
|
||||||
|
"Me":{
|
||||||
|
"name":"Savanni",
|
||||||
|
"rank":{"Kyu":10}
|
||||||
|
},
|
||||||
|
"DatabasePath": "kifu/core/fixtures/five_games"
|
||||||
|
}
|
||||||
|
|
|
@ -13,8 +13,8 @@ fn handle_response(api: CoreApi, window: gtk::ApplicationWindow, message: CoreRe
|
||||||
CoreResponse::HomeView(view) => perftrace("HomeView", || {
|
CoreResponse::HomeView(view) => perftrace("HomeView", || {
|
||||||
let api = api.clone();
|
let api = api.clone();
|
||||||
|
|
||||||
let new_game = Home::new(api, view);
|
let home = Home::new(api, view);
|
||||||
window.set_child(Some(&new_game));
|
window.set_child(Some(&home));
|
||||||
}),
|
}),
|
||||||
CoreResponse::PlayingFieldView(view) => perftrace("PlayingFieldView", || {
|
CoreResponse::PlayingFieldView(view) => perftrace("PlayingFieldView", || {
|
||||||
let api = api.clone();
|
let api = api.clone();
|
||||||
|
|
|
@ -3,7 +3,12 @@ use gtk::{glib, prelude::*, subclass::prelude::*};
|
||||||
use kifu_core::ui::GamePreviewElement;
|
use kifu_core::ui::GamePreviewElement;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GamePreviewPrivate;
|
pub struct GamePreviewPrivate {
|
||||||
|
title: gtk::Label,
|
||||||
|
black_player: gtk::Label,
|
||||||
|
white_player: gtk::Label,
|
||||||
|
date: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
impl ObjectSubclass for GamePreviewPrivate {
|
impl ObjectSubclass for GamePreviewPrivate {
|
||||||
|
@ -21,22 +26,26 @@ glib::wrapper! {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamePreview {
|
impl GamePreview {
|
||||||
pub fn new(element: GamePreviewElement) -> GamePreview {
|
pub fn new() -> GamePreview {
|
||||||
let s: Self = Object::builder().build();
|
let s: Self = Object::builder().build();
|
||||||
s.set_orientation(gtk::Orientation::Horizontal);
|
s.set_orientation(gtk::Orientation::Horizontal);
|
||||||
|
s.set_homogeneous(true);
|
||||||
|
s.set_hexpand(true);
|
||||||
|
|
||||||
println!("game_preview: {:?}", element);
|
s.append(&s.imp().date);
|
||||||
let black_player = match element.black_rank {
|
s.append(&s.imp().title);
|
||||||
Some(rank) => format!("{} ({})", element.black_player, rank.to_string()),
|
s.append(&s.imp().black_player);
|
||||||
None => element.black_player,
|
s.append(&s.imp().white_player);
|
||||||
};
|
|
||||||
let white_player = match element.white_rank {
|
|
||||||
Some(rank) => format!("{} ({})", element.white_player, rank.to_string()),
|
|
||||||
None => element.white_player,
|
|
||||||
};
|
|
||||||
s.append(>k::Label::new(Some(&black_player)));
|
|
||||||
s.append(>k::Label::new(Some(&white_player)));
|
|
||||||
|
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_game(&self, element: GamePreviewElement) {
|
||||||
|
self.imp().black_player.set_text(&element.black_player);
|
||||||
|
self.imp().white_player.set_text(&element.white_player);
|
||||||
|
self.imp().title.set_text(&element.name);
|
||||||
|
if let Some(date) = element.date.first() {
|
||||||
|
self.imp().date.set_text(&date);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::ui::GamePreview;
|
use crate::{ui::Library, CoreApi};
|
||||||
use crate::CoreApi;
|
|
||||||
use glib::Object;
|
use glib::Object;
|
||||||
use gtk::{glib, prelude::*, subclass::prelude::*};
|
use gtk::{glib, prelude::*, subclass::prelude::*};
|
||||||
use kifu_core::{
|
use kifu_core::{
|
||||||
|
@ -101,31 +100,51 @@ impl Default for HomePrivate {
|
||||||
impl ObjectSubclass for HomePrivate {
|
impl ObjectSubclass for HomePrivate {
|
||||||
const NAME: &'static str = "Home";
|
const NAME: &'static str = "Home";
|
||||||
type Type = Home;
|
type Type = Home;
|
||||||
type ParentType = gtk::Grid;
|
type ParentType = gtk::Box;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectImpl for HomePrivate {}
|
impl ObjectImpl for HomePrivate {}
|
||||||
impl WidgetImpl for HomePrivate {}
|
impl WidgetImpl for HomePrivate {}
|
||||||
impl GridImpl for HomePrivate {}
|
impl BoxImpl for HomePrivate {}
|
||||||
|
|
||||||
glib::wrapper! {
|
glib::wrapper! {
|
||||||
pub struct Home(ObjectSubclass<HomePrivate>) @extends gtk::Grid, gtk::Widget;
|
pub struct Home(ObjectSubclass<HomePrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Home {
|
impl Home {
|
||||||
pub fn new(api: CoreApi, view: HomeView) -> Home {
|
pub fn new(api: CoreApi, view: HomeView) -> Home {
|
||||||
let s: Self = Object::builder().build();
|
let s: Self = Object::builder().build();
|
||||||
|
s.set_spacing(4);
|
||||||
|
s.set_homogeneous(false);
|
||||||
|
s.set_orientation(gtk::Orientation::Vertical);
|
||||||
|
|
||||||
|
let players = gtk::Box::builder()
|
||||||
|
.spacing(4)
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.build();
|
||||||
|
s.append(&players);
|
||||||
|
|
||||||
let black_player = PlayerDataEntry::new(view.black_player);
|
let black_player = PlayerDataEntry::new(view.black_player);
|
||||||
s.attach(&black_player, 1, 1, 1, 1);
|
players.append(&black_player);
|
||||||
*s.imp().black_player.borrow_mut() = Some(black_player.clone());
|
*s.imp().black_player.borrow_mut() = Some(black_player.clone());
|
||||||
|
|
||||||
let white_player = PlayerDataEntry::new(view.white_player);
|
let white_player = PlayerDataEntry::new(view.white_player);
|
||||||
s.attach(&white_player, 2, 1, 1, 1);
|
players.append(&white_player);
|
||||||
*s.imp().white_player.borrow_mut() = Some(white_player.clone());
|
*s.imp().white_player.borrow_mut() = Some(white_player.clone());
|
||||||
|
|
||||||
let new_game_button = gtk::Button::builder().label(&view.start_game.label).build();
|
let new_game_button = gtk::Button::builder().label(&view.start_game.label).build();
|
||||||
s.attach(&new_game_button, 2, 2, 1, 1);
|
s.append(&new_game_button);
|
||||||
|
|
||||||
|
let library = Library::new();
|
||||||
|
let library_view = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.min_content_width(360)
|
||||||
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.child(&library)
|
||||||
|
.build();
|
||||||
|
s.append(&library_view);
|
||||||
|
|
||||||
|
library.set_games(view.games);
|
||||||
|
|
||||||
new_game_button.connect_clicked({
|
new_game_button.connect_clicked({
|
||||||
move |_| {
|
move |_| {
|
||||||
|
@ -139,12 +158,6 @@ impl Home {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let game_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
||||||
s.attach(&game_list, 1, 3, 2, 1);
|
|
||||||
view.games
|
|
||||||
.iter()
|
|
||||||
.for_each(|game_preview| game_list.append(&GamePreview::new(game_preview.clone())));
|
|
||||||
|
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
use crate::ui::GamePreview;
|
||||||
|
use glib::Object;
|
||||||
|
use gtk::{glib, prelude::*, subclass::prelude::*};
|
||||||
|
use kifu_core::ui::GamePreviewElement;
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct GameObjectPrivate {
|
||||||
|
game: Rc<RefCell<Option<GamePreviewElement>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for GameObjectPrivate {
|
||||||
|
const NAME: &'static str = "GameObject";
|
||||||
|
type Type = GameObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for GameObjectPrivate {}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct GameObject(ObjectSubclass<GameObjectPrivate>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameObject {
|
||||||
|
pub fn new(game: GamePreviewElement) -> Self {
|
||||||
|
let s: Self = Object::builder().build();
|
||||||
|
*s.imp().game.borrow_mut() = Some(game);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn game(&self) -> Option<GamePreviewElement> {
|
||||||
|
self.imp().game.borrow().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LibraryPrivate {
|
||||||
|
model: gio::ListStore,
|
||||||
|
list_view: gtk::ListView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LibraryPrivate {
|
||||||
|
fn default() -> Self {
|
||||||
|
let vector: Vec<GameObject> = vec![];
|
||||||
|
let model = gio::ListStore::new(glib::types::Type::OBJECT);
|
||||||
|
model.extend_from_slice(&vector);
|
||||||
|
let factory = gtk::SignalListItemFactory::new();
|
||||||
|
|
||||||
|
factory.connect_setup(move |_, list_item| {
|
||||||
|
let preview = GamePreview::new();
|
||||||
|
list_item
|
||||||
|
.downcast_ref::<gtk::ListItem>()
|
||||||
|
.expect("Needs to be a ListItem")
|
||||||
|
.set_child(Some(&preview));
|
||||||
|
});
|
||||||
|
factory.connect_bind(move |_, list_item| {
|
||||||
|
let game_element = list_item
|
||||||
|
.downcast_ref::<gtk::ListItem>()
|
||||||
|
.expect("Needs to be ListItem")
|
||||||
|
.item()
|
||||||
|
.and_downcast::<GameObject>()
|
||||||
|
.expect("The item has to be a GameObject.");
|
||||||
|
|
||||||
|
let preview = list_item
|
||||||
|
.downcast_ref::<gtk::ListItem>()
|
||||||
|
.expect("Needs to be ListItem")
|
||||||
|
.child()
|
||||||
|
.and_downcast::<GamePreview>()
|
||||||
|
.expect("The child has to be a GamePreview object.");
|
||||||
|
|
||||||
|
match game_element.game() {
|
||||||
|
Some(game) => preview.set_game(game),
|
||||||
|
None => (),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let selection_model = gtk::NoSelection::new(Some(model.clone()));
|
||||||
|
let list_view = gtk::ListView::new(Some(selection_model), Some(factory));
|
||||||
|
list_view.set_hexpand(true);
|
||||||
|
Self { model, list_view }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for LibraryPrivate {
|
||||||
|
const NAME: &'static str = "Library";
|
||||||
|
type Type = Library;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for LibraryPrivate {}
|
||||||
|
impl WidgetImpl for LibraryPrivate {}
|
||||||
|
impl BoxImpl for LibraryPrivate {}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct Library(ObjectSubclass<LibraryPrivate>) @extends gtk::Widget, gtk::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let s: Self = Object::builder().build();
|
||||||
|
s.set_hexpand(true);
|
||||||
|
s.append(&s.imp().list_view);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_games(&self, games: Vec<GamePreviewElement>) {
|
||||||
|
let games = games
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| GameObject::new(g))
|
||||||
|
.collect::<Vec<GameObject>>();
|
||||||
|
self.imp().model.extend_from_slice(&games);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,9 @@ pub use chat::Chat;
|
||||||
mod game_preview;
|
mod game_preview;
|
||||||
pub use game_preview::GamePreview;
|
pub use game_preview::GamePreview;
|
||||||
|
|
||||||
|
mod library;
|
||||||
|
pub use library::Library;
|
||||||
|
|
||||||
mod player_card;
|
mod player_card;
|
||||||
pub use player_card::PlayerCard;
|
pub use player_card::PlayerCard;
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,16 @@ pub enum Date {
|
||||||
Date(chrono::NaiveDate),
|
Date(chrono::NaiveDate),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Date {
|
||||||
|
pub fn to_string(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Date::Year(y) => format!("{}", y),
|
||||||
|
Date::YearMonth(y, m) => format!("{}-{}", y, m),
|
||||||
|
Date::Date(date) => format!("{}-{}-{}", date.year(), date.month(), date.day()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
impl TryFrom<&str> for Date {
|
impl TryFrom<&str> for Date {
|
||||||
type Error = String;
|
type Error = String;
|
||||||
|
|
|
@ -99,6 +99,11 @@ impl TryFrom<Tree> for Game {
|
||||||
info.app_name = tree.sequence[0]
|
info.app_name = tree.sequence[0]
|
||||||
.find_prop("AP")
|
.find_prop("AP")
|
||||||
.map(|prop| prop.values[0].clone());
|
.map(|prop| prop.values[0].clone());
|
||||||
|
|
||||||
|
info.game_name = tree.sequence[0]
|
||||||
|
.find_prop("GN")
|
||||||
|
.map(|prop| prop.values[0].clone());
|
||||||
|
|
||||||
info.black_player = tree.sequence[0]
|
info.black_player = tree.sequence[0]
|
||||||
.find_prop("PB")
|
.find_prop("PB")
|
||||||
.map(|prop| prop.values.join(", "));
|
.map(|prop| prop.values.join(", "));
|
||||||
|
|
Loading…
Reference in New Issue