From cd5a3d30ba90e6900645506d69d416584d5ad76c Mon Sep 17 00:00:00 2001 From: Coko <91132775+coko7@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:24:40 +0200 Subject: [PATCH 1/3] feat(keybinds): add word deletion and half-page scrolling Add three configurable keybind actions shared by the app launcher, dmenu, and cclip modes: - delete_word (default Ctrl+W, Ctrl+Backspace, Ctrl+H) removes the last word of the query, or of the tag prompt in cclip mode. Ctrl+H is bound because most terminals send Ctrl+Backspace as ^H. - half_page_up / half_page_down (default Ctrl+U / Ctrl+D) move the selection half a visible page, clamped at the list ends. Grid layouts step by whole rows and rotated layouts flip direction like up/down. --- USAGE.md | 4 ++ config.toml | 3 ++ fsel.1 | 6 +++ keybinds.toml | 12 +++++ src/core/state/mod.rs | 2 + src/core/state/update.rs | 16 ++++++ src/modes/app_launcher/events.rs | 30 +++++++++++ src/modes/cclip/events/keyboard.rs | 84 ++++++++++++++++++++++++++++++ src/modes/dmenu/events.rs | 59 +++++++++++++++++++++ src/ui.rs | 2 + src/ui/editing.rs | 75 ++++++++++++++++++++++++++ src/ui/keybinds.rs | 65 +++++++++++++++++++++++ 12 files changed, 358 insertions(+) create mode 100644 src/ui/editing.rs diff --git a/USAGE.md b/USAGE.md index 5576d6db..16c5ac4c 100644 --- a/USAGE.md +++ b/USAGE.md @@ -506,6 +506,8 @@ image and text fullscreen previews. Disabling inline images does not disable tex - `Alt+Delete` - Delete selected clipboard entry (selection stays at the same physical index; next item becomes selected) - `Esc` - Exit without copying - Arrow keys - Navigate +- `Ctrl+U` / `Ctrl+D` - Move selection half a page up / down +- `Ctrl+W` / `Ctrl+Backspace` - Delete the last word of the query or tag prompt - Type to filter **Note:** Tag creation and management requires cclip with tag support. Tags appear as `[tagname]` prefixes in the clipboard item list. @@ -808,6 +810,8 @@ Note: Bare `FSEL_*` launcher keys set root defaults. `[app_launcher]` in `config `FILTER_DESKTOP`, `FILTER_ACTIONS`, `LIST_EXECUTABLES_IN_PATH`, `HIDE_BEFORE_TYPING`, `LAUNCH_PREFIX`, `MATCH_MODE`, `RANKING_MODE`, `PINNED_ORDER`, `CONFIRM_FIRST_LAUNCH`, `PREFIX_DEPTH`, `GRID_COLUMNS`, `GRID_ROW_HEIGHT`, `ICON_MODE`, `ICON_POSITION`, `ICON_DESCRIPTION_POSITION`, `ICON_PREVIEW_WIDTH_PERCENT`, `ICON_LIST_WIDTH`, `ICON_LIST_HEIGHT`, `ICON_LIST_GAP`, `ICON_LIST_VERTICAL_ALIGN_PERCENT`, `ICON_ARROW_BEFORE`, `ICON_SIZE`, `ICON_HORIZONTAL_ALIGN_PERCENT`, `ICON_VERTICAL_ALIGN_PERCENT`, `ICON_THEME` (each prefixed with `FSEL_APP_LAUNCHER_`) +In every mode, `Ctrl+W` or `Ctrl+Backspace` deletes the last word of the query (`delete_word`), and `Ctrl+U` / `Ctrl+D` move the selection half a page (`half_page_up` / `half_page_down`). Terminals usually send Ctrl+Backspace as Ctrl+H, so `delete_word` binds Ctrl+H as well. + Keybinds are not configurable via environment variables; use `~/.config/fsel/keybinds.toml` or the `[keybinds]` section in `config.toml`. When both are present, the embedded `[keybinds]` section takes precedence. #### Common Mistakes (Will Crash): diff --git a/config.toml b/config.toml index 86834398..a396362b 100644 --- a/config.toml +++ b/config.toml @@ -14,6 +14,9 @@ highlight_color = "LightBlue" # pin = [{ key = "space", modifiers = "ctrl" }] # hide = [{ key = "delete", modifiers = "alt" }] # unhide_last = [{ key = "u", modifiers = "alt" }] +# delete_word = [{ key = "w", modifiers = "ctrl" }, { key = "h", modifiers = "ctrl" }] +# half_page_up = [{ key = "u", modifiers = "ctrl" }] +# half_page_down = [{ key = "d", modifiers = "ctrl" }] # Cursor character in search box cursor = "█" diff --git a/fsel.1 b/fsel.1 index df5e2520..3b623c3d 100644 --- a/fsel.1 +++ b/fsel.1 @@ -307,6 +307,9 @@ Navigate up/down .BR "←/→" Jump to top/bottom of list .TP +.BR "Ctrl-U/Ctrl-D" +Move selection half a page up/down +.TP .BR "Enter" " or " "Ctrl-Y" Launch selected application .TP @@ -325,6 +328,9 @@ Exit .B Backspace Remove characters from search .TP +.BR "Ctrl-W" " or " "Ctrl-Backspace" " or " "Ctrl-H" +Delete the last word from search +.TP .B Tab/Shift-Tab Navigate down/up (alternative) .TP diff --git a/keybinds.toml b/keybinds.toml index 1f3585d3..756af9a1 100644 --- a/keybinds.toml +++ b/keybinds.toml @@ -16,6 +16,10 @@ left = ["left"] # Jump to last item in list right = ["right"] +# Move selection half a page up / down (stops at the ends of the list) +half_page_up = [{ key = "u", modifiers = "ctrl" }] +half_page_down = [{ key = "d", modifiers = "ctrl" }] + # ===== ACTIONS ===== # Launch selected app or select item (also: Ctrl+Y) @@ -36,6 +40,14 @@ unhide_last = [{ key = "u", modifiers = "alt" }] # Delete character from search query backspace = ["backspace"] +# Delete the last word from the search query (or the tag prompt in cclip mode) +# Note: most terminals send Ctrl+Backspace as Ctrl+H, so both are bound by default +delete_word = [ + { key = "w", modifiers = "ctrl" }, + { key = "backspace", modifiers = "ctrl" }, + { key = "h", modifiers = "ctrl" }, +] + # ===== CCLIP MODE ONLY ===== # These keybinds only work in clipboard history mode (fsel --cclip) diff --git a/src/core/state/mod.rs b/src/core/state/mod.rs index 02ad73e7..1c91ab15 100644 --- a/src/core/state/mod.rs +++ b/src/core/state/mod.rs @@ -178,6 +178,8 @@ pub enum Message { CharInput(char), /// Backspace pressed. Backspace, + /// Delete the last word of the query. + DeleteWord, /// Move selection up. MoveUp, /// Move selection down. diff --git a/src/core/state/update.rs b/src/core/state/update.rs index 96fd57aa..d093dcb1 100644 --- a/src/core/state/update.rs +++ b/src/core/state/update.rs @@ -6,6 +6,7 @@ pub fn update(state: &mut State, msg: Message, hard_stop: bool, max_visible: usi Message::SelectIndex(index) => select_index(state, index), Message::CharInput(character) => update_query(state, Some(character), "User typed"), Message::Backspace => update_query(state, None, "User pressed backspace"), + Message::DeleteWord => delete_word(state), Message::MoveUp => move_up(state, hard_stop, max_visible), Message::MoveDown => move_down(state, hard_stop, max_visible), Message::MoveFirst => move_first(state), @@ -53,6 +54,21 @@ fn update_query(state: &mut State, character: Option, reason: &str) { state.filter(); } +fn delete_word(state: &mut State) { + let old_query = state.query.clone(); + crate::ui::delete_word(&mut state.query); + + if crate::cli::DEBUG_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + crate::core::debug_logger::log_query_change( + &old_query, + &state.query, + "User deleted a word", + ); + } + + state.filter(); +} + fn move_up(state: &mut State, hard_stop: bool, max_visible: usize) { if let Some(selected) = state.selected { state.selected = if selected > 0 { diff --git a/src/modes/app_launcher/events.rs b/src/modes/app_launcher/events.rs index aac88937..79b6c091 100644 --- a/src/modes/app_launcher/events.rs +++ b/src/modes/app_launcher/events.rs @@ -45,8 +45,16 @@ fn handle_key_event( Message::MoveUp } else if cli.keybinds.matches_right(key.code, key.modifiers) { Message::MoveDown + } else if cli.keybinds.matches_delete_word(key.code, key.modifiers) { + Message::DeleteWord } else if cli.keybinds.matches_backspace(key.code, key.modifiers) { Message::Backspace + } else if cli.keybinds.matches_half_page_up(key.code, key.modifiers) + || cli.keybinds.matches_half_page_down(key.code, key.modifiers) + { + let forward = cli.keybinds.matches_half_page_down(key.code, key.modifiers); + move_half_page(state, cli, terminal_area, forward); + return; } else if cli.keybinds.matches_pin(key.code, key.modifiers) { toggle_selected_pin(state, db); refresh_info(state, cli); @@ -142,6 +150,28 @@ fn handle_key_event( refresh_info(state, cli); } +fn move_half_page( + state: &mut State, + cli: &Opts, + terminal_area: ratatui::layout::Rect, + forward: bool, +) { + let layout = crate::ui::launcher_result_layout(terminal_area, cli); + if let Some(selected) = state.selected { + let step = crate::ui::half_page_step(layout.capacity(), layout.scroll_step()); + let forward = forward != (cli.panels.rotation >= 180); + let next = crate::ui::half_page_target(selected, state.shown.len(), step, forward); + crate::core::state::update( + state, + Message::SelectIndex(next), + cli.hard_stop, + layout.capacity(), + ); + } + layout.keep_visible(state.selected, &mut state.scroll_offset); + refresh_info(state, cli); +} + fn handle_mouse_event( state: &mut State, mouse_event: MouseEvent, diff --git a/src/modes/cclip/events/keyboard.rs b/src/modes/cclip/events/keyboard.rs index b2cbb2b2..d7910efa 100644 --- a/src/modes/cclip/events/keyboard.rs +++ b/src/modes/cclip/events/keyboard.rs @@ -17,6 +17,9 @@ enum KeyAction { Select, Input(char), Backspace, + DeleteWord, + HalfPageDown, + HalfPageUp, First, Last, Down, @@ -40,6 +43,12 @@ fn key_action(keybinds: &Keybinds, key: KeyEvent) -> KeyAction { KeyAction::Exit } else if keybinds.matches_select(code, modifiers) { KeyAction::Select + } else if keybinds.matches_delete_word(code, modifiers) { + KeyAction::DeleteWord + } else if keybinds.matches_half_page_down(code, modifiers) { + KeyAction::HalfPageDown + } else if keybinds.matches_half_page_up(code, modifiers) { + KeyAction::HalfPageUp } else if keybinds.matches_backspace(code, modifiers) { KeyAction::Backspace } else if keybinds.matches_left(code, modifiers) { @@ -131,6 +140,15 @@ pub(super) async fn handle_key_event( KeyAction::Backspace => { pop_char(ctx.ui); } + KeyAction::DeleteWord => { + delete_word(ctx.ui); + } + KeyAction::HalfPageDown => { + move_half_page(ctx, true)?; + } + KeyAction::HalfPageUp => { + move_half_page(ctx, false)?; + } KeyAction::First => { if matches!(ctx.ui.tag_mode, TagMode::Normal) { move_to_first(ctx.ui); @@ -171,6 +189,8 @@ fn oriented_action(action: KeyAction, rotation: u16, normal: bool) -> KeyAction match (rotation >= 180, axis_action) { (true, KeyAction::Down) => KeyAction::Up, (true, KeyAction::Up) => KeyAction::Down, + (true, KeyAction::HalfPageDown) => KeyAction::HalfPageUp, + (true, KeyAction::HalfPageUp) => KeyAction::HalfPageDown, (_, other) => other, } } @@ -203,6 +223,39 @@ fn pop_char(ui: &mut crate::ui::DmenuUI<'_>) { } } +fn delete_word(ui: &mut crate::ui::DmenuUI<'_>) { + match &mut ui.tag_mode { + TagMode::PromptingTagName { input, .. } + | TagMode::PromptingTagColor { input, .. } + | TagMode::PromptingTagEmoji { input, .. } + | TagMode::RemovingTag { input, .. } => crate::ui::delete_word(input), + TagMode::Normal => { + crate::ui::delete_word(&mut ui.query); + ui.filter(); + } + } +} + +fn move_half_page(ctx: &mut EventContext<'_, '_>, forward: bool) -> Result<()> { + if !matches!(ctx.ui.tag_mode, TagMode::Normal) { + return Ok(()); + } + + if let Some(selected) = ctx.ui.selected { + let max_visible = ctx.options.max_visible_items(ctx.terminal.size()?.into()); + let step = crate::ui::half_page_step(max_visible, 1); + ctx.ui.selected = Some(crate::ui::half_page_target( + selected, + ctx.ui.shown.len(), + step, + forward, + )); + keep_selection_visible(ctx.ui, max_visible); + } + + Ok(()) +} + fn handle_down(ctx: &mut EventContext<'_, '_>) -> Result<()> { match &ctx.ui.tag_mode { TagMode::PromptingTagName { .. } => { @@ -344,4 +397,35 @@ up = [{ key = "k", modifiers = "alt" }] KeyAction::Ignore ); } + + #[test] + fn default_word_and_half_page_keys_are_classified() { + let keybinds = Keybinds::default(); + + assert_eq!( + key_action( + &keybinds, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL) + ), + KeyAction::DeleteWord + ); + assert_eq!( + key_action( + &keybinds, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL) + ), + KeyAction::HalfPageDown + ); + assert_eq!( + key_action( + &keybinds, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL) + ), + KeyAction::HalfPageUp + ); + assert_eq!( + super::oriented_action(KeyAction::HalfPageDown, 180, true), + KeyAction::HalfPageUp + ); + } } diff --git a/src/modes/dmenu/events.rs b/src/modes/dmenu/events.rs index 8624c09b..39a7461b 100644 --- a/src/modes/dmenu/events.rs +++ b/src/modes/dmenu/events.rs @@ -39,6 +39,17 @@ pub(super) fn handle_key_event( { return handle_submit(ui, options); } + (code, modifiers) if options.keybinds.matches_delete_word(code, modifiers) => { + crate::ui::delete_word(&mut ui.query); + ui.filter(); + auto_select_if_single_match(ui, options); + } + (code, modifiers) if options.keybinds.matches_half_page_up(code, modifiers) => { + move_half_page(ui, options, terminal_area, false); + } + (code, modifiers) if options.keybinds.matches_half_page_down(code, modifiers) => { + move_half_page(ui, options, terminal_area, true); + } (code, modifiers) if matches_dmenu_binding( &options.keybinds, @@ -256,6 +267,29 @@ fn move_selection( options.result_layout(terminal_area, ui); } +fn move_half_page( + ui: &mut DmenuUI, + options: &DmenuOptions, + terminal_area: ratatui::layout::Rect, + forward: bool, +) { + let Some(selected) = ui.selected else { + return; + }; + + let capacity = options.result_layout(terminal_area, ui).capacity(); + let step = crate::ui::half_page_step(capacity, 1); + let forward = forward != (options.panels.rotation >= 180); + ui.selected = Some(crate::ui::half_page_target( + selected, + ui.shown.len(), + step, + forward, + )); + + options.result_layout(terminal_area, ui); +} + #[cfg(test)] mod tests { #[test] @@ -416,6 +450,31 @@ up = [{ key = "k", modifiers = "alt" }] assert_eq!(backspace_ui.query, "a"); } + #[test] + fn default_word_delete_and_half_page_keys() { + let options = DmenuOptions::from_cli(&Opts::default()); + let items = (0..40) + .map(|index| Item::new_simple(index.to_string(), index.to_string(), index + 1)) + .collect(); + let mut ui = DmenuUI::new(items, false, false); + ui.filter(); + ui.selected = Some(0); + let area = ratatui::layout::Rect::new(0, 0, 80, 24); + let half = options.result_layout(area, &mut ui).capacity() / 2; + let ctrl = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL); + + handle_key_event(&mut ui, ctrl('d'), &options, area); + assert_eq!(ui.selected, Some(half)); + handle_key_event(&mut ui, ctrl('u'), &options, area); + assert_eq!(ui.selected, Some(0)); + handle_key_event(&mut ui, ctrl('u'), &options, area); + assert_eq!(ui.selected, Some(0)); + + ui.query = "foo bar".to_string(); + handle_key_event(&mut ui, ctrl('w'), &options, area); + assert_eq!(ui.query, "foo "); + } + #[test] fn selected_output_returns_original_line_number() { let cli = Opts { diff --git a/src/ui.rs b/src/ui.rs index 8c2ebd12..79561c40 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,6 +3,7 @@ mod app_list; mod app_ui; mod dmenu_ui; +mod editing; mod graphics; pub(crate) mod graphics_probe; mod input; @@ -19,6 +20,7 @@ pub(crate) use app_list::{launcher_list_icon_area, launcher_result_layout, launc pub use app_ui::{AppIcons, UI}; pub(crate) use app_ui::{ListIconPlacement, launcher_preview_icon_area}; pub use dmenu_ui::{DmenuUI, TagMode}; +pub(crate) use editing::{delete_word, half_page_step, half_page_target}; pub use graphics::{DISPLAY_STATE, DisplayState, GraphicsAdapter, ImageManager}; #[allow(unused_imports)] pub use input::{AsyncInput, Config as InputConfig, Event as InputEvent, Input}; diff --git a/src/ui/editing.rs b/src/ui/editing.rs new file mode 100644 index 00000000..8142765a --- /dev/null +++ b/src/ui/editing.rs @@ -0,0 +1,75 @@ +//! Query editing and list paging helpers shared by every interactive mode. + +/// Remove the last word from `text`, readline `unix-word-rubout` style: +/// trailing whitespace first, then everything back to the previous whitespace. +pub(crate) fn delete_word(text: &mut String) { + let trimmed = text.trim_end().len(); + let start = text[..trimmed] + .char_indices() + .rev() + .find(|(_, character)| character.is_whitespace()) + .map_or(0, |(index, character)| index + character.len_utf8()); + text.truncate(start); +} + +/// Half of `capacity`, rounded down to whole rows of `row_len` items, never below one row. +pub(crate) fn half_page_step(capacity: usize, row_len: usize) -> usize { + let row_len = row_len.max(1); + (capacity / row_len / 2).max(1) * row_len +} + +/// Index `step` items away from `selected`, clamped to the list instead of wrapping. +pub(crate) fn half_page_target(selected: usize, len: usize, step: usize, forward: bool) -> usize { + if len == 0 { + return selected; + } + if forward { + selected.saturating_add(step).min(len - 1) + } else { + selected.saturating_sub(step) + } +} + +#[cfg(test)] +mod tests { + use super::{delete_word, half_page_step, half_page_target}; + + fn deleted(text: &str) -> String { + let mut text = text.to_string(); + delete_word(&mut text); + text + } + + #[test] + fn delete_word_removes_last_word_and_trailing_space() { + assert_eq!(deleted("fire fox"), "fire "); + assert_eq!(deleted("fire fox "), "fire "); + assert_eq!(deleted("firefox"), ""); + assert_eq!(deleted(" "), ""); + assert_eq!(deleted(""), ""); + } + + #[test] + fn delete_word_handles_multibyte_characters() { + assert_eq!(deleted("café 日本語"), "café "); + assert_eq!(deleted("a\u{3000}b"), "a\u{3000}"); + } + + #[test] + fn half_page_step_keeps_grid_columns_aligned() { + assert_eq!(half_page_step(20, 1), 10); + assert_eq!(half_page_step(1, 1), 1); + assert_eq!(half_page_step(0, 1), 1); + assert_eq!(half_page_step(12, 4), 4); + assert_eq!(half_page_step(24, 4), 12); + } + + #[test] + fn half_page_target_clamps_at_list_ends() { + assert_eq!(half_page_target(2, 10, 5, true), 7); + assert_eq!(half_page_target(7, 10, 5, true), 9); + assert_eq!(half_page_target(7, 10, 5, false), 2); + assert_eq!(half_page_target(2, 10, 5, false), 0); + assert_eq!(half_page_target(0, 0, 5, true), 0); + } +} diff --git a/src/ui/keybinds.rs b/src/ui/keybinds.rs index ee02a49a..171a4a52 100644 --- a/src/ui/keybinds.rs +++ b/src/ui/keybinds.rs @@ -25,6 +25,12 @@ pub struct Keybinds { pub unhide_last: Vec, #[serde(default = "default_backspace")] pub backspace: Vec, + #[serde(default = "default_delete_word")] + pub delete_word: Vec, + #[serde(default = "default_half_page_up")] + pub half_page_up: Vec, + #[serde(default = "default_half_page_down")] + pub half_page_down: Vec, #[serde(default = "default_image_preview")] pub image_preview: Vec, #[serde(default = "default_tag")] @@ -46,6 +52,9 @@ impl Default for Keybinds { hide: default_hide(), unhide_last: default_unhide_last(), backspace: default_backspace(), + delete_word: default_delete_word(), + half_page_up: default_half_page_up(), + half_page_down: default_half_page_down(), image_preview: default_image_preview(), tag: default_tag(), cclip_delete: default_cclip_delete(), @@ -220,6 +229,38 @@ fn default_backspace() -> Vec { vec![KeyBind::Simple("backspace".to_string())] } +fn default_delete_word() -> Vec { + // Most terminals send Ctrl+Backspace as ^H, which crossterm reports as Ctrl+H + vec![ + KeyBind::WithMod { + key: "w".to_string(), + modifiers: "ctrl".to_string(), + }, + KeyBind::WithMod { + key: "backspace".to_string(), + modifiers: "ctrl".to_string(), + }, + KeyBind::WithMod { + key: "h".to_string(), + modifiers: "ctrl".to_string(), + }, + ] +} + +fn default_half_page_up() -> Vec { + vec![KeyBind::WithMod { + key: "u".to_string(), + modifiers: "ctrl".to_string(), + }] +} + +fn default_half_page_down() -> Vec { + vec![KeyBind::WithMod { + key: "d".to_string(), + modifiers: "ctrl".to_string(), + }] +} + fn default_image_preview() -> Vec { // Note: Ctrl+I is the same as Tab in terminals, so we use Alt+I instead vec![KeyBind::WithMod { @@ -293,6 +334,18 @@ impl Keybinds { self.backspace.iter().any(|kb| kb.matches(code, mods)) } + pub fn matches_delete_word(&self, code: KeyCode, mods: KeyModifiers) -> bool { + self.delete_word.iter().any(|kb| kb.matches(code, mods)) + } + + pub fn matches_half_page_up(&self, code: KeyCode, mods: KeyModifiers) -> bool { + self.half_page_up.iter().any(|kb| kb.matches(code, mods)) + } + + pub fn matches_half_page_down(&self, code: KeyCode, mods: KeyModifiers) -> bool { + self.half_page_down.iter().any(|kb| kb.matches(code, mods)) + } + pub fn matches_image_preview(&self, code: KeyCode, mods: KeyModifiers) -> bool { self.image_preview.iter().any(|kb| kb.matches(code, mods)) } @@ -355,6 +408,18 @@ mod tests { assert!(!keybinds.matches_hide(KeyCode::Delete, KeyModifiers::NONE)); } + #[test] + fn default_word_and_half_page_bindings() { + let keybinds = Keybinds::default(); + + assert!(keybinds.matches_delete_word(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert!(keybinds.matches_delete_word(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert!(keybinds.matches_delete_word(KeyCode::Backspace, KeyModifiers::CONTROL)); + assert!(!keybinds.matches_delete_word(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(keybinds.matches_half_page_up(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(keybinds.matches_half_page_down(KeyCode::Char('d'), KeyModifiers::CONTROL)); + } + #[test] fn combined_modifiers_are_matched_exactly() { let keybinds = Keybinds { From 8afc9e9e4d43d1f625137b4a9a8d9a364855ed10 Mon Sep 17 00:00:00 2001 From: Coko <91132775+coko7@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:28:38 +0200 Subject: [PATCH 2/3] chore(lint): allow deprecated fetch_update until MSRV reaches 1.95 Rust 1.99 deprecates AtomicX::fetch_update in favor of try_update, which fails clippy under -D warnings. try_update is only stable since 1.95 and the crate's MSRV is 1.94, so allow the deprecation at both call sites instead of renaming. --- src/ui/dmenu_ui/content/cclip.rs | 2 ++ src/ui/graphics.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/ui/dmenu_ui/content/cclip.rs b/src/ui/dmenu_ui/content/cclip.rs index b0f68193..0d4077ff 100644 --- a/src/ui/dmenu_ui/content/cclip.rs +++ b/src/ui/dmenu_ui/content/cclip.rs @@ -225,6 +225,8 @@ fn decode_content_for_view(mime_type: &str, bytes: Vec, raw_content: bool) - Some(content_for_view(mime_type, &content, raw_content)) } +// `try_update` replaces `fetch_update` but needs Rust 1.95; MSRV is 1.94. +#[allow(deprecated)] fn acquire_content_permit(in_flight: &Arc) -> Option { in_flight .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { diff --git a/src/ui/graphics.rs b/src/ui/graphics.rs index 70a64bac..cdebad9e 100644 --- a/src/ui/graphics.rs +++ b/src/ui/graphics.rs @@ -984,6 +984,8 @@ fn svg_options(load_system_fonts: bool) -> resvg::usvg::Options<'static> { return None; } let pixels = u64::from(width).checked_mul(u64::from(height))?; + // `try_update` replaces `fetch_update` but needs Rust 1.95; MSRV is 1.94. + #[allow(deprecated)] remaining_raster_pixels .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { remaining.checked_sub(pixels) From ed1d5a29849750c308e8d852e8e1cd7356c75530 Mon Sep 17 00:00:00 2001 From: Coko <91132775+coko7@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:36:30 +0200 Subject: [PATCH 3/3] refactor(keybinds): address review feedback on word deletion and paging --- src/core/{state/mod.rs => state.rs} | 0 src/core/state/update.rs | 6 ++++-- src/modes/app_launcher/events.rs | 4 ++-- src/modes/dmenu/events.rs | 4 ++-- src/ui/editing.rs | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) rename src/core/{state/mod.rs => state.rs} (100%) diff --git a/src/core/state/mod.rs b/src/core/state.rs similarity index 100% rename from src/core/state/mod.rs rename to src/core/state.rs diff --git a/src/core/state/update.rs b/src/core/state/update.rs index d093dcb1..669dd108 100644 --- a/src/core/state/update.rs +++ b/src/core/state/update.rs @@ -55,10 +55,12 @@ fn update_query(state: &mut State, character: Option, reason: &str) { } fn delete_word(state: &mut State) { - let old_query = state.query.clone(); + let old_query = crate::cli::DEBUG_ENABLED + .load(std::sync::atomic::Ordering::Relaxed) + .then(|| state.query.clone()); crate::ui::delete_word(&mut state.query); - if crate::cli::DEBUG_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + if let Some(old_query) = old_query { crate::core::debug_logger::log_query_change( &old_query, &state.query, diff --git a/src/modes/app_launcher/events.rs b/src/modes/app_launcher/events.rs index 79b6c091..501b894b 100644 --- a/src/modes/app_launcher/events.rs +++ b/src/modes/app_launcher/events.rs @@ -159,8 +159,8 @@ fn move_half_page( let layout = crate::ui::launcher_result_layout(terminal_area, cli); if let Some(selected) = state.selected { let step = crate::ui::half_page_step(layout.capacity(), layout.scroll_step()); - let forward = forward != (cli.panels.rotation >= 180); - let next = crate::ui::half_page_target(selected, state.shown.len(), step, forward); + let oriented_forward = forward != (cli.panels.rotation >= 180); + let next = crate::ui::half_page_target(selected, state.shown.len(), step, oriented_forward); crate::core::state::update( state, Message::SelectIndex(next), diff --git a/src/modes/dmenu/events.rs b/src/modes/dmenu/events.rs index 39a7461b..220dbc66 100644 --- a/src/modes/dmenu/events.rs +++ b/src/modes/dmenu/events.rs @@ -279,12 +279,12 @@ fn move_half_page( let capacity = options.result_layout(terminal_area, ui).capacity(); let step = crate::ui::half_page_step(capacity, 1); - let forward = forward != (options.panels.rotation >= 180); + let oriented_forward = forward != (options.panels.rotation >= 180); ui.selected = Some(crate::ui::half_page_target( selected, ui.shown.len(), step, - forward, + oriented_forward, )); options.result_layout(terminal_area, ui); diff --git a/src/ui/editing.rs b/src/ui/editing.rs index 8142765a..be061def 100644 --- a/src/ui/editing.rs +++ b/src/ui/editing.rs @@ -14,8 +14,8 @@ pub(crate) fn delete_word(text: &mut String) { /// Half of `capacity`, rounded down to whole rows of `row_len` items, never below one row. pub(crate) fn half_page_step(capacity: usize, row_len: usize) -> usize { - let row_len = row_len.max(1); - (capacity / row_len / 2).max(1) * row_len + let items_per_row = row_len.max(1); + (capacity / items_per_row / 2).max(1) * items_per_row } /// Index `step` items away from `selected`, clamped to the list instead of wrapping.