keymap: shortcuts dialog sets, import review and summary

Set row with picker, New Set (based on current shortcuts or a set),
Rename, Delete, Save, Revert Set, Export, Import and Summarize; actions
that do not fit go into a More menu. The foreign import review lists
what will change, suggestions to pick, unmatched, unreadable,
approximated and matched-without-change entries, then the report of
skipped, taken and failed changes with Copy Report. ui::summary writes
the shortcut summary as HTML and plain text.
This commit is contained in:
aiongg committed 2026-10-19 12:03:23 +00:00
1 parent 39f343c47b
commit d2b9f91838
10 files changed
+1019 -39

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ mod capture;
mod rows;
mod state;
mod style;
pub mod summary;
mod text;
mod view;
+323
View File
@@ -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<usize>,
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<usize>) -> Option<String> {
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<Act> {
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::<Vec<_>>().join(", ")
};
let changes: Vec<Entry> = 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<Entry> = 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<Entry> = 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<Entry> = 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<Entry> = 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<Entry>); 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<Act> {
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<Entry>)]) -> 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
}
}
+1 -7
View File
@@ -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<String>, usize) {
pub(crate) fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option<String>, 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 {
+217
View File
@@ -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<Item> {
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<Act> {
let more = self.ui("More");
let natural: Vec<f32> = items.iter().map(|i| self.button_width(ui, &i.text)).collect();
let spacing = ui.spacing().item_spacing.x;
let (shown, overflow): (Vec<usize>, Vec<usize>) = 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<Act> {
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(&current)).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);
}
}
+39 -6
View File
@@ -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<String>,
},
Rename,
}
#[derive(Clone, Debug)]
struct Opened {
profiles: Option<Profiles>,
@@ -199,6 +214,7 @@ pub struct DialogState {
pub(crate) text_focus: bool,
/// The table lines drawn last frame.
pub(crate) visible: std::ops::Range<usize>,
pub(crate) set_form: Option<SetForm>,
}
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 };
+113
View File
@@ -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<String>)>,
}
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<Group> {
let mut out: Vec<Group> = 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
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,
"<!DOCTYPE html>\n<html dir=\"{dir}\">\n<head>\n<meta charset=\"utf-8\">\n<title>{title}</title>\n<style>{CSS}</style>\n</head>\n<body>\n<h1>{title}</h1>\n"
);
for g in groups(km, text) {
let _ = writeln!(s, "<h2>{}</h2>\n<table>", escape_html(&g.name));
for (label, keys) in &g.rows {
let keys: String = keys.iter().map(|k| format!("<kbd dir=\"ltr\">{}</kbd>", escape_html(k))).collect();
let _ = writeln!(s, "<tr><th scope=\"row\">{}</th><td>{keys}</td></tr>", escape_html(label));
}
s.push_str("</table>\n");
}
s.push_str("</body>\n</html>\n");
s
}
/// Plain text titled `title`: a line per group, then ` label<TAB>shortcuts` 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
}
+41
View File
@@ -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 {
+21 -19
View File
@@ -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) {
+207 -3
View File
@@ -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<dyn Text>,
window: bool,
/// Profiles and built-in sets, when the dialog shows sets.
sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>,
outcomes: Vec<Outcome>,
/// 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 = "<b>\"Tom\" & 'Jerry'</b>\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("&lt;b&gt;&quot;Tom&quot; &amp; &#39;Jerry&#39;&lt;/b&gt;"), "{html}");
assert!(!html.contains("<b>"));
assert!(html.contains("<kbd dir=\"ltr\">⌘S</kbd>"));
assert!(text.lines().any(|l| l == " <b>\"Tom\" & 'Jerry'</b> 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")));
}
+56 -4
View File
@@ -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,