mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 01:00:48 +00:00
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:
1 parent
39f343c47b
commit
d2b9f91838
10 files changed
+1019
-39
No files matched your search
@@ -19,6 +19,7 @@ mod capture;
|
||||
mod rows;
|
||||
mod state;
|
||||
mod style;
|
||||
pub mod summary;
|
||||
mod text;
|
||||
mod view;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => 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,
|
||||
"<!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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("<b>"Tom" & 'Jerry'</b>"), "{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")));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user