Files
oak-gpui/crates/gpui/src/platform/keystroke.rs
T
张小白 58ba833792 windows: Fix keystroke (#30753)
Closes #22656

Part of #29144, this PR completely rewrites the key handling logic on
Windows, making it much more consistent with how things work on macOS.
However, one remaining issue is that on Windows, we should be using
`Ctrl+Shift+4` instead of `Ctrl+$`. That part is expected to be
addressed in #29144.


Release Notes:

- N/A
2025-05-15 20:49:06 +08:00

545 lines
16 KiB
Rust

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
error::Error,
fmt::{Display, Write},
};
/// A keystroke and associated metadata generated by the platform
#[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)]
pub struct Keystroke {
/// the state of the modifier keys at the time the keystroke was generated
pub modifiers: Modifiers,
/// key is the character printed on the key that was pressed
/// e.g. for option-s, key is "s"
pub key: String,
/// key_char is the character that could have been typed when
/// this binding was pressed.
/// e.g. for s this is "s", for option-s "ß", and cmd-s None
pub key_char: Option<String>,
}
/// Error type for `Keystroke::parse`. This is used instead of `anyhow::Error` so that Zed can use
/// markdown to display it.
#[derive(Debug)]
pub struct InvalidKeystrokeError {
/// The invalid keystroke.
pub keystroke: String,
}
impl Error for InvalidKeystrokeError {}
impl Display for InvalidKeystrokeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Invalid keystroke \"{}\". {}",
self.keystroke, KEYSTROKE_PARSE_EXPECTED_MESSAGE
)
}
}
/// Sentence explaining what keystroke parser expects, starting with "Expected ..."
pub const KEYSTROKE_PARSE_EXPECTED_MESSAGE: &str = "Expected a sequence of modifiers \
(`ctrl`, `alt`, `shift`, `fn`, `cmd`, `super`, or `win`) \
followed by a key, separated by `-`.";
impl Keystroke {
/// When matching a key we cannot know whether the user intended to type
/// the key_char or the key itself. On some non-US keyboards keys we use in our
/// bindings are behind option (for example `$` is typed `alt-ç` on a Czech keyboard),
/// and on some keyboards the IME handler converts a sequence of keys into a
/// specific character (for example `"` is typed as `" space` on a brazilian keyboard).
///
/// This method assumes that `self` was typed and `target' is in the keymap, and checks
/// both possibilities for self against the target.
pub(crate) fn should_match(&self, target: &Keystroke) -> bool {
#[cfg(not(target_os = "windows"))]
if let Some(key_char) = self
.key_char
.as_ref()
.filter(|key_char| key_char != &&self.key)
{
let ime_modifiers = Modifiers {
control: self.modifiers.control,
platform: self.modifiers.platform,
..Default::default()
};
if &target.key == key_char && target.modifiers == ime_modifiers {
return true;
}
}
#[cfg(target_os = "windows")]
if let Some(key_char) = self
.key_char
.as_ref()
.filter(|key_char| key_char != &&self.key)
{
// On Windows, if key_char is set, then the typed keystroke produced the key_char
if &target.key == key_char && target.modifiers == Modifiers::none() {
return true;
}
}
target.modifiers == self.modifiers && target.key == self.key
}
/// key syntax is:
/// [secondary-][ctrl-][alt-][shift-][cmd-][fn-]key[->key_char]
/// key_char syntax is only used for generating test events,
/// secondary means "cmd" on macOS and "ctrl" on other platforms
/// when matching a key with an key_char set will be matched without it.
pub fn parse(source: &str) -> std::result::Result<Self, InvalidKeystrokeError> {
let mut control = false;
let mut alt = false;
let mut shift = false;
let mut platform = false;
let mut function = false;
let mut key = None;
let mut key_char = None;
let mut components = source.split('-').peekable();
while let Some(component) = components.next() {
if component.eq_ignore_ascii_case("ctrl") {
control = true;
continue;
}
if component.eq_ignore_ascii_case("alt") {
alt = true;
continue;
}
if component.eq_ignore_ascii_case("shift") {
shift = true;
continue;
}
if component.eq_ignore_ascii_case("fn") {
function = true;
continue;
}
if component.eq_ignore_ascii_case("secondary") {
if cfg!(target_os = "macos") {
platform = true;
} else {
control = true;
};
continue;
}
let is_platform = component.eq_ignore_ascii_case("cmd")
|| component.eq_ignore_ascii_case("super")
|| component.eq_ignore_ascii_case("win");
if is_platform {
platform = true;
continue;
}
let mut key_str = component.to_string();
if let Some(next) = components.peek() {
if next.is_empty() && source.ends_with('-') {
key = Some(String::from("-"));
break;
} else if next.len() > 1 && next.starts_with('>') {
key = Some(key_str);
key_char = Some(String::from(&next[1..]));
components.next();
} else {
return Err(InvalidKeystrokeError {
keystroke: source.to_owned(),
});
}
continue;
}
if component.len() == 1 && component.as_bytes()[0].is_ascii_uppercase() {
// Convert to shift + lowercase char
shift = true;
key_str.make_ascii_lowercase();
} else {
// convert ascii chars to lowercase so that named keys like "tab" and "enter"
// are accepted case insensitively and stored how we expect so they are matched properly
key_str.make_ascii_lowercase()
}
key = Some(key_str);
}
// Allow for the user to specify a keystroke modifier as the key itself
// This sets the `key` to the modifier, and disables the modifier
if key.is_none() {
if shift {
key = Some("shift".to_string());
shift = false;
} else if control {
key = Some("control".to_string());
control = false;
} else if alt {
key = Some("alt".to_string());
alt = false;
} else if platform {
key = Some("platform".to_string());
platform = false;
} else if function {
key = Some("function".to_string());
function = false;
}
}
let key = key.ok_or_else(|| InvalidKeystrokeError {
keystroke: source.to_owned(),
})?;
Ok(Keystroke {
modifiers: Modifiers {
control,
alt,
shift,
platform,
function,
},
key,
key_char,
})
}
/// Produces a representation of this key that Parse can understand.
pub fn unparse(&self) -> String {
let mut str = String::new();
if self.modifiers.function {
str.push_str("fn-");
}
if self.modifiers.control {
str.push_str("ctrl-");
}
if self.modifiers.alt {
str.push_str("alt-");
}
if self.modifiers.platform {
#[cfg(target_os = "macos")]
str.push_str("cmd-");
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
str.push_str("super-");
#[cfg(target_os = "windows")]
str.push_str("win-");
}
if self.modifiers.shift {
str.push_str("shift-");
}
str.push_str(&self.key);
str
}
/// Returns true if this keystroke left
/// the ime system in an incomplete state.
pub fn is_ime_in_progress(&self) -> bool {
self.key_char.is_none()
&& (is_printable_key(&self.key) || self.key.is_empty())
&& !(self.modifiers.platform
|| self.modifiers.control
|| self.modifiers.function
|| self.modifiers.alt)
}
/// Returns a new keystroke with the key_char filled.
/// This is used for dispatch_keystroke where we want users to
/// be able to simulate typing "space", etc.
pub fn with_simulated_ime(mut self) -> Self {
if self.key_char.is_none()
&& !self.modifiers.platform
&& !self.modifiers.control
&& !self.modifiers.function
&& !self.modifiers.alt
{
self.key_char = match self.key.as_str() {
"space" => Some(" ".into()),
"tab" => Some("\t".into()),
"enter" => Some("\n".into()),
key if !is_printable_key(key) || key.is_empty() => None,
key => {
if self.modifiers.shift {
Some(key.to_uppercase())
} else {
Some(key.into())
}
}
}
}
self
}
}
fn is_printable_key(key: &str) -> bool {
!matches!(
key,
"f1" | "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12"
| "f13"
| "f14"
| "f15"
| "f16"
| "f17"
| "f18"
| "f19"
| "f20"
| "f21"
| "f22"
| "f23"
| "f24"
| "f25"
| "f26"
| "f27"
| "f28"
| "f29"
| "f30"
| "f31"
| "f32"
| "f33"
| "f34"
| "f35"
| "backspace"
| "delete"
| "left"
| "right"
| "up"
| "down"
| "pageup"
| "pagedown"
| "insert"
| "home"
| "end"
| "back"
| "forward"
| "escape"
)
}
impl std::fmt::Display for Keystroke {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.modifiers.control {
if cfg!(target_os = "macos") {
f.write_char('^')?;
} else {
write!(f, "ctrl-")?;
}
}
if self.modifiers.alt {
if cfg!(target_os = "macos") {
f.write_char('⌥')?;
} else {
write!(f, "alt-")?;
}
}
if self.modifiers.platform {
#[cfg(target_os = "macos")]
f.write_char('⌘')?;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
f.write_char('❖')?;
#[cfg(target_os = "windows")]
f.write_char('⊞')?;
}
if self.modifiers.shift {
if cfg!(target_os = "macos") {
f.write_char('⇧')?;
} else {
write!(f, "shift-")?;
}
}
let key = match self.key.as_str() {
"backspace" if cfg!(target_os = "macos") => '⌫',
"up" if cfg!(target_os = "macos") => '↑',
"down" if cfg!(target_os = "macos") => '↓',
"left" if cfg!(target_os = "macos") => '←',
"right" if cfg!(target_os = "macos") => '→',
"tab" if cfg!(target_os = "macos") => '⇥',
"escape" if cfg!(target_os = "macos") => '⎋',
"shift" if cfg!(target_os = "macos") => '⇧',
"control" if cfg!(target_os = "macos") => '⌃',
"alt" if cfg!(target_os = "macos") => '⌥',
"platform" if cfg!(target_os = "macos") => '⌘',
key => {
if key.len() == 1 {
key.chars().next().unwrap().to_ascii_uppercase()
} else {
return f.write_str(key);
}
}
};
f.write_char(key)
}
}
/// The state of the modifier keys at some point in time
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize, Hash, JsonSchema)]
pub struct Modifiers {
/// The control key
#[serde(default)]
pub control: bool,
/// The alt key
/// Sometimes also known as the 'meta' key
#[serde(default)]
pub alt: bool,
/// The shift key
#[serde(default)]
pub shift: bool,
/// The command key, on macos
/// the windows key, on windows
/// the super key, on linux
#[serde(default)]
pub platform: bool,
/// The function key
#[serde(default)]
pub function: bool,
}
impl Modifiers {
/// Returns whether any modifier key is pressed.
pub fn modified(&self) -> bool {
self.control || self.alt || self.shift || self.platform || self.function
}
/// Whether the semantically 'secondary' modifier key is pressed.
///
/// On macOS, this is the command key.
/// On Linux and Windows, this is the control key.
pub fn secondary(&self) -> bool {
#[cfg(target_os = "macos")]
{
self.platform
}
#[cfg(not(target_os = "macos"))]
{
self.control
}
}
/// Returns how many modifier keys are pressed.
pub fn number_of_modifiers(&self) -> u8 {
self.control as u8
+ self.alt as u8
+ self.shift as u8
+ self.platform as u8
+ self.function as u8
}
/// Returns [`Modifiers`] with no modifiers.
pub fn none() -> Modifiers {
Default::default()
}
/// Returns [`Modifiers`] with just the command key.
pub fn command() -> Modifiers {
Modifiers {
platform: true,
..Default::default()
}
}
/// A Returns [`Modifiers`] with just the secondary key pressed.
pub fn secondary_key() -> Modifiers {
#[cfg(target_os = "macos")]
{
Modifiers {
platform: true,
..Default::default()
}
}
#[cfg(not(target_os = "macos"))]
{
Modifiers {
control: true,
..Default::default()
}
}
}
/// Returns [`Modifiers`] with just the windows key.
pub fn windows() -> Modifiers {
Modifiers {
platform: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with just the super key.
pub fn super_key() -> Modifiers {
Modifiers {
platform: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with just control.
pub fn control() -> Modifiers {
Modifiers {
control: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with just alt.
pub fn alt() -> Modifiers {
Modifiers {
alt: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with just shift.
pub fn shift() -> Modifiers {
Modifiers {
shift: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with command + shift.
pub fn command_shift() -> Modifiers {
Modifiers {
shift: true,
platform: true,
..Default::default()
}
}
/// Returns [`Modifiers`] with command + shift.
pub fn control_shift() -> Modifiers {
Modifiers {
shift: true,
control: true,
..Default::default()
}
}
/// Checks if this [`Modifiers`] is a subset of another [`Modifiers`].
pub fn is_subset_of(&self, other: &Modifiers) -> bool {
(other.control || !self.control)
&& (other.alt || !self.alt)
&& (other.shift || !self.shift)
&& (other.platform || !self.platform)
&& (other.function || !self.function)
}
}