mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 09:11:10 +00:00
631 lines
25 KiB
Rust
631 lines
25 KiB
Rust
//! The table's model: areas (Show), filters, search, group headers, sorting and the cached list of
|
||
//! lines the table draws.
|
||
|
||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||
|
||
use crate::chord::{Platform, Trigger};
|
||
use crate::command::CommandRef;
|
||
use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides};
|
||
use crate::ui::Options;
|
||
use crate::ui::text::{Text, trigger_text, visual_around};
|
||
|
||
/// One entry of the Show menu.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
pub(crate) enum AreaId {
|
||
All,
|
||
/// A top menu, `paths[0][0]`.
|
||
Menu(String),
|
||
Tools,
|
||
/// A panel scope, by name.
|
||
Panel(String),
|
||
/// Commands with no path that are not tools.
|
||
Other,
|
||
/// An app area from [`Options::areas`], by id.
|
||
App(&'static str),
|
||
/// [`Options::fixed`].
|
||
Fixed,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct AreaEntry {
|
||
pub id: AreaId,
|
||
pub label: String,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub(crate) enum Filter {
|
||
#[default]
|
||
All,
|
||
WithShortcut,
|
||
/// An overrides entry exists for the command.
|
||
Changed,
|
||
/// The command is in a [`ConflictKind::Clash`].
|
||
Conflicts,
|
||
}
|
||
|
||
impl Filter {
|
||
pub(crate) const ALL: [Filter; 4] = [Filter::All, Filter::WithShortcut, Filter::Changed, Filter::Conflicts];
|
||
|
||
pub(crate) fn label(self, text: &dyn Text) -> String {
|
||
match self {
|
||
Filter::All => text.ui("All"),
|
||
Filter::WithShortcut => text.ui("With Shortcut"),
|
||
Filter::Changed => text.ui("Changed"),
|
||
Filter::Conflicts => text.ui("Conflicts"),
|
||
}
|
||
.into_owned()
|
||
}
|
||
}
|
||
|
||
/// A binding as a row shows it.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct Chip {
|
||
pub binding: Binding,
|
||
/// Trigger text, plus ` (Scope)` for a scope other than the command's home scope (display order).
|
||
pub text: String,
|
||
/// Not one of the command's defaults.
|
||
pub changed: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct Row {
|
||
/// Position in the catalog.
|
||
pub index: usize,
|
||
pub command: CommandRef,
|
||
/// [`CommandRef::key`], for automation ids and the overrides.
|
||
pub key: String,
|
||
/// The translated label.
|
||
pub label: String,
|
||
/// Translated path parts between the group header and the label, shown dim before it.
|
||
pub prefix: Vec<String>,
|
||
pub chips: Vec<Chip>,
|
||
/// An overrides entry exists.
|
||
pub changed: bool,
|
||
/// A tool sharing a key with another tool of its group.
|
||
pub cycles: bool,
|
||
/// In a clash.
|
||
pub conflict: bool,
|
||
}
|
||
|
||
/// A read-only row from [`Options::fixed`].
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct FixedLine {
|
||
pub label: String,
|
||
pub keys: String,
|
||
/// The label of a catalog command now bound to the row's chord: drawn struck through.
|
||
pub now_used_by: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) enum Line {
|
||
Header { name: String, collapsed: bool },
|
||
Row(Row),
|
||
Fixed(FixedLine),
|
||
}
|
||
|
||
/// The lines for one query, area, filter and set of overrides.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub(crate) struct Built {
|
||
pub lines: Vec<Line>,
|
||
/// Command and fixed rows shown (headers not counted); 0 shows "No matching commands".
|
||
pub matches: usize,
|
||
/// Clash pairs in the whole keymap.
|
||
pub clashes: usize,
|
||
/// Shadow pairs in the whole keymap, shown as an information line.
|
||
pub shadows: usize,
|
||
}
|
||
|
||
impl Built {
|
||
/// The line index of a command's row.
|
||
pub(crate) fn position(&self, command: &CommandRef) -> Option<usize> {
|
||
self.lines.iter().position(|l| matches!(l, Line::Row(r) if r.command == *command))
|
||
}
|
||
}
|
||
|
||
/// What the lines were built from.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct View<'a> {
|
||
pub query: &'a str,
|
||
pub area: &'a AreaId,
|
||
pub filter: Filter,
|
||
pub collapsed: &'a BTreeSet<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct Key {
|
||
query: String,
|
||
area: AreaId,
|
||
filter: Filter,
|
||
collapsed: BTreeSet<String>,
|
||
overrides: Overrides,
|
||
platform: Platform,
|
||
}
|
||
|
||
/// The built lines, rebuilt only when the view or the keymap's overrides change.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub(crate) struct RowCache {
|
||
key: Option<Key>,
|
||
built: Built,
|
||
}
|
||
|
||
impl RowCache {
|
||
pub(crate) fn get(&mut self, keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> &Built {
|
||
let fresh = self.key.as_ref().is_some_and(|k| {
|
||
k.query == view.query
|
||
&& k.area == *view.area
|
||
&& k.filter == view.filter
|
||
&& k.collapsed == *view.collapsed
|
||
&& k.platform == keymap.platform()
|
||
&& k.overrides == *keymap.overrides()
|
||
});
|
||
if !fresh {
|
||
self.built = build(keymap, options, text, view);
|
||
self.key = Some(Key {
|
||
query: view.query.to_string(),
|
||
area: view.area.clone(),
|
||
filter: view.filter,
|
||
collapsed: view.collapsed.clone(),
|
||
overrides: keymap.overrides().clone(),
|
||
platform: keymap.platform(),
|
||
});
|
||
}
|
||
&self.built
|
||
}
|
||
|
||
/// The lines last built.
|
||
pub(crate) fn built(&self) -> &Built {
|
||
&self.built
|
||
}
|
||
}
|
||
|
||
fn top_menu(b: &Bindable) -> Option<&str> {
|
||
b.paths.first().and_then(|p| p.first()).map(String::as_str)
|
||
}
|
||
|
||
fn is_tool(b: &Bindable) -> bool {
|
||
matches!(b.kind, Kind::Tool { .. })
|
||
}
|
||
|
||
/// The Show entries, in catalog order, leaving out areas with no rows.
|
||
pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec<AreaEntry> {
|
||
let catalog = keymap.catalog();
|
||
let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }];
|
||
let mut menus: Vec<&str> = Vec::new();
|
||
for m in catalog.iter().filter_map(top_menu) {
|
||
if !menus.contains(&m) {
|
||
menus.push(m);
|
||
}
|
||
}
|
||
for m in menus {
|
||
let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]);
|
||
out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label });
|
||
}
|
||
if catalog.iter().any(is_tool) {
|
||
out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() });
|
||
}
|
||
let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect();
|
||
for b in catalog.iter().filter(|b| !b.scope.is_app()) {
|
||
if !panels.contains(&b.scope.as_str()) {
|
||
panels.push(b.scope.as_str());
|
||
}
|
||
}
|
||
for p in panels.into_iter().filter(|p| catalog.iter().any(|b| b.scope.as_str() == *p)) {
|
||
out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) });
|
||
}
|
||
if catalog.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) {
|
||
out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() });
|
||
}
|
||
for a in &options.areas {
|
||
out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() });
|
||
}
|
||
if !options.fixed.is_empty() {
|
||
out.push(AreaEntry { id: AreaId::Fixed, label: text.ui("Fixed Keys").into_owned() });
|
||
}
|
||
out
|
||
}
|
||
|
||
fn panel_label(text: &dyn Text, panel: &str) -> String {
|
||
craft_i18n::fmt(&text.ui("{panel} Panel"), &[("panel", &text.segment(panel))])
|
||
}
|
||
|
||
fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool {
|
||
match area {
|
||
AreaId::All => true,
|
||
AreaId::Menu(m) => top_menu(b) == Some(m.as_str()),
|
||
AreaId::Tools => is_tool(b),
|
||
AreaId::Panel(p) => b.scope.as_str() == p,
|
||
AreaId::Other => top_menu(b).is_none() && !is_tool(b),
|
||
AreaId::App(id) => options.areas.iter().any(|a| a.id == *id && (a.rows)(b)),
|
||
AreaId::Fixed => false,
|
||
}
|
||
}
|
||
|
||
/// The first id segment, capitalized: `layer.lock` → `Layer`.
|
||
fn id_group(id: &str) -> String {
|
||
let first = id.split('.').next().unwrap_or(id);
|
||
let mut chars = first.chars();
|
||
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
|
||
}
|
||
|
||
/// A row's group header (`None`: no header) with its rank among groups, and how many path parts
|
||
/// the header stands for.
|
||
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 {
|
||
AreaId::All => menu(1).unwrap_or_else(|| {
|
||
if let Kind::Tool { .. } = b.kind {
|
||
(1, Some(text.ui("Tools").into_owned()), 0)
|
||
} else if !b.scope.is_app() {
|
||
(2, Some(panel_label(text, b.scope.as_str())), 0)
|
||
} else {
|
||
(3, Some(text.ui("Other").into_owned()), 0)
|
||
}
|
||
}),
|
||
AreaId::Menu(_) if submenus && path.len() >= 3 => (0, path.get(1).map(|s| text.segment(s).into_owned()), 2),
|
||
AreaId::Menu(_) => (0, None, 1),
|
||
AreaId::Tools => match &b.kind {
|
||
Kind::Tool { group: Some(g) } => (0, Some(text.segment(g).into_owned()), usize::MAX),
|
||
_ => (0, None, usize::MAX),
|
||
},
|
||
AreaId::Other => (0, Some(id_group(&b.command.id)), usize::MAX),
|
||
AreaId::Panel(_) | AreaId::App(_) | AreaId::Fixed => menu(1).unwrap_or((0, None, usize::MAX)),
|
||
}
|
||
}
|
||
|
||
/// What a query asks for: lowercase text, and the trigger it spells, if any.
|
||
struct Query {
|
||
text: String,
|
||
trigger: Option<Trigger>,
|
||
}
|
||
|
||
impl Query {
|
||
fn new(q: &str, platform: Platform) -> Query {
|
||
let text = q.trim().to_lowercase();
|
||
let trigger = Trigger::parse(q.trim()).ok().map(|t| t.effective(platform));
|
||
Query { text, trigger }
|
||
}
|
||
|
||
fn hit(&self, s: &str) -> bool {
|
||
s.to_lowercase().contains(&self.text)
|
||
}
|
||
|
||
/// Label, aliases, path parts and id, as written and translated; then the bindings: the same
|
||
/// effective trigger when the query spells one, else their canonical or display text. A query
|
||
/// that spells a chord (`cmd+s`, `⌘S`) does not also match longer chords containing its text.
|
||
fn matches(&self, b: &Bindable, bindings: &[Binding], text: &dyn Text, platform: Platform) -> bool {
|
||
if self.text.is_empty() {
|
||
return true;
|
||
}
|
||
let named = self.hit(&b.label)
|
||
|| self.hit(&text.command(b))
|
||
|| b.aliases.iter().any(|a| self.hit(a))
|
||
|| b.paths.iter().flatten().any(|s| self.hit(s) || self.hit(&text.segment(s)))
|
||
|| self.hit(&b.command.id);
|
||
named
|
||
|| match self.trigger {
|
||
Some(t) => bindings.iter().any(|x| x.trigger.effective(platform) == t),
|
||
None => bindings.iter().any(|x| {
|
||
self.hit(&x.trigger.to_string()) || self.hit(&x.trigger.display(platform)) || self.hit(&trigger_text(text, &x.trigger, platform))
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built {
|
||
let platform = keymap.platform();
|
||
let catalog = keymap.catalog();
|
||
let mut built = Built::default();
|
||
let mut clashing: HashSet<String> = HashSet::new();
|
||
for c in keymap.conflicts() {
|
||
match c.kind {
|
||
ConflictKind::Clash => {
|
||
built.clashes += 1;
|
||
clashing.insert(c.a.0.key());
|
||
clashing.insert(c.b.0.key());
|
||
}
|
||
ConflictKind::Shadow => built.shadows += 1,
|
||
}
|
||
}
|
||
let query = Query::new(view.query, platform);
|
||
if *view.area == AreaId::Fixed {
|
||
build_fixed(keymap, options, text, view, &query, &mut built);
|
||
return built;
|
||
}
|
||
|
||
let bindings: Vec<Vec<Binding>> = catalog.iter().map(|b| keymap.bindings(&b.command)).collect();
|
||
// How many tools of a group hold each effective trigger.
|
||
let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new();
|
||
for (b, list) in catalog.iter().zip(&bindings) {
|
||
if let Kind::Tool { group } = &b.kind {
|
||
for x in list {
|
||
*tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1;
|
||
}
|
||
}
|
||
}
|
||
let submenus = match view.area {
|
||
AreaId::Menu(m) => catalog.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)),
|
||
_ => false,
|
||
};
|
||
|
||
// Groups in first-seen order within their rank.
|
||
let mut groups: Vec<(u8, Option<String>, Vec<Row>)> = Vec::new();
|
||
for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() {
|
||
if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) {
|
||
continue;
|
||
}
|
||
let key = b.command.key();
|
||
let changed = keymap.overrides().get(&key).is_some();
|
||
let conflict = clashing.contains(&key);
|
||
let keep = match view.filter {
|
||
Filter::All => true,
|
||
Filter::WithShortcut => !list.is_empty(),
|
||
Filter::Changed => changed,
|
||
Filter::Conflicts => conflict,
|
||
};
|
||
if !keep {
|
||
continue;
|
||
}
|
||
let (rank, header, depth) = group(view.area, b, submenus, text);
|
||
let path = b.paths.first().map(Vec::as_slice).unwrap_or_default();
|
||
let prefix = path.get(depth..path.len().saturating_sub(1)).unwrap_or_default().iter().map(|s| text.segment(s).into_owned()).collect();
|
||
let defaults = &b.defaults;
|
||
let chips = list
|
||
.iter()
|
||
.map(|x| {
|
||
let mut t = trigger_text(text, &x.trigger, platform);
|
||
if x.scope_or(&b.scope) != &b.scope {
|
||
let scope = text.segment(x.scope_or(&b.scope).as_str());
|
||
let s = craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &t), ("scope", &scope)]);
|
||
t = visual_around(text, &s, &t);
|
||
}
|
||
Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) }
|
||
})
|
||
.collect();
|
||
let cycles = match &b.kind {
|
||
Kind::Tool { group } => list.iter().any(|x| tool_keys.get(&(group.as_deref(), x.trigger.effective(platform))).is_some_and(|n| *n > 1)),
|
||
Kind::Command => false,
|
||
};
|
||
let row = Row { index, command: b.command.clone(), key, label: text.command(b).into_owned(), prefix, chips, changed, cycles, conflict };
|
||
match groups.iter_mut().find(|(r, h, _)| *r == rank && *h == header) {
|
||
Some((_, _, rows)) => rows.push(row),
|
||
None => groups.push((rank, header, vec![row])),
|
||
}
|
||
}
|
||
groups.sort_by_key(|(rank, _, _)| *rank);
|
||
for (_, header, mut rows) in groups {
|
||
if options.sort_by_label {
|
||
rows.sort_by_cached_key(|r| r.label.to_lowercase());
|
||
}
|
||
built.matches += rows.len();
|
||
let collapsed = header.as_ref().is_some_and(|h| view.collapsed.contains(h));
|
||
if let Some(name) = header {
|
||
built.lines.push(Line::Header { name, collapsed });
|
||
}
|
||
if !collapsed {
|
||
built.lines.extend(rows.into_iter().map(Line::Row));
|
||
}
|
||
}
|
||
built
|
||
}
|
||
|
||
fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, query: &Query, built: &mut Built) {
|
||
if matches!(view.filter, Filter::Changed | Filter::Conflicts) {
|
||
return;
|
||
}
|
||
let platform = keymap.platform();
|
||
let mut groups: Vec<(&str, Vec<FixedLine>)> = Vec::new();
|
||
for f in &options.fixed {
|
||
if !query.text.is_empty()
|
||
&& !(query.hit(&f.label)
|
||
|| query.hit(&f.keys)
|
||
|| query.hit(&f.group)
|
||
|| query.trigger.is_some_and(|t| f.chord.is_some_and(|c| c.effective(platform) == t)))
|
||
{
|
||
continue;
|
||
}
|
||
let now_used_by = f.chord.and_then(|c| {
|
||
let c = c.effective(platform);
|
||
keymap
|
||
.catalog()
|
||
.iter()
|
||
.find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c))
|
||
.map(|b| text.command(b).into_owned())
|
||
});
|
||
let line = FixedLine { label: f.label.clone(), keys: f.keys.clone(), now_used_by };
|
||
match groups.iter_mut().find(|(g, _)| *g == f.group) {
|
||
Some((_, lines)) => lines.push(line),
|
||
None => groups.push((&f.group, vec![line])),
|
||
}
|
||
}
|
||
for (name, lines) in groups {
|
||
built.matches += lines.len();
|
||
let collapsed = view.collapsed.contains(name);
|
||
built.lines.push(Line::Header { name: name.to_string(), collapsed });
|
||
if !collapsed {
|
||
built.lines.extend(lines.into_iter().map(Line::Fixed));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) mod tests {
|
||
use std::borrow::Cow;
|
||
|
||
use super::*;
|
||
use crate::keymap::Bindable;
|
||
use crate::scope::{Scope, Scopes};
|
||
use crate::ui::text::English;
|
||
use crate::ui::{Area, FixedRow};
|
||
|
||
fn key(text: &str) -> Binding {
|
||
Binding::parse(text).unwrap_or_else(|_| Binding::new(Trigger::single(crate::Chord::from(crate::Key::F35))))
|
||
}
|
||
|
||
/// The fixture of the dialog tests: Save, Save As, Export, Arrange › Bring to Front, two
|
||
/// tools sharing M, Timeline Loop, Bold in the Text context.
|
||
pub(crate) fn fixture(platform: Platform) -> Keymap {
|
||
let tool = Kind::Tool { group: Some("Marquee".into()) };
|
||
let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||
let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app);
|
||
let catalog = vec![
|
||
Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")),
|
||
Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")),
|
||
Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")),
|
||
Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])),
|
||
Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(key("M")),
|
||
Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(key("M")),
|
||
Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")),
|
||
Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")),
|
||
];
|
||
Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform)
|
||
}
|
||
|
||
fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet<String>) -> View<'a> {
|
||
View { query, area, filter, collapsed }
|
||
}
|
||
|
||
fn labels(built: &Built) -> Vec<String> {
|
||
built
|
||
.lines
|
||
.iter()
|
||
.map(|l| match l {
|
||
Line::Header { name, .. } => format!("# {name}"),
|
||
Line::Row(r) => {
|
||
let chips: Vec<&str> = r.chips.iter().map(|c| c.text.as_str()).collect();
|
||
let cycles = if r.cycles { " (cycles)" } else { "" };
|
||
format!("{}{}: {}{cycles}", r.prefix.iter().map(|p| format!("{p} › ")).collect::<String>(), r.label, chips.join(", "))
|
||
}
|
||
Line::Fixed(f) => format!("{} = {}{}", f.label, f.keys, f.now_used_by.as_ref().map(|l| format!(" (now {l})")).unwrap_or_default()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn lines(km: &Keymap, options: &Options, text: &dyn Text, query: &str, area: AreaId, filter: Filter) -> Vec<String> {
|
||
let none = BTreeSet::new();
|
||
labels(&build(km, options, text, &view(query, &area, filter, &none)))
|
||
}
|
||
|
||
#[test]
|
||
fn areas_follow_paths_kind_and_scope() {
|
||
let km = fixture(Platform::Mac);
|
||
let options = Options { areas: vec![Area { id: "type", label: "Type", rows: |b| b.command.id.starts_with("type.") }], ..Options::default() };
|
||
let names: Vec<String> = areas(&km, &options, &English).into_iter().map(|a| a.label).collect();
|
||
assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other", "Type"]);
|
||
|
||
let all = lines(&km, &options, &English, "", AreaId::All, Filter::All);
|
||
assert_eq!(
|
||
all,
|
||
[
|
||
"# File",
|
||
"Save: ⌘S",
|
||
"Save As…: ⇧⌘S",
|
||
"Export…: ⌘E",
|
||
"# Object",
|
||
"Arrange › Bring to Front: ",
|
||
"# Tools",
|
||
"Marquee Tool: M (cycles)",
|
||
"Elliptical Marquee Tool: M (cycles)",
|
||
"# Timeline Panel",
|
||
"Loop: L",
|
||
"# Other",
|
||
"Bold: ⌘B (Text)",
|
||
]
|
||
);
|
||
assert_eq!(lines(&km, &options, &English, "", AreaId::Menu("Object".into()), Filter::All), ["# Arrange", "Bring to Front: "]);
|
||
assert_eq!(lines(&km, &options, &English, "", AreaId::Panel("Timeline".into()), Filter::All), ["Loop: L"]);
|
||
assert_eq!(lines(&km, &options, &English, "", AreaId::Other, Filter::All), ["# Timeline", "Loop: L", "# Type", "Bold: ⌘B (Text)"]);
|
||
}
|
||
|
||
#[test]
|
||
fn sort_by_label_and_collapsed_groups() {
|
||
let km = fixture(Platform::Mac);
|
||
let options = Options { sort_by_label: true, ..Options::default() };
|
||
let area = AreaId::Menu("File".into());
|
||
let none = BTreeSet::new();
|
||
assert_eq!(labels(&build(&km, &options, &English, &view("", &area, Filter::All, &none))), ["Export…: ⌘E", "Save: ⌘S", "Save As…: ⇧⌘S"]);
|
||
let collapsed: BTreeSet<String> = ["File".to_string()].into();
|
||
let built = build(&km, &options, &English, &view("", &AreaId::All, Filter::All, &collapsed));
|
||
assert_eq!(built.lines.first(), Some(&Line::Header { name: "File".into(), collapsed: true }));
|
||
assert!(matches!(built.lines.get(1), Some(Line::Header { .. })));
|
||
}
|
||
|
||
struct Spanish;
|
||
impl Text for Spanish {
|
||
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
|
||
source.into()
|
||
}
|
||
fn plural(&self, n: u64, one: &str, other: &str) -> String {
|
||
English.plural(n, one, other)
|
||
}
|
||
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
|
||
if b.command.id == "file.export" { "Exportar…".into() } else { b.label.as_str().into() }
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn search_by_name_translation_or_chord() {
|
||
let km = fixture(Platform::Mac);
|
||
let o = Options::default();
|
||
let find = |q: &str| -> Vec<String> {
|
||
let none = BTreeSet::new();
|
||
build(&km, &o, &Spanish, &view(q, &AreaId::All, Filter::All, &none))
|
||
.lines
|
||
.into_iter()
|
||
.filter_map(|l| if let Line::Row(r) = l { Some(r.key) } else { None })
|
||
.collect()
|
||
};
|
||
assert_eq!(find("save"), ["file.save", "file.saveAs"]);
|
||
assert_eq!(find("exportar"), ["file.export"]);
|
||
assert_eq!(find("cmd+s"), ["file.save"]);
|
||
assert_eq!(find("⌘S"), ["file.save"]);
|
||
assert_eq!(find("⇧⌘S"), ["file.saveAs"]);
|
||
assert_eq!(find("Text: Cmd+B"), Vec::<String>::new(), "a binding text with a scope is not a trigger");
|
||
assert_eq!(find("cmd+b"), ["type.bold"], "a trigger matches in any scope");
|
||
assert_eq!(find("arrange"), ["object.front"]);
|
||
assert!(find("zzz").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn filters_and_conflict_counts() -> Result<(), crate::AssignError> {
|
||
let mut km = fixture(Platform::Windows);
|
||
let o = Options::default();
|
||
km.assign(&"file.export".into(), key("Cmd+S"), crate::Policy::Keep)?;
|
||
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Changed), ["# File", "Export…: Ctrl+E, Ctrl+S"]);
|
||
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: Ctrl+S", "Export…: Ctrl+E, Ctrl+S"]);
|
||
assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("Object".into()), Filter::WithShortcut), Vec::<String>::new());
|
||
let none = BTreeSet::new();
|
||
let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none));
|
||
assert_eq!((built.clashes, built.matches), (1, 8));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn fixed_rows_strike_through_when_a_command_takes_their_chord() {
|
||
let km = fixture(Platform::Mac);
|
||
let fixed = |label: &str, keys: &str, chord: Option<&str>| FixedRow {
|
||
group: "Rating".into(),
|
||
label: label.into(),
|
||
keys: keys.into(),
|
||
chord: chord.and_then(|c| Trigger::parse(c).ok()),
|
||
};
|
||
let o = Options { fixed: vec![fixed("Rate 1", "1", Some("1")), fixed("Loop", "L", Some("L"))], ..Options::default() };
|
||
assert_eq!(lines(&km, &o, &English, "", AreaId::Fixed, Filter::All), ["# Rating", "Rate 1 = 1", "Loop = L (now Loop)"]);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_rebuilds_on_overrides_and_view_changes() -> Result<(), crate::AssignError> {
|
||
let mut km = fixture(Platform::Mac);
|
||
let o = Options::default();
|
||
let mut cache = RowCache::default();
|
||
let none = BTreeSet::new();
|
||
let area = AreaId::Menu("File".into());
|
||
let before = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone();
|
||
km.assign(&"file.save".into(), key("F2"), crate::Policy::Steal)?;
|
||
let after = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone();
|
||
assert_ne!(before, after);
|
||
assert_eq!(cache.get(&km, &o, &English, &view("save as", &area, Filter::All, &none)).matches, 1);
|
||
Ok(())
|
||
}
|
||
}
|