use crate::game::rules::{TouchMode, Variant};
use crate::i18n::{set_language, LANGUAGE_LOADER};
use crate::ui::icons::{self, Icon};
use egui::{Color32, RichText, Ui};
use i18n_embed_fl::fl;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SearchAlgo {
Nrpa,
Beam,
Systematic,
Perturbation,
#[cfg(feature = "neural")]
Puct,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StartPoint {
Empty,
Seeded,
Continue,
}
pub fn start_points_for(algo: SearchAlgo) -> &'static [StartPoint] {
match algo {
SearchAlgo::Nrpa => &[StartPoint::Empty, StartPoint::Seeded, StartPoint::Continue],
SearchAlgo::Systematic | SearchAlgo::Beam => &[StartPoint::Empty, StartPoint::Continue],
SearchAlgo::Perturbation => &[],
#[cfg(feature = "neural")]
SearchAlgo::Puct => &[StartPoint::Empty],
}
}
pub struct ResumeInfo {
pub algo: SearchAlgo,
pub age: Duration,
}
#[cfg(feature = "neural")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PriorSource {
None,
Bundled,
Corpus,
TabulaRasa,
File,
}
#[cfg(feature = "neural")]
impl PriorSource {
pub fn all() -> &'static [PriorSource] {
&[
PriorSource::None,
PriorSource::Bundled,
PriorSource::Corpus,
PriorSource::TabulaRasa,
PriorSource::File,
]
}
pub(crate) fn label_key(self) -> &'static str {
match self {
PriorSource::None => "prior-none",
PriorSource::Bundled => "prior-bundled",
PriorSource::Corpus => "prior-corpus",
PriorSource::TabulaRasa => "prior-tabula-rasa",
PriorSource::File => "prior-file",
}
}
pub(crate) fn hint_key(self) -> &'static str {
match self {
PriorSource::None => "prior-none-hint",
PriorSource::Bundled => "prior-bundled-hint",
PriorSource::Corpus => "prior-corpus-hint",
PriorSource::TabulaRasa => "prior-tabula-rasa-hint",
PriorSource::File => "prior-file-hint",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ExportFormat {
Msr,
Json,
Pentasol,
Svg,
Png,
}
pub fn export_formats() -> &'static [ExportFormat] {
#[cfg(not(target_arch = "wasm32"))]
{
&[
ExportFormat::Msr,
ExportFormat::Json,
ExportFormat::Pentasol,
ExportFormat::Svg,
ExportFormat::Png,
]
}
#[cfg(target_arch = "wasm32")]
{
&[
ExportFormat::Msr,
ExportFormat::Json,
ExportFormat::Pentasol,
ExportFormat::Svg,
]
}
}
pub fn export_format_label(f: ExportFormat) -> &'static str {
match f {
ExportFormat::Msr => "MSR",
ExportFormat::Json => "JSON",
ExportFormat::Pentasol => "Pentasol",
ExportFormat::Svg => "SVG",
ExportFormat::Png => "PNG",
}
}
pub struct ControlsInput {
pub variant: Variant,
pub algo: SearchAlgo,
pub showing_preview: bool,
pub start_point: StartPoint,
pub warm_available: bool,
pub loaded_terminal: bool,
pub record_names: Vec<String>,
pub alarm_active: bool,
pub export_format: ExportFormat,
pub dark_mode: bool,
pub score: usize,
pub legal_count: usize,
pub search_running: bool,
pub search_paused: bool,
pub nodes_explored: u64,
pub best_search_score: u32,
pub nodes_per_sec: f64,
pub elapsed: Duration,
pub records: Vec<(u32, Duration)>,
pub checkpoint_supported: bool,
pub resume: Option<ResumeInfo>,
}
#[derive(Default)]
pub struct ControlsOutput {
pub new_game: Option<Variant>,
pub load_record: Option<usize>,
pub open_setup: bool,
pub stop_search: bool,
pub toggle_pause: bool,
pub load_best: bool,
pub dismiss_preview: bool,
pub checkpoint: bool,
pub resume_search: bool,
pub set_export_format: Option<ExportFormat>,
pub copy: bool,
pub export_file: bool,
pub import: bool,
pub silence_alarm: bool,
pub toggle_theme: bool,
pub show_shortcuts: bool,
pub show_rules: bool,
}
pub fn show(ui: &mut Ui, input: &ControlsInput) -> ControlsOutput {
let mut out = ControlsOutput::default();
let l = &*LANGUAGE_LOADER;
let sep = num_sep();
ui.add_space(8.0);
ui.heading(fl!(l, "app-title"));
ui.horizontal(|ui| {
ui.label(fl!(l, "language-label"));
let current = crate::i18n::current_language();
egui::ComboBox::from_id_salt("language")
.selected_text(crate::i18n::language_endonym(¤t))
.show_ui(ui, |ui| {
for lang in crate::i18n::available_languages() {
let name = crate::i18n::language_endonym(&lang);
if ui.selectable_label(current == lang, name).clicked() {
set_language(&lang);
}
}
});
let theme_icon = if input.dark_mode {
Icon::Sun
} else {
Icon::Moon
};
if icons::icon_button(ui, theme_icon, false, true)
.on_hover_text(fl!(l, "btn-theme"))
.clicked()
{
out.toggle_theme = true;
}
if ui
.button("?")
.on_hover_text(fl!(l, "btn-shortcuts"))
.clicked()
{
out.show_shortcuts = true;
}
if icons::icon_button(ui, Icon::Info, false, true)
.on_hover_text(fl!(l, "rules-title"))
.clicked()
{
out.show_rules = true;
}
});
if input.alarm_active {
let btn = egui::Button::new(
RichText::new(fl!(l, "btn-silence"))
.strong()
.color(Color32::WHITE),
)
.fill(Color32::from_rgb(200, 40, 40));
if ui.add_sized([ui.available_width(), 30.0], btn).clicked() {
out.silence_alarm = true;
}
}
ui.separator();
ui.add_space(6.0);
ui.heading(fl!(l, "game-section"));
ui.add_space(4.0);
ui.label(
RichText::new(format!("{} : {}", fl!(l, "score-label"), input.score))
.size(20.0)
.strong(),
);
if !input.showing_preview {
ui.label(format!(
"{} : {}",
fl!(l, "legal-moves-label"),
input.legal_count
));
}
ui.add_space(10.0);
ui.label(RichText::new(fl!(l, "variant-label")).strong());
ui.horizontal_wrapped(|ui| {
for v in [Variant::T5, Variant::D5, Variant::T4, Variant::D4] {
let mode = match v.touch_mode {
TouchMode::Touching => fl!(l, "touch-touching"),
TouchMode::Disjoint => fl!(l, "touch-disjoint"),
};
let tip = fl!(l, "variant-tip", len = (v.len() as i64), mode = mode);
if ui
.selectable_label(input.variant == v, v.name())
.on_hover_text(tip)
.clicked()
{
out.new_game = Some(v);
}
}
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(fl!(l, "format-label"));
egui::ComboBox::from_id_salt("export_format")
.selected_text(export_format_label(input.export_format))
.show_ui(ui, |ui| {
for &f in export_formats() {
if ui
.selectable_label(input.export_format == f, export_format_label(f))
.clicked()
{
out.set_export_format = Some(f);
}
}
});
});
ui.horizontal(|ui| {
if icons::icon_button(ui, Icon::New, false, true)
.on_hover_text(format!("{} ({}N)", fl!(l, "btn-new"), crate::ui::cmd_key()))
.clicked()
{
out.new_game = Some(input.variant);
}
if icons::icon_button(ui, Icon::Copy, false, true)
.on_hover_text(fl!(l, "btn-copy"))
.clicked()
{
out.copy = true;
}
if icons::icon_button(ui, Icon::Export, false, true)
.on_hover_text(format!(
"{} ({}S)",
fl!(l, "btn-export-file"),
crate::ui::cmd_key()
))
.clicked()
{
out.export_file = true;
}
if icons::icon_button(ui, Icon::Import, false, true)
.on_hover_text(fl!(l, "btn-import"))
.clicked()
{
out.import = true;
}
});
ui.add_space(4.0);
let n_records = input.record_names.len();
ui.add_enabled_ui(n_records > 0, |ui| {
egui::ComboBox::from_id_salt("load_record")
.selected_text(format!("{} ({n_records})", fl!(l, "load-record")))
.show_ui(ui, |ui| {
for (i, name) in input.record_names.iter().enumerate() {
if ui
.selectable_label(false, egui::RichText::new(name).monospace())
.clicked()
{
out.load_record = Some(i);
}
}
});
});
ui.add_space(10.0);
{
ui.separator();
ui.add_space(6.0);
ui.heading(fl!(l, "search-section"));
ui.add_space(4.0);
ui.add_space(2.0);
if input.search_running {
ui.horizontal(|ui| {
if icons::icon_button(ui, Icon::Stop, false, true)
.on_hover_text(fl!(l, "btn-stop"))
.clicked()
{
out.stop_search = true;
}
let (pi, ptip) = if input.search_paused {
(Icon::Play, fl!(l, "btn-resume"))
} else {
(Icon::Pause, fl!(l, "btn-pause"))
};
if icons::icon_button(ui, pi, false, true)
.on_hover_text(ptip)
.clicked()
{
out.toggle_pause = true;
}
});
} else if ui
.add_sized(
[ui.available_width(), 28.0],
egui::Button::new(fl!(l, "search-configure")),
)
.on_hover_text(fl!(l, "search-configure-hint"))
.clicked()
{
out.open_setup = true;
}
if input.checkpoint_supported
&& matches!(
input.algo,
SearchAlgo::Systematic | SearchAlgo::Nrpa | SearchAlgo::Perturbation
)
{
if input.search_running {
if ui.button(fl!(l, "btn-checkpoint")).clicked() {
out.checkpoint = true;
}
} else if let Some(ref r) = input.resume {
if ui.button(fl!(l, "btn-resume-search")).clicked() {
out.resume_search = true;
}
ui.label(
RichText::new(format!(
"{} · {} · {}",
fl!(l, "resume-saved"),
algo_label(r.algo),
format_dur(r.age),
))
.weak()
.small(),
);
}
}
if input.search_running || input.nodes_explored > 0 {
ui.add_space(6.0);
ui.label(format!(
"{} : {}",
fl!(l, "time-label"),
format_dur(input.elapsed)
));
ui.label(format!(
"{} : {}",
fl!(l, "nodes-explored-label"),
format_num(input.nodes_explored, sep)
));
if input.search_running && input.nodes_per_sec > 0.0 {
ui.label(format!(
"{} : {}",
fl!(l, "nodes-per-second-label"),
format_rate(input.nodes_per_sec)
));
#[cfg(target_arch = "wasm32")]
ui.label(
RichText::new(fl!(l, "wasm-rate-disclaimer"))
.weak()
.italics()
.small(),
);
}
if !input.records.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new(fl!(l, "records-label")).strong());
for &(score, dur) in input.records.iter().rev().take(5) {
ui.label(format!(" {} {}", score, format_dur(dur)));
}
}
if input.showing_preview && !input.search_running {
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button(fl!(l, "btn-load-best")).clicked() {
out.load_best = true;
}
if ui.button(fl!(l, "btn-dismiss-preview")).clicked() {
out.dismiss_preview = true;
}
});
}
}
ui.add_space(10.0);
}
out
}
pub(crate) fn algo_id(a: SearchAlgo) -> &'static str {
match a {
SearchAlgo::Nrpa => "nrpa",
SearchAlgo::Beam => "beam",
SearchAlgo::Systematic => "systematic",
SearchAlgo::Perturbation => "perturbation",
#[cfg(feature = "neural")]
SearchAlgo::Puct => "puct",
}
}
pub(crate) fn algo_from_id(id: &str) -> Option<SearchAlgo> {
Some(match id {
"nrpa" => SearchAlgo::Nrpa,
"beam" => SearchAlgo::Beam,
"systematic" => SearchAlgo::Systematic,
"perturbation" => SearchAlgo::Perturbation,
#[cfg(feature = "neural")]
"puct" => SearchAlgo::Puct,
_ => return None,
})
}
pub(crate) fn algo_label(a: SearchAlgo) -> String {
let l = &*LANGUAGE_LOADER;
match a {
SearchAlgo::Nrpa => fl!(l, "algo-nrpa"),
SearchAlgo::Beam => fl!(l, "algo-beam"),
SearchAlgo::Systematic => fl!(l, "algo-systematic"),
SearchAlgo::Perturbation => fl!(l, "algo-perturbation"),
#[cfg(feature = "neural")]
SearchAlgo::Puct => crate::i18n::tr("algo-puct"),
}
}
pub(crate) fn start_point_label(sp: StartPoint) -> String {
let l = &*LANGUAGE_LOADER;
match sp {
StartPoint::Empty => fl!(l, "start-empty"),
StartPoint::Seeded => fl!(l, "start-seeded"),
StartPoint::Continue => fl!(l, "start-continue"),
}
}
pub(crate) fn render_search_options(ui: &mut Ui, algo: SearchAlgo, enabled: bool) {
use crate::i18n::tr;
use crate::search::plugin::{registry, OptionKind, OptionValue};
let reg = registry();
let id = algo_id(algo);
let mut shown = false;
for spec in reg.options() {
if !spec.scope.applies_to(id) {
continue;
}
if !reg.option_visible(spec.key) {
continue; }
if !shown {
ui.add_space(6.0);
shown = true;
}
let label = tr(spec.label_key);
let hint = tr(spec.help_key);
ui.add_enabled_ui(enabled, |ui| match spec.kind {
OptionKind::Toggle { default } => {
let mut v = reg.value_bool(spec.key, default);
if ui.checkbox(&mut v, &label).on_hover_text(&hint).changed() {
reg.set_value(spec.key, OptionValue::Toggle(v));
}
}
OptionKind::Float {
default,
min,
max,
step,
} => {
ui.label(RichText::new(&label).strong());
let mut v = reg.value_f64(spec.key, default);
if ui
.add(egui::Slider::new(&mut v, min..=max).step_by(step))
.on_hover_text(&hint)
.changed()
{
reg.set_value(spec.key, OptionValue::Float(v));
}
}
OptionKind::Int { default, min, max } => {
ui.label(RichText::new(&label).strong());
let mut v = reg.value_int(spec.key, default);
let slider = egui::Slider::new(&mut v, min..=max);
let slider = if max - min > 1000 {
slider.logarithmic(true)
} else {
slider
};
if ui.add(slider).on_hover_text(&hint).changed() {
reg.set_value(spec.key, OptionValue::Int(v));
}
}
});
}
}
fn format_rate(r: f64) -> String {
if r >= 1_000_000.0 {
format!("{:.1}M/s", r / 1_000_000.0)
} else if r >= 1_000.0 {
format!("{:.1}k/s", r / 1_000.0)
} else {
format!("{:.0}/s", r)
}
}
fn num_sep() -> char {
match crate::i18n::current_language().language.as_str() {
"en" | "ja" => ',',
_ => '\u{00A0}',
}
}
fn format_num(n: u64, sep: char) -> String {
let s = n.to_string();
if s.len() <= 3 {
return s;
}
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
result.push(sep);
}
result.push(ch);
}
result
}
fn format_dur(d: Duration) -> String {
let secs = d.as_secs();
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{}:{:02}:{:02}", h, m, s)
} else {
format!("{}:{:02}", m, s)
}
}