diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index f09dc8e4..5075b37e 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -19,6 +19,7 @@ mod capture; mod rows; mod state; mod style; +pub mod summary; mod text; mod view; diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs new file mode 100644 index 00000000..fe642cd6 --- /dev/null +++ b/crates/keymap/src/ui/review.rs @@ -0,0 +1,323 @@ +//! The foreign import review (§2.7 of the dialog plan). It takes the place of the table and +//! detail pane: what the set will change, what did not match and why, suggestions to pick from, +//! and after the import the report of what was skipped, taken or failed. + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; + +use super::{Draw, FIELD_W, fill}; +use crate::chord::foreign::{ForeignError, ReadNote}; +use crate::command::CommandRef; +use crate::import::{FailedChange, ImportPlan, ImportReport, NoChangeReason, SkipReason, UnmatchedReason}; + +/// One list row: the item, its line, its keys and a note. +struct Entry { + item: String, + line: Option, + keys: String, + note: String, + /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. + to: bool, +} + +enum Act { + Pick(usize, usize), + Import, + Close, + Copy(String), +} + +impl Draw<'_, '_> { + /// Draws the review in `rect` while one is open; returns whether it drew. + pub(super) fn review(&mut self, ui: &mut Ui, rect: Rect) -> bool { + let Some(r) = self.state.review.as_ref() else { return false }; + let (plan, name, done) = (r.plan.clone(), r.name.clone(), r.done.clone()); + let mut act = None; + self.child(ui, rect, |d, ui| { + act = match &done { + Some((set, report)) => d.after_report(ui, set, report), + None => d.plan_view(ui, &plan, name), + }; + }); + match act { + Some(Act::Pick(s, c)) => self.state.pick_suggestion(self.env, s, c), + Some(Act::Import) => self.state.import_reviewed(self.env), + Some(Act::Close) => self.state.close_review(), + Some(Act::Copy(text)) => ui.ctx().copy_text(text), + None => {} + } + true + } + + fn line_text(&self, line: Option) -> Option { + line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())])) + } + + /// "item · line n · keys — note" on one wrapping line, the item in the strong colour. + fn entry(&self, ui: &mut Ui, e: &Entry) { + let font = TextStyle::Body.resolve(ui.style()); + let strong = ui.visuals().strong_text_color(); + let dim = self.style.dim; + let mut job = LayoutJob::default(); + let mut add = |s: &str, color: Color32| job.append(s, 0.0, TextFormat::simple(font.clone(), color)); + add(&self.vis(&e.item), strong); + if let Some(line) = self.line_text(e.line) { + add(&format!(" · {}", self.vis(&line)), dim); + } + if !e.keys.is_empty() { + add(&format!(" · {}", e.keys), ui.visuals().text_color()); + } + if e.to { + add(&format!(" — {}", self.vis(&e.note)), ui.visuals().text_color()); + } else if !e.note.is_empty() { + add(&format!(" — {}", self.vis(&e.note)), dim); + } + ui.add(Label::new(job).wrap()); + } + + fn section(&self, ui: &mut Ui, title: &str, entries: &[Entry]) { + if entries.is_empty() { + return; + } + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{title} ({})", entries.len()))).strong()); + for e in entries { + self.entry(ui, e); + } + } + + fn unmatched_reason(&self, r: UnmatchedReason) -> String { + match r { + UnmatchedReason::NoMatch => self.ui("no command has this name"), + UnmatchedReason::Ambiguous => self.ui("more than one command fits"), + UnmatchedReason::Context => self.ui("in a context that is not imported"), + UnmatchedReason::Tool => self.ui("only a command of the other kind fits (tool or menu command)"), + } + } + + fn foreign_error(&self, e: &ForeignError) -> String { + match e { + ForeignError::Empty => self.ui("empty shortcut"), + ForeignError::TooLong => self.ui("the shortcut is too long"), + ForeignError::UnknownKey(k) => fill(self.ui("unknown key {key}"), &[("key", k)]), + ForeignError::UnsupportedModifier(m) => fill(self.ui("unsupported modifier {key}"), &[("key", m)]), + } + } + + fn read_note(&self, n: ReadNote) -> String { + match n { + ReadNote::Numpad => self.ui("numeric keypad key read as main key"), + } + } + + fn plan_view(&mut self, ui: &mut Ui, plan: &ImportPlan, mut name: String) -> Option { + let mut act = None; + let report = &plan.report; + let set = report.set.clone().unwrap_or_else(|| self.ui("Imported")); + ui.label(RichText::new(self.vis(&fill(self.ui("Import “{set}”"), &[("set", &set)]))).strong()); + + let t = self.env.text; + let mut counts = vec![t.plural(plan.commands.len() as u64, "{n} command will change", "{n} commands will change")]; + let more = [ + (report.unmatched.len(), t.plural(report.unmatched.len() as u64, "{n} item not matched", "{n} items not matched")), + ( + report.unmatched_without_shortcut, + t.plural(report.unmatched_without_shortcut as u64, "{n} item without a shortcut", "{n} items without a shortcut"), + ), + (report.unreadable.len(), t.plural(report.unreadable.len() as u64, "{n} item could not be read", "{n} items could not be read")), + (report.approximated.len(), t.plural(report.approximated.len() as u64, "{n} key read approximately", "{n} keys read approximately")), + (report.truncated, t.plural(report.truncated as u64, "{n} entry cut at a limit", "{n} entries cut at a limit")), + ]; + counts.extend(more.into_iter().filter(|(n, _)| *n > 0).map(|(_, s)| s)); + ui.label(RichText::new(self.vis(&counts.join(" · "))).color(self.style.dim)); + + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Set name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut name).id(self.base.with("import_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.import.name".into(), edit.rect, &name_label); + let import = if self.env.sets.is_some() { self.ui("Import as New Set") } else { self.ui("Import") }; + let valid = self.env.sets.is_none() || crate::profiles::set_name(&name).is_some(); + if self.button(ui, valid, &import, "shortcuts.import.ok") { + act = Some(Act::Import); + } + if self.button(ui, true, &self.ui("Cancel Import"), "shortcuts.import.cancel") { + act = Some(Act::Close); + } + }); + if let Some(r) = self.state.review.as_mut() { + r.name = name; + } + + ScrollArea::vertical().id_salt(self.base.with("review")).auto_shrink([false, false]).show(ui, |ui| { + let chord_list = |d: &Self, c: &crate::import::Planned| -> String { + let home = d.env.keymap.bindable(&c.command).map(|b| b.scope.clone()); + if c.bindings.is_empty() { + return d.ui("None"); + } + c.bindings.iter().map(|b| d.binding_text(&b.binding, home.as_ref())).collect::>().join(", ") + }; + let changes: Vec = plan + .commands + .iter() + .map(|c| Entry { + item: c.item.clone(), + line: c.line, + keys: String::new(), + note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chord_list(self, c))]), + to: true, + }) + .collect(); + self.section(ui, &self.ui("Will change"), &changes); + + if !report.suggestions.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); + for (si, s) in report.suggestions.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + self.entry(ui, &Entry { item: s.item.clone(), line: s.line, keys: String::new(), note: String::new(), to: false }); + let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]); + let choose = self.ui("Choose a command…"); + let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| { + for (ci, c) in s.candidates.iter().enumerate() { + if ui.selectable_label(false, self.vis(&self.label_of(c))).clicked() { + act = Some(Act::Pick(si, ci)); + } + } + }); + let resp = combo.response.on_hover_text(self.vis(&label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label)); + self.tag(|| format!("shortcuts.import.suggestion.{si}"), resp.rect, &label); + }); + } + } + + let unmatched: Vec = report + .unmatched + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.unmatched_reason(u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Not matched"), &unmatched); + let unreadable: Vec = report + .unreadable + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.foreign_error(&u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Unreadable"), &unreadable); + let approximated: Vec = report + .approximated + .iter() + .map(|a| Entry { item: a.item.clone(), line: a.line, keys: a.keys.clone(), note: self.read_note(a.note), to: false }) + .collect(); + self.section(ui, &self.ui("Approximated"), &approximated); + let kept: Vec = report + .matched_without_change + .iter() + .map(|m| Entry { + item: m.item.clone(), + line: m.line, + keys: String::new(), + note: match m.reason { + NoChangeReason::PanelMenu => { + fill(self.ui("{label} keeps its shortcuts (matched by a panel menu name)"), &[("label", &self.label_of(&m.command))]) + } + }, + to: false, + }) + .collect(); + self.section(ui, &self.ui("Matched without change"), &kept); + }); + act + } + + fn skip_note(&self, reason: SkipReason, kept_by: Option<&CommandRef>) -> String { + match (reason, kept_by) { + (SkipReason::Taken, Some(c)) => fill(self.ui("taken by {label}"), &[("label", &self.label_of(c))]), + (SkipReason::Taken, None) => self.ui("taken by a later entry"), + (SkipReason::Reserved, _) => self.ui("reserved by the system"), + (SkipReason::UnknownScope, _) => self.ui("the scope is unknown"), + } + } + + fn failed_note(&self, change: FailedChange) -> String { + match change { + FailedChange::Bind => self.ui("the set's shortcuts could not be added"), + FailedChange::Clear => self.ui("the shortcuts could not be removed"), + FailedChange::Unbind => self.ui("the shortcut could not be taken from it"), + } + } + + /// The report's lists: skipped keys, shortcuts taken from other commands, failed changes. + fn report_lists(&self, report: &ImportReport) -> [(String, Vec); 3] { + let skipped = report + .skipped + .iter() + .map(|s| Entry { + item: s.item.clone(), + line: s.line, + keys: s.shortcut.clone(), + note: self.skip_note(s.reason, s.kept_by.as_ref()), + to: false, + }) + .collect(); + let cleared = report + .conflicts_cleared + .iter() + .map(|c| Entry { item: self.label_of(&c.command), line: None, keys: c.shortcut.clone(), note: String::new(), to: false }) + .collect(); + let failed = report + .failed + .iter() + .map(|f| Entry { item: self.label_of(&f.command), line: None, keys: f.shortcuts.join(", "), note: self.failed_note(f.change), to: false }) + .collect(); + [(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)] + } + + fn after_report(&mut self, ui: &mut Ui, set: &str, report: &ImportReport) -> Option { + let mut act = None; + let heading = fill(self.ui("Imported as “{set}”"), &[("set", set)]); + ui.label(RichText::new(self.vis(&heading)).strong()); + let changed = self.env.text.plural(report.imported as u64, "{n} command changed", "{n} commands changed"); + ui.label(RichText::new(self.vis(&changed)).color(self.style.dim)); + let lists = self.report_lists(report); + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Copy Report"), "shortcuts.import.copy") { + act = Some(Act::Copy(self.report_text(&heading, &changed, &lists))); + } + if self.button(ui, true, &self.ui("Done"), "shortcuts.import.done") { + act = Some(Act::Close); + } + }); + ScrollArea::vertical().id_salt(self.base.with("report")).auto_shrink([false, false]).show(ui, |ui| { + for (title, entries) in &lists { + self.section(ui, title, entries); + } + }); + act + } + + /// The report as plain text for Copy Report (logical order). + fn report_text(&self, heading: &str, changed: &str, lists: &[(String, Vec)]) -> String { + let mut out = format!("{heading}\n{changed}\n"); + for (title, entries) in lists.iter().filter(|(_, e)| !e.is_empty()) { + out.push_str(&format!("\n{title}\n")); + for e in entries { + let mut line = format!(" {}", e.item); + for part in [self.line_text(e.line).unwrap_or_default(), e.keys.clone()] { + if !part.is_empty() { + line.push_str(" · "); + line.push_str(&part); + } + } + if !e.note.is_empty() { + line.push_str(" — "); + line.push_str(&e.note); + } + out.push_str(&line); + out.push('\n'); + } + } + out + } +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 69b0afaa..1e07a516 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -176,12 +176,6 @@ impl RowCache { pub(crate) fn built(&self) -> &Built { &self.built } - - /// Forgets the lines, for a change the key does not cover (the interface language). - #[allow(dead_code, reason = "for hosts that switch the interface language")] - pub(crate) fn invalidate(&mut self) { - self.key = None; - } } fn top_menu(b: &Bindable) -> Option<&str> { @@ -255,7 +249,7 @@ fn id_group(id: &str) -> String { /// A row's group header (`None`: no header) with its rank among groups, and how many path parts /// the header stands for. -fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { +pub(crate) fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth)); match area { diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs new file mode 100644 index 00000000..64e39f01 --- /dev/null +++ b/crates/keymap/src/ui/sets.rs @@ -0,0 +1,217 @@ +//! The set row (§2.6 of the dialog plan): the set picker, then New Set…, Rename…, Delete, Save, +//! Revert Set, Export, Import… and Summarize…; actions that do not fit go into a More menu. New +//! Set… and Rename… open an inline form under the row. + +use craft_ui::layout::fit_toolbar; +use egui::{ComboBox, Key as EguiKey, RichText, TextEdit, Ui}; + +use super::{Draw, FIELD_W}; +use crate::profiles::{Profiles, set_name}; +use crate::ui::state::{DialogState, SetForm, SetFormKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Act { + New, + Rename, + Delete, + Save, + Revert, + /// Export as craft-keymap JSON; with app formats the item is a menu. + Export, + ExportAs(&'static str), + Import, + Summarize, +} + +struct Item { + act: Act, + text: String, + id: &'static str, + enabled: bool, +} + +impl Draw<'_, '_> { + /// Drawn above the header when the app passes sets. + pub(super) fn sets_row(&mut self, ui: &mut Ui) { + let Some((p, b)) = self.env.sets.as_ref() else { return }; + let names = p.names(b); + let active = p.active_name(b).to_string(); + let builtin = Profiles::is_builtin(&active, b); + let modified = DialogState::is_modified(self.env); + let editable = self.env.options.editable; + let items = self.set_items(builtin, modified); + let mut act = None; + ui.add_enabled_ui(self.state.review.is_none(), |ui| { + ui.horizontal(|ui| { + let set_label = self.ui("Set:"); + let l = ui.label(self.vis(&set_label)); + let mut pick = None; + let combo = ui.add_enabled_ui(editable, |ui| { + ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == active, self.vis(n)).clicked() { + pick = Some(n.clone()); + } + } + }) + }); + let combo = combo.inner.response.labelled_by(l.id); + self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); + if modified { + ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); + } + if let Some(n) = pick.filter(|n| *n != active) { + self.state.set_form = None; + self.state.switch_set(self.env, &n); + } + act = self.set_toolbar(ui, &items); + }); + self.set_form(ui); + }); + if let Some(a) = act { + self.set_act(a, &active); + } + } + + fn set_items(&self, builtin: bool, modified: bool) -> Vec { + let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled }; + let mut items = Vec::new(); + if self.env.options.editable { + items.push(item(Act::New, "New Set…", "shortcuts.set.new", true)); + items.push(item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin)); + items.push(item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin)); + items.push(item(Act::Save, "Save", "shortcuts.set.save", modified)); + items.push(item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified)); + } + let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; + items.push(item(Act::Export, export, "shortcuts.set.export", true)); + if self.env.options.editable { + items.push(item(Act::Import, "Import…", "shortcuts.set.import", true)); + } + if self.env.options.summary { + items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true)); + } + items + } + + /// The actions that fit in the row's remaining width, then More with the rest. + fn set_toolbar(&self, ui: &mut Ui, items: &[Item]) -> Option { + let more = self.ui("More"); + let natural: Vec = items.iter().map(|i| self.button_width(ui, &i.text)).collect(); + let spacing = ui.spacing().item_spacing.x; + let (shown, overflow): (Vec, Vec) = match fit_toolbar(&natural, ui.available_width(), spacing, self.button_width(ui, &more)) { + Some(fit) => (fit.shown.iter().map(|&(i, _)| i).collect(), fit.overflow), + None => (Vec::new(), (0..items.len()).collect()), + }; + let mut act = None; + for i in shown { + act = act.or(self.set_item(ui, &items[i])); + } + if !overflow.is_empty() { + let r = ui.menu_button(self.vis(&more), |ui| { + for &i in &overflow { + act = act.or(self.set_item(ui, &items[i])); + } + }); + self.tag(|| "shortcuts.set.more".into(), r.response.rect, &more); + } + act + } + + fn set_item(&self, ui: &mut Ui, item: &Item) -> Option { + if item.act == Act::Export && !self.env.options.export_formats.is_empty() { + let mut act = None; + let r = ui.menu_button(self.vis(&item.text), |ui| { + if self.button(ui, true, &self.ui("Keyboard Shortcuts (JSON)"), "shortcuts.set.export.json") { + act = Some(Act::Export); + } + for f in &self.env.options.export_formats { + let label = self.env.text.ui(f.label).into_owned(); + if self.button(ui, true, &label, &format!("shortcuts.set.export.{}", f.id)) { + act = Some(Act::ExportAs(f.id)); + } + } + }); + self.tag(|| item.id.into(), r.response.rect, &item.text); + return act; + } + self.button(ui, item.enabled, &item.text, item.id).then_some(item.act) + } + + fn set_act(&mut self, act: Act, active: &str) { + let form = |kind, name: &str| Some(SetForm { kind, name: name.to_string(), focus: true }); + match act { + Act::New => self.state.set_form = form(SetFormKind::New { based_on: None }, ""), + Act::Rename => self.state.set_form = form(SetFormKind::Rename, active), + Act::Delete => self.state.ask_delete_set(self.env), + Act::Save => self.state.save_set(self.env), + Act::Revert => self.state.revert_set(self.env), + Act::Export => self.state.export_set(self.env), + Act::ExportAs(f) => self.state.export_as(self.env, f), + Act::Import => self.state.request_import(self.env), + Act::Summarize => self.state.summarize(self.env), + } + } + + /// New Set… (Name, Based on) or Rename… (Name), under the set row. + fn set_form(&mut self, ui: &mut Ui) { + let Some(mut form) = self.state.set_form.clone() else { return }; + let names = self.env.sets.as_ref().map(|(p, b)| p.names(b)).unwrap_or_default(); + let (mut submit, mut cancel) = (false, false); + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut form.name).id(self.base.with("set_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.setForm.name".into(), edit.rect, &name_label); + if std::mem::take(&mut form.focus) { + edit.request_focus(); + } + if edit.lost_focus() { + let (enter, escape) = ui.input(|i| (i.key_pressed(EguiKey::Enter), i.key_pressed(EguiKey::Escape))); + submit |= enter; + cancel |= escape; + } + if let SetFormKind::New { based_on } = &mut form.kind { + let based_label = self.ui("Based on:"); + let l = ui.label(self.vis(&based_label)); + let current = self.ui("Current shortcuts"); + let shown = based_on.clone().unwrap_or_else(|| current.clone()); + let combo = ComboBox::from_id_salt(self.base.with("based_on")).selected_text(self.vis(&shown)).show_ui(ui, |ui| { + if ui.selectable_label(based_on.is_none(), self.vis(¤t)).clicked() { + *based_on = None; + } + for n in &names { + if ui.selectable_label(based_on.as_ref() == Some(n), self.vis(n)).clicked() { + *based_on = Some(n.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.setForm.basedOn".into(), combo.rect, &based_label); + } + let ok = match form.kind { + SetFormKind::New { .. } => self.ui("Create"), + SetFormKind::Rename => self.ui("Rename"), + }; + submit |= self.button(ui, set_name(&form.name).is_some(), &ok, "shortcuts.setForm.ok"); + cancel |= self.button(ui, true, &self.ui("Cancel"), "shortcuts.setForm.cancel"); + }); + if cancel { + self.state.set_form = None; + return; + } + if !submit || set_name(&form.name).is_none() { + self.state.set_form = Some(form); + return; + } + self.state.message = None; + match &form.kind { + SetFormKind::New { based_on } => self.state.new_set(self.env, &form.name, based_on.as_deref()), + SetFormKind::Rename => self.state.rename_set(self.env, &form.name), + } + // A refused name keeps the form open for another try. + let refused = self.state.message.as_ref().is_some_and(|m| m.refused); + self.state.set_form = refused.then_some(form); + } +} diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index edfc7d33..71bf9ba6 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -12,7 +12,7 @@ use crate::profiles::{Builtins, ProfileError, Profiles}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; -use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture}; +use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture, summary}; /// What the dialog edits this frame. pub(crate) struct Env<'e> { @@ -105,7 +105,6 @@ pub(crate) struct Message { } /// What to do after the Save changes prompt. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Then { Switch(String), @@ -113,7 +112,6 @@ pub(crate) enum Then { Import, } -#[allow(dead_code, reason = "set row prompts")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Prompt { /// "Restore every default shortcut in this set?" @@ -134,7 +132,6 @@ pub(crate) enum Answer { } /// A foreign set under review. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug)] pub(crate) struct Review { pub set: ForeignSet, @@ -146,6 +143,24 @@ pub(crate) struct Review { pub done: Option<(String, ImportReport)>, } +/// The set row's inline form: New Set… or Rename…. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SetForm { + pub kind: SetFormKind, + pub name: String, + /// Focus the name field on the next frame. + pub focus: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SetFormKind { + /// `None`: based on the current shortcuts. + New { + based_on: Option, + }, + Rename, +} + #[derive(Clone, Debug)] struct Opened { profiles: Option, @@ -199,6 +214,7 @@ pub struct DialogState { pub(crate) text_focus: bool, /// The table lines drawn last frame. pub(crate) visible: std::ops::Range, + pub(crate) set_form: Option, } impl Default for DialogState { @@ -239,6 +255,7 @@ impl DialogState { bottom_h: 0.0, text_focus: false, visible: 0..0, + set_form: None, } } @@ -693,6 +710,8 @@ impl DialogState { self.conflict = None; } else if self.prompt.is_some() { self.prompt = None; + } else if self.set_form.is_some() { + self.set_form = None; } else if self.review.is_some() { self.review = None; } else if self.field.is_some() { @@ -710,8 +729,6 @@ impl DialogState { } } -// The set row and the import review draw with these (T3); until then some are unused. -#[allow(dead_code, reason = "set row and import review actions")] impl DialogState { // ---- Sets ---- @@ -911,6 +928,22 @@ impl DialogState { result } + /// Summarize…: every command with a shortcut, as HTML and plain text. + pub(crate) fn summarize(&mut self, env: &Env) { + let title = env.text.ui(env.options.title).into_owned(); + let (name, heading) = match env.sets.as_ref() { + Some((p, b)) => { + let name = p.active_name(b).to_string(); + let heading = env.ui("{title}: {set}", &[("title", &title), ("set", &name)]); + (name, heading) + } + None => (title.clone(), title), + }; + let html = summary::html(env.keymap, env.text, &heading); + let text = summary::text(env.keymap, env.text, &heading); + self.outcome = Some(Outcome::Summary { name, html, text }); + } + /// Answers the open prompt. pub(crate) fn answer(&mut self, env: &mut Env, answer: Answer) { let Some(prompt) = self.prompt.take() else { return }; diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs new file mode 100644 index 00000000..ef2cb56b --- /dev/null +++ b/crates/keymap/src/ui/summary.rs @@ -0,0 +1,113 @@ +//! The shortcut summary behind Summarize…: every command that has a shortcut, grouped as in the +//! dialog's All Commands view, as an HTML page and as plain text. Apps can also call these for a +//! cheat sheet outside the dialog. + +use std::fmt::Write as _; + +use crate::keymap::{Binding, Keymap}; +use crate::scope::Scope; +use crate::ui::rows::{AreaId, group}; +use crate::ui::text::{Text, trigger_text}; + +struct Group { + rank: u8, + name: String, + /// (label, shortcuts as shown) + rows: Vec<(String, Vec)>, +} + +fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> String { + let chord = trigger_text(text, &b.trigger, km.platform()); + match &b.scope { + Some(s) if s != home => { + let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() }; + format!("{chord} ({scope})") + } + _ => chord, + } +} + +/// Groups in the table's order; commands in catalogue order within a group. +fn groups(km: &Keymap, text: &dyn Text) -> Vec { + let mut out: Vec = Vec::new(); + for b in km.catalogue() { + let bindings = km.bindings(&b.command); + if bindings.is_empty() { + continue; + } + let keys = bindings.iter().map(|x| binding_text(km, text, x, &b.scope)).collect(); + let (rank, name, _) = group(&AreaId::All, b, false, text); + let name = name.unwrap_or_default(); + let row = (text.command(b).into_owned(), keys); + match out.iter_mut().find(|g| g.rank == rank && g.name == name) { + Some(g) => g.rows.push(row), + None => out.push(Group { rank, name, rows: vec![row] }), + } + } + out.sort_by_key(|g| g.rank); + out +} + +/// Text for HTML element content and attribute values. +pub fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// One line of plain text: control characters (line breaks, tabs) become spaces, so a label +/// cannot break the layout. +fn one_line(s: &str) -> String { + s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +const CSS: &str = "body{font-family:system-ui,sans-serif;margin:2em;color:#111;background:#fff}\ +h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em;border-bottom:1px solid #ccc}\ +table{border-collapse:collapse;width:100%}th,td{padding:.2em .6em;vertical-align:top}\ +th{font-weight:normal;text-align:start;width:60%}tr:nth-child(even){background:#f4f4f4}\ +kbd{font-family:inherit;border:1px solid #bbb;border-radius:3px;padding:0 .3em;margin-inline-end:.3em;white-space:nowrap}\ +@media print{body{margin:0}tr{break-inside:avoid}}"; + +/// A standalone HTML page titled `title`. Chords are marked left to right so a right-to-left page +/// keeps them as written. +pub fn html(km: &Keymap, text: &dyn Text, title: &str) -> String { + let dir = if text.rtl() { "rtl" } else { "ltr" }; + let title = escape_html(title); + let mut s = String::new(); + let _ = write!( + s, + "\n\n\n\n{title}\n\n\n\n

{title}

\n" + ); + for g in groups(km, text) { + let _ = writeln!(s, "

{}

\n", escape_html(&g.name)); + for (label, keys) in &g.rows { + let keys: String = keys.iter().map(|k| format!("{}", escape_html(k))).collect(); + let _ = writeln!(s, "", escape_html(label)); + } + s.push_str("
{}{keys}
\n"); + } + s.push_str("\n\n"); + s +} + +/// Plain text titled `title`: a line per group, then ` labelshortcuts` per command. +pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String { + let mut s = one_line(title); + s.push('\n'); + for g in groups(km, text) { + let _ = write!(s, "\n{}\n", one_line(&g.name)); + for (label, keys) in &g.rows { + let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", "))); + } + } + s +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 80455b3d..ecca273d 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -110,6 +110,11 @@ pub const PLURALS: &[(&str, &str)] = &[ ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), ("{n} command will change", "{n} commands will change"), ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} item not matched", "{n} items not matched"), + ("{n} item could not be read", "{n} items could not be read"), + ("{n} key read approximately", "{n} keys read approximately"), + ("{n} entry cut at a limit", "{n} entries cut at a limit"), + ("{n} command changed", "{n} commands changed"), ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), ]; @@ -204,7 +209,11 @@ pub const STRINGS: &[&str] = &[ "Revert Set", "Import…", "Export", + "Export…", "Summarize…", + "{title}: {set}", + "Create", + "Rename", "More", "Name:", "Based on:", @@ -223,15 +232,44 @@ pub const STRINGS: &[&str] = &[ "This file is from a newer version (format {n})", "The file is too large", // Import review. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", "Will change", "Not matched", "Unreadable", "Approximated", "Suggestions", "numeric keypad key read as main key", + "Matched without change", + "Command for {item}", + "Choose a command…", + "no command has this name", + "more than one command fits", + "in a context that is not imported", + "only a command of the other kind fits (tool or menu command)", + "empty shortcut", + "the shortcut is too long", + "unknown key {key}", + "unsupported modifier {key}", + "{label} keeps its shortcuts (matched by a panel menu name)", "Import as New Set", "Imported", + "Imported as “{set}”", + "Skipped", + "Taken from other commands", + "Not changed", + "taken by {label}", + "taken by a later entry", + "reserved by the system", + "the scope is unknown", + "the set's shortcuts could not be added", + "the shortcuts could not be removed", + "the shortcut could not be taken from it", "Copy Report", + "Done", ]; #[cfg(test)] @@ -271,6 +309,9 @@ mod tests { include_str!("capture.rs"), include_str!("view.rs"), include_str!("style.rs"), + include_str!("sets.rs"), + include_str!("review.rs"), + include_str!("summary.rs"), ]; let mut missing = Vec::new(); for src in sources { diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 1618a4dd..dbb42d62 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -26,6 +26,11 @@ use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; use crate::ui::style::{self, DialogStyle}; use crate::ui::text::trigger_text; +#[path = "review.rs"] +mod review; +#[path = "sets.rs"] +mod sets; + /// Below this width the detail pane goes under the table. const NARROW: f32 = 720.0; /// Smallest table or detail pane when dragging the divider. @@ -125,6 +130,13 @@ impl Draw<'_, '_> { r.clicked() } + /// The width a [`Button`] with `text` takes. + fn button_width(&self, ui: &Ui, text: &str) -> f32 { + let pad = ui.spacing().button_padding.x * 2.0; + let g = WidgetText::from(self.vis(text)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(ui.spacing().interact_size.x) + } + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) } @@ -211,7 +223,8 @@ impl Draw<'_, '_> { let gap = ui.spacing().item_spacing.y; ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { self.sets_row(ui); - self.header(ui); + // Show, Filter and Search act on the table, which the import review replaces. + ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); let rest = ui.available_rect_before_wrap(); let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); @@ -231,15 +244,6 @@ impl Draw<'_, '_> { ui.advance_cursor_after_rect(full); } - /// The set row: drawn here, above the header, when the app passes sets (§2.6). - fn sets_row(&mut self, _ui: &mut Ui) {} - - /// The import review takes the place of the table and detail pane while it is open (§2.7). - /// Returns whether it drew. - fn review(&mut self, _ui: &mut Ui, _rect: Rect) -> bool { - false - } - fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { ui.set_clip_rect(rect.intersect(ui.clip_rect())); @@ -1177,16 +1181,14 @@ impl Draw<'_, '_> { self.state.ask_reset_all(); } } + if self.env.sets.is_none() && self.env.options.summary { + let label = self.ui("Summarize…"); + if self.button(ui, true, &label, "shortcuts.summarize") { + self.state.summarize(self.env); + } + } let gap = ui.spacing().item_spacing.x; - let pad = ui.spacing().button_padding.x * 2.0; - let min = ui.spacing().interact_size.x; - let width: f32 = right - .iter() - .map(|(t, _)| { - let g = WidgetText::from(self.vis(t)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); - (g.size().x + pad).max(min) + gap - }) - .sum(); + let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); ui.add_space((ui.available_width() - width).max(0.0)); for (text, id) in &right { if self.button(ui, true, text, id) { diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index dc88b091..05c11036 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -1,11 +1,14 @@ #![cfg(feature = "ui")] //! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, -//! platform display, accessibility labels and small windows. +//! platform display, accessibility labels, small windows, sets, import review and the summary. use std::borrow::Cow; use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; -use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{ + Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, + Platform, Profiles, Scope, Scopes, Trigger, +}; use egui::accesskit::Role; use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; use egui_kittest::Harness; @@ -20,11 +23,24 @@ struct Fx { state: DialogState, text: Box, window: bool, + /// Profiles and built-in sets, when the dialog shows sets. + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, outcomes: Vec, /// A key press was still in the frame's input after the dialog ran. keys_left: bool, } +/// The dialog of `fx` for a call outside the frame (import_json, review_import). +fn dialog(fx: &mut Fx) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, text, sets, .. } = fx; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) +} + impl Fx { fn new(km: Keymap) -> Fx { Fx { @@ -33,6 +49,7 @@ impl Fx { state: DialogState::new(), text: Box::new(English), window: false, + sets: None, outcomes: Vec::new(), keys_left: false, } @@ -50,8 +67,12 @@ impl Fx { fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { let mut h = Harness::builder().with_size(size).build_ui_state( |ui, fx: &mut Fx| { - let Fx { km, options, state, text, window, outcomes, keys_left } = fx; + let Fx { km, options, state, text, window, sets, outcomes, keys_left } = fx; let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let dialog = match sets { + Some((p, b)) => dialog.sets(p, b), + None => dialog, + }; let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; outcomes.extend(out); *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); @@ -515,3 +536,186 @@ fn rtl_puts_the_detail_pane_left_and_keeps_chords() { assert!(table.min.x > 300.0, "table on the right: {table:?}"); assert!(has(&h, "Save As…: ⇧⌘S")); } + +// ---- Sets ---- + +fn with_sets(profiles: Profiles) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((profiles, vec![("Fixture Defaults", Overrides::new())])); + open(fx) +} + +fn profiles<'h>(h: &'h Harness<'_, Fx>) -> &'h Profiles { + &h.state().sets.as_ref().expect("sets").0 +} + +/// The last control with this label: a prompt's button rather than the set row's. +fn click_last(h: &mut Harness<'_, Fx>, label: &str) { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("{label}")).click(); + h.run(); +} + +fn new_set(h: &mut Harness<'_, Fx>, name: &str) { + click(h, "New Set…"); + h.get_by_role_and_label(Role::TextInput, "Name:").type_text(name); + h.run(); + click(h, "Create"); +} + +#[test] +fn cancel_restores_keymap_and_sets() { + let mut p = Profiles::default(); + p.save_as("Mine", Overrides::new(), &[("Fixture Defaults", Overrides::new())]).expect("save_as"); + let before = p.clone(); + let mut h = with_sets(p); + click(&mut h, "Set:"); + click_last(&mut h, "Mine"); + assert_eq!(profiles(&h).active, "Mine"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert!(has(&h, "(modified)")); + new_set(&mut h, "Other"); + assert_eq!(profiles(&h).active, "Other"); + assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + click_last(&mut h, "Delete"); + assert!(!profiles(&h).sets.contains_key("Other")); + click(&mut h, "Cancel"); + assert!(h.state().cancelled()); + assert_eq!(profiles(&h), &before); + assert!(h.state().km.overrides().is_empty()); +} + +#[test] +fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut h = with_sets(Profiles::default()); + assert!(h.get_by_label("Rename…").accesskit_node().is_disabled(), "built-ins cannot be renamed"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + let p = profiles(&h); + assert_eq!(p.active, "Custom"); + assert_eq!(p.sets.get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); +} + +#[test] +fn set_export_import_round_trip() { + let mut h = with_sets(Profiles::default()); + new_set(&mut h, "Name"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + h.get_all_by_label("Export…").next().expect("the set row's Export…").click(); + h.run(); + let (name, json) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Export { name, json } = o { Some((name.clone(), json.clone())) } else { None }) + .expect("Outcome::Export"); + assert_eq!(name, "Name"); + let edited = h.state().km.overrides().clone(); + + let mut fresh = with_sets(profiles(&h).clone()); + let (d, state) = dialog(fresh.state_mut()); + assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); + fresh.run(); + assert_eq!(profiles(&fresh).active, "Name 2"); + assert_eq!(fresh.state().km.overrides(), &edited); + assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); +} + +fn foreign_set() -> ForeignSet { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + } +} + +fn review(h: &mut Harness<'_, Fx>) { + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = dialog(h.state_mut()); + d.review_import(state, foreign_set(), options); + h.run(); +} + +#[test] +fn foreign_import_review_lists_skipped_and_unreadable() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(has(&h, "Import “Theirs”")); + for (item, line) in [("File > Save", 3), ("File > Frobnicate", 4), ("File > Export…", 5), ("Object > Arrange > Bring to Front", 6)] { + let found = h.query_all_by_label_contains(item).any(|n| n.accesskit_node().value().is_some_and(|l| l.contains(&format!("line {line}")))); + assert!(found, "{item} with line {line}"); + } + assert!(h.query_by_label_contains("unknown key").is_some(), "the unreadable key says why"); + assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); + assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert_eq!(profiles(&h).active, "Theirs"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); + assert!(has(&h, "Imported as “Theirs”")); + click(&mut h, "Done"); + assert!(h.state().state.is_open() && has(&h, "Commands")); +} + +#[test] +fn picking_a_suggestion_replans_and_is_remembered() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(h.query_by_label_contains("3 commands will change").is_some()); + click(&mut h, "Command for File > Sve Az"); + click_last(&mut h, "Save As…"); + let remembered = h.state().outcomes.iter().find_map(|o| if let Outcome::Remembered(m) = o { Some(m.clone()) } else { None }); + assert!(remembered.is_some_and(|m| m.values().any(|c| c.id == "file.saveAs"))); + assert!(h.query_by_label_contains("4 commands will change").is_some()); + assert!(h.query_by_label_contains("File > Sve Az · line 7 — Save As…: ").is_some(), "the pick moved to Will change"); + assert!(h.query_by_label("Command for File > Sve Az").is_none()); +} + +// ---- Summary ---- + +#[test] +fn summary_escapes_html_and_keeps_text_lines() { + let mut km = fixture(Platform::Mac); + let mut catalogue = km.catalogue().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + catalogue.push( + Bindable::new("edit.tricky", label, Scope::app()) + .with_path(vec!["Edit".into(), "Tricky".into()]) + .with_default(craft_keymap::Binding::parse("F6").expect("F6")), + ); + km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut h = open(Fx::new(km)); + click(&mut h, "Summarize…"); + let (html, text) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Summary { html, text, .. } = o { Some((html.clone(), text.clone())) } else { None }) + .expect("Outcome::Summary"); + assert!(html.contains("<b>"Tom" & 'Jerry'</b>"), "{html}"); + assert!(!html.contains("")); + assert!(html.contains("⌘S")); + assert!(text.lines().any(|l| l == " \"Tom\" & 'Jerry' Next\tF6"), "{text}"); + assert!(text.lines().any(|l| l == " Save\t⌘S"), "{text}"); +} + +#[test] +fn narrow_set_row_moves_actions_into_more() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(560.0, 600.0)); + assert!(h.query_by_label("Summarize…").is_none(), "Summarize… is in More"); + click(&mut h, "More"); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index a0dd1f54..b232f5dc 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; -use craft_keymap::{Keymap, Platform}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -20,6 +20,19 @@ struct Fx { km: Keymap, options: Options, state: DialogState, + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &English).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) + } } /// A named state of the dialog: window size and what to do before the picture. @@ -27,9 +40,29 @@ struct Case { name: &'static str, size: Vec2, platform: Platform, + /// Show named sets. + sets: bool, act: fn(&mut Harness<'_, Fx>), } +fn foreign_review(h: &mut Harness<'_, Fx>) { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = h.state_mut().dialog(); + d.review_import(state, set, options); + h.run(); +} + fn click(h: &mut Harness<'_, Fx>, label: &str) { h.get_by_label(label).click(); h.run(); @@ -40,6 +73,7 @@ const CASES: &[Case] = &[ name: "selected", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save As…").click(); h.run(); @@ -49,22 +83,38 @@ const CASES: &[Case] = &[ name: "conflict", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { click(h, "Export…: ⌘E"); h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); h.run(); }, }, - Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") }, + Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, sets: false, act: |h| click(h, "Save: Ctrl+S") }, Case { name: "narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save").click(); h.run(); }, }, + Case { + name: "sets", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + sets: true, + act: |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }, + }, + Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, act: |h| click(h, "More") }, + Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, act: foreign_review }, ]; #[test] @@ -74,11 +124,13 @@ fn dialog_screenshots() { for case in CASES { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { - let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() }; + let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { - ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state); + let (d, state) = fx.dialog(); + d.ui(ui, state); }); }, fx,