blob: fef475c23a29b73fbe3150c1f07cfb874ae9b02b [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Declarative command scopes. Native editing gets first refusal; the nearest
//! matching scope owns an unconsumed chord, including when disabled.
//! 声明式快捷键作用域;原生编辑优先,最近的声明独占尚未消费的按键。
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Shortcut {
#[schemars(regex(
pattern = "^([a-z0-9]|escape|enter|tab|delete|space|up|down|left|right|home|end|pageup|pagedown|f([1-9]|1[0-2]))$"
))]
pub key: String,
#[schemars(length(max = 4))]
pub modifiers: Vec<Modifier>,
#[schemars(length(min = 1))]
pub input: String,
pub enabled: bool,
}
/// Route navigation yielded by a native editor to a declared child component.
/// Ordinary unhandled button keys do not activate this binding.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FocusShortcut {
pub key: String,
pub modifiers: Vec<Modifier>,
pub child: String,
pub enabled: bool,
}
impl FocusShortcut {
pub fn matches(&self, key: &str, modifiers: Modifiers) -> bool {
self.key == key && physical(&self.modifiers, false) == modifiers
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Modifier {
Control,
Primary,
Alt,
Shift,
Super,
}
/// Physical modifiers after the presenter resolves Primary.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct Modifiers {
pub control: bool,
pub alt: bool,
pub shift: bool,
pub super_key: bool,
}
impl Shortcut {
pub fn physical(&self, primary_is_super: bool) -> Modifiers {
physical(&self.modifiers, primary_is_super)
}
pub fn matches(&self, key: &str, modifiers: Modifiers, primary_is_super: bool) -> bool {
self.key == key && self.physical(primary_is_super) == modifiers
}
}
fn physical(modifiers: &[Modifier], primary_is_super: bool) -> Modifiers {
let mut result = Modifiers::default();
for modifier in modifiers {
match modifier {
Modifier::Control => result.control = true,
Modifier::Primary if primary_is_super => result.super_key = true,
Modifier::Primary => result.control = true,
Modifier::Alt => result.alt = true,
Modifier::Shift => result.shift = true,
Modifier::Super => result.super_key = true,
}
}
result
}
pub fn validate(shortcuts: &[Shortcut], primary_is_super: bool) -> Result<(), String> {
validate_chords(
shortcuts
.iter()
.map(|s| (s.key.as_str(), s.modifiers.as_slice(), s.input.as_str())),
primary_is_super,
)
}
pub fn validate_focus(shortcuts: &[Shortcut], focus: &[FocusShortcut]) -> Result<(), String> {
validate_chords(
shortcuts
.iter()
.map(|s| (s.key.as_str(), s.modifiers.as_slice(), s.input.as_str()))
.chain(
focus
.iter()
.map(|s| (s.key.as_str(), s.modifiers.as_slice(), s.child.as_str())),
),
false,
)
}
fn validate_chords<'a>(
bindings: impl IntoIterator<Item = (&'a str, &'a [Modifier], &'a str)>,
primary_is_super: bool,
) -> Result<(), String> {
let mut chords = BTreeSet::new();
for (key, modifiers, target) in bindings {
let printable = key == "space"
|| key.len() == 1
&& key
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let named = matches!(
key,
"escape"
| "enter"
| "tab"
| "delete"
| "space"
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "pageup"
| "pagedown"
) || key
.strip_prefix('f')
.and_then(|n| n.parse::<u8>().ok())
.is_some_and(|n| (1..=12).contains(&n) && key == format!("f{n}"));
let physical = physical(modifiers, primary_is_super);
let unique_modifiers = modifiers
.iter()
.enumerate()
.all(|(i, m)| !modifiers[..i].contains(m));
if (!printable && !named)
|| target.is_empty()
|| modifiers.len() > 4
|| !unique_modifiers
|| (printable && !physical.control && !physical.alt && !physical.super_key)
|| !chords.insert((key, physical))
{
return Err("invalid or duplicate shortcut chord".into());
}
}
Ok(())
}
/// Merge command inputs with ordinary buttons, never with a typed native input.
/// The input table is derived from the finite scene declaration.
pub fn inputs(
shortcuts: &[Shortcut],
inputs: &mut BTreeMap<String, serde_json::Value>,
) -> Result<(), String> {
for shortcut in shortcuts.iter().filter(|shortcut| shortcut.enabled) {
if let Some(schema) = inputs.get(&shortcut.input)
&& schema != &serde_json::Value::Bool(true)
{
return Err("shortcut collides with typed native input".into());
}
inputs.insert(shortcut.input.clone(), serde_json::Value::Bool(true));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn shortcut(key: &str, modifiers: Vec<Modifier>) -> Shortcut {
Shortcut {
key: key.into(),
modifiers,
input: "command".into(),
enabled: true,
}
}
#[test]
fn physical_chords_are_unambiguous_and_leave_typing_native() {
assert!(validate(&[shortcut("b", vec![])], false).is_err());
assert!(validate(&[shortcut("b", vec![Modifier::Shift])], false).is_err());
let primary = shortcut("b", vec![Modifier::Primary]);
let control = shortcut("b", vec![Modifier::Control]);
assert!(validate(&[primary.clone(), control.clone()], false).is_err());
assert!(validate(&[primary, control], true).is_ok());
assert!(validate(&[shortcut("escape", vec![])], false).is_ok());
assert!(validate(&[shortcut("space", vec![])], false).is_err());
assert!(validate(&[shortcut("f01", vec![])], false).is_err());
assert!(
validate(
&[shortcut("b", vec![Modifier::Control, Modifier::Control])],
false
)
.is_err()
);
}
#[test]
fn commands_merge_only_with_buttons_without_an_input_count_cap() {
let command = shortcut("b", vec![Modifier::Primary]);
let mut table = BTreeMap::from([("command".into(), json!(true))]);
inputs(std::slice::from_ref(&command), &mut table).unwrap();
assert_eq!(table.len(), 1);
table.insert("command".into(), json!({"type":"object"}));
assert!(inputs(std::slice::from_ref(&command), &mut table).is_err());
let mut table = (0..1024)
.map(|n| (format!("input-{n}"), json!(true)))
.collect();
inputs(&[command], &mut table).unwrap();
assert_eq!(table.len(), 1025);
}
}