Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions crates/noa-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,6 @@ pub struct App {
/// seeded from `sidebar-enabled`. A quick-terminal window never shows it
/// regardless (`window_sidebar_eligible`, FR-14).
sidebar_visible_groups: HashSet<WindowGroupId>,
/// The registered global `sidebar-hotkey`, kept alive for the app's
/// lifetime (dropping it unregisters). `None` until installed, or when no
/// `sidebar-hotkey` is configured / registration failed.
sidebar_hotkey: Option<crate::macos_hotkey::GlobalHotKey>,
/// The dedicated branch-poll worker (FR-8/FR-9): receives OSC-7-driven
/// cwd-change requests and posts back git branch + project icon as
/// [`crate::session_store::SessionDelta::Branch`]. Kept off the io read loop
Expand Down Expand Up @@ -588,7 +584,8 @@ impl App {
let initial_cursor_style =
resolve_cursor_style(config.cursor_style, config.cursor_style_blink);
let background_image = load_background_image_runtime(&config);
let (keybinds, keybind_diagnostics) = KeybindEngine::from_config(&config.keybinds);
let (keybinds, keybind_diagnostics) =
KeybindEngine::from_config(&config.keybinds, config.sidebar_hotkey.as_deref());
for diagnostic in keybind_diagnostics {
log::warn!("config keybind: {diagnostic}");
}
Expand Down Expand Up @@ -690,7 +687,6 @@ impl App {
hotkey_install_attempted: false,
secure_input: crate::secure_input::SecureInput::new(),
session_store: SessionStore::new(),
sidebar_hotkey: None,
branch_poll: Some(crate::branch_poll::spawn(proxy_for_branch_poll)),
session_persister: crate::session_persist::SessionPersister::spawn(),
sidebar_visible_gate,
Expand Down
7 changes: 4 additions & 3 deletions crates/noa-app/src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,10 @@ pub struct AppConfig {
/// `sidebar-font-size`: the session sidebar's own font size in points,
/// independent of the terminal grid's `font-size`.
pub sidebar_font_size: f32,
/// `sidebar-hotkey`: the global chord that toggles the sidebar for the
/// focused window (FR-13). `None` (or the empty-string "disabled" sentinel)
/// registers no chord.
/// `sidebar-hotkey`: the in-app chord that toggles the sidebar for the
/// focused window — a `ToggleSidebar` rebind fed to
/// `KeybindEngine::from_config`, not a system-wide hotkey. `None` (or the
/// empty-string sentinel) keeps the default `cmd+shift+s` binding.
pub sidebar_hotkey: Option<String>,
/// `sidebar-preview-lines`: number of trailing output rows shown in each
/// sidebar card. `0` disables the preview rows.
Expand Down
24 changes: 19 additions & 5 deletions crates/noa-app/src/app/config_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,14 @@ impl App {
let sidebar_preview_changed =
previous.sidebar_preview_lines != applied.sidebar_preview_lines;
let sidebar_font_size_changed = previous.sidebar_font_size != applied.sidebar_font_size;
let keybinds_changed = previous.keybinds != applied.keybinds;
let sidebar_hotkey_changed = previous.sidebar_hotkey != applied.sidebar_hotkey;
// `sidebar-hotkey` feeds the keybind engine (in-app chord), so a
// change to either input rebuilds it.
let keybinds_changed = previous.keybinds != applied.keybinds || sidebar_hotkey_changed;
let server_restart = decide_server_restart(&previous, &applied);
let quick_terminal_hotkey_changed =
previous.quick_terminal_hotkey != applied.quick_terminal_hotkey;
let sidebar_hotkey_changed = previous.sidebar_hotkey != applied.sidebar_hotkey;
let hotkeys_changed = quick_terminal_hotkey_changed || sidebar_hotkey_changed;
let hotkeys_changed = quick_terminal_hotkey_changed;

self.config = applied;

Expand Down Expand Up @@ -240,7 +242,10 @@ impl App {
self.restart_ipc_server();
}
if keybinds_changed {
let (keybinds, diagnostics) = KeybindEngine::from_config(&self.config.keybinds);
let (keybinds, diagnostics) = KeybindEngine::from_config(
&self.config.keybinds,
self.config.sidebar_hotkey.as_deref(),
);
for diagnostic in diagnostics {
log::warn!("config reload keybind: {diagnostic}");
}
Expand All @@ -253,10 +258,19 @@ impl App {
if quick_terminal_hotkey_changed && let Some(menu) = self.macos_menu.as_ref() {
menu.set_quick_terminal_hotkey(self.config.quick_terminal_hotkey.as_deref());
}
// Any engine rebuild can move the effective ToggleSidebar chord
// (sidebar-hotkey or an explicit keybind), so resync the menu
// accelerator from the engine, not from the raw config value.
if keybinds_changed && let Some(menu) = self.macos_menu.as_ref() {
menu.set_sidebar_chord(
self.keybinds
.chord_for(crate::commands::AppCommand::ToggleSidebar)
.as_deref(),
);
}
}
if hotkeys_changed {
self.quick_terminal_hotkey = None;
self.sidebar_hotkey = None;
self.hotkey_install_attempted = false;
}

Expand Down
1 change: 0 additions & 1 deletion crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ impl ApplicationHandler<UserEvent> for App {
self.handle_app_command(event_loop, command, CommandOrigin::App)
}
UserEvent::ToggleQuickTerminal => self.toggle_quick_terminal(event_loop),
UserEvent::ToggleSidebar => self.toggle_sidebar(),
UserEvent::SessionDelta(delta) => {
if let crate::session_store::SessionDelta::Bell { id } = &delta {
let window_id = WindowId::from(id.window_id.0);
Expand Down
3 changes: 3 additions & 0 deletions crates/noa-app/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,9 @@ impl App {
crate::macos_menu::MacosMenu::install(
self.proxy.clone(),
self.config.quick_terminal_hotkey.as_deref(),
self.keybinds
.chord_for(crate::commands::AppCommand::ToggleSidebar)
.as_deref(),
)
.expect("failed to install macOS app menu"),
);
Expand Down
22 changes: 5 additions & 17 deletions crates/noa-app/src/app/quick_terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,11 @@ impl App {
})
}

/// Register the global `quick-terminal-hotkey` and `sidebar-hotkey` once,
/// after the app is running. A no-op per chord when unset or explicitly
/// disabled; a registration failure is logged, not fatal. Both go through
/// the same `parse_hotkey` path (FR-13).
/// Register the global `quick-terminal-hotkey` once, after the app is
/// running. A no-op when unset or explicitly disabled; a registration
/// failure is logged, not fatal. (`sidebar-hotkey` is deliberately not
/// registered here — it is an in-app keybind, applied via
/// `KeybindEngine::from_config`.)
pub(super) fn install_global_hotkey_if_needed(&mut self) {
if self.hotkey_install_attempted {
return;
Expand All @@ -376,19 +377,6 @@ impl App {
None => log::warn!("failed to register quick-terminal-hotkey `{spec}`"),
}
}

if let Some(spec) = self.config.sidebar_hotkey.clone()
&& !spec.trim().is_empty()
{
match crate::macos_hotkey::GlobalHotKey::register(
&spec,
self.proxy.clone(),
crate::macos_hotkey::HotkeyAction::Sidebar,
) {
Some(hotkey) => self.sidebar_hotkey = Some(hotkey),
None => log::warn!("failed to register sidebar-hotkey `{spec}`"),
}
}
}

/// Toggle the quick terminal: reveal it (creating its window on first use)
Expand Down
5 changes: 3 additions & 2 deletions crates/noa-app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ pub fn run_action(action: CliAction) -> anyhow::Result<()> {
for diagnostic in diagnostics {
eprintln!("{}", diagnostic.message);
}
let (keybinds, diagnostics) = KeybindEngine::from_config(&config.keybinds);
let (keybinds, diagnostics) =
KeybindEngine::from_config(&config.keybinds, config.sidebar_hotkey.as_deref());
for diagnostic in diagnostics {
eprintln!("config keybind: {diagnostic}");
}
Expand Down Expand Up @@ -687,7 +688,7 @@ mod tests {

#[test]
fn list_keybinds_output_uses_the_supplied_effective_engine() {
let (keybinds, diagnostics) = KeybindEngine::from_config(&[
let (keybinds, diagnostics) = KeybindEngine::from_config_test(&[
noa_config::KeybindConfig::Unbind {
trigger: "cmd+t".to_string(),
},
Expand Down
84 changes: 77 additions & 7 deletions crates/noa-app/src/commands/key_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ impl KeyTrigger {
pub(super) fn matches(&self, logical_key: &Key, mods: ModifiersState) -> bool {
self.mods.matches(mods) && self.key.matches(logical_key)
}

/// Whether two triggers can match the same keypress. Strictly wider than
/// `==`: character tokens carry alternate logical candidates (`[`/`{`,
/// `]`/`}`, `\`/`¥`/`_` — see [`KeyToken::candidates`]), so dedup and
/// conflict checks must use this relation — structural equality would
/// leave a shadowing binding alive that runtime matching still hits.
pub(super) fn overlaps(&self, other: &Self) -> bool {
self.mods == other.mods && self.key.overlaps(other.key)
}
}

impl std::fmt::Display for KeyTrigger {
Expand Down Expand Up @@ -86,11 +95,27 @@ enum KeyToken {

impl KeyToken {
fn parse(token: &str) -> Result<Self, KeybindParseError> {
if token == "plus" {
return Ok(Self::Character('+'));
}
if token == "grave" || token == "backtick" {
return Ok(Self::Character('`'));
// Multi-char aliases shared with the global-hotkey chord syntax
// (`macos_hotkey::parse_hotkey`), so a chord accepted there — e.g. a
// pre-existing `sidebar-hotkey` value — parses identically here.
let aliased = match token {
"plus" => Some('+'),
"grave" | "backtick" => Some('`'),
"equal" => Some('='),
"minus" => Some('-'),
"leftbracket" => Some('['),
"rightbracket" => Some(']'),
"semicolon" => Some(';'),
"backslash" => Some('\\'),
"comma" => Some(','),
"slash" => Some('/'),
"period" => Some('.'),
"yen" | "jis-yen" | "jis_yen" | "intl-yen" | "intl_yen" => Some('¥'),
"underscore" | "jis-underscore" | "jis_underscore" | "intl-ro" | "intl_ro" => Some('_'),
_ => None,
};
if let Some(ch) = aliased {
return Ok(Self::Character(ch));
}
let mut chars = token.chars();
if let (Some(ch), None) = (chars.next(), chars.next()) {
Expand All @@ -106,23 +131,59 @@ impl KeyToken {
"home" => NamedKeyToken::Home,
"end" => NamedKeyToken::End,
"enter" | "return" => NamedKeyToken::Enter,
"tab" => NamedKeyToken::Tab,
"space" => NamedKeyToken::Space,
"escape" | "esc" => NamedKeyToken::Escape,
_ => return Err(KeybindParseError::UnknownKey(token.to_string())),
}))
}

/// The alternate logical-key characters `expected` matches beyond its
/// own (case-insensitive) character. The single source of truth for
/// character equivalence — runtime matching ([`Self::matches`]) and
/// trigger overlap ([`Self::overlaps`]) both derive from it, so a chord
/// can never match a keypress that dedup/conflict checks treated as
/// unrelated. `\` covers the JIS variants the retired global hotkey
/// registered physically (ANSI Backslash → `\`, JIS Yen → `¥`, JIS Ro →
/// `_` — see `macos_hotkey::carbon_keycodes`).
fn alternates(expected: char) -> &'static [char] {
match expected {
'[' => &['{'],
']' => &['}'],
'\\' => &['¥', '_'],
_ => &[],
}
}

fn matches(self, logical_key: &Key) -> bool {
match (self, logical_key) {
(Self::Character(expected), Key::Character(actual)) => {
actual.chars().next().is_some_and(|actual| {
actual.eq_ignore_ascii_case(&expected)
|| (expected == '[' && actual == '{')
|| (expected == ']' && actual == '}')
|| Self::alternates(expected).contains(&actual)
})
}
(Self::Named(expected), Key::Named(actual)) => expected.matches(*actual),
_ => false,
}
}

/// Whether the two tokens' match sets intersect: equal characters, one
/// character being an alternate of the other, or a shared alternate.
fn overlaps(self, other: Self) -> bool {
match (self, other) {
(Self::Character(a), Self::Character(b)) => {
a.eq_ignore_ascii_case(&b)
|| Self::alternates(a).contains(&b)
|| Self::alternates(b).contains(&a)
|| Self::alternates(a)
.iter()
.any(|ka| Self::alternates(b).contains(ka))
}
(Self::Named(a), Self::Named(b)) => a == b,
_ => false,
}
}
}

impl std::fmt::Display for KeyToken {
Expand All @@ -149,6 +210,9 @@ enum NamedKeyToken {
Home,
End,
Enter,
Tab,
Space,
Escape,
}

impl NamedKeyToken {
Expand All @@ -165,6 +229,9 @@ impl NamedKeyToken {
Self::Home => "home",
Self::End => "end",
Self::Enter => "enter",
Self::Tab => "tab",
Self::Space => "space",
Self::Escape => "escape",
}
}

Expand All @@ -180,6 +247,9 @@ impl NamedKeyToken {
| (Self::Home, NamedKey::Home)
| (Self::End, NamedKey::End)
| (Self::Enter, NamedKey::Enter)
| (Self::Tab, NamedKey::Tab)
| (Self::Space, NamedKey::Space)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match space keybinds against character-space events

When winit reports Space as Key::Character(" ") (the input layer tests already handle that platform variation), a space trigger parsed here never matches because NamedKeyToken::Space only accepts NamedKey::Space. That makes sidebar-hotkey = cmd+space or any configured keybind using space silently ineffective in those environments unless a native menu accelerator happens to catch it; please match the character-space form too.

Useful? React with 👍 / 👎.

| (Self::Escape, NamedKey::Escape)
)
}
}
Expand Down
Loading