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
4 changes: 4 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "█"
Expand Down
6 changes: 6 additions & 0 deletions fsel.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions keybinds.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions src/core/state/mod.rs → src/core/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ pub enum Message {
CharInput(char),
/// Backspace pressed.
Backspace,
/// Delete the last word of the query.
DeleteWord,
Comment thread
coko7 marked this conversation as resolved.
Resolved
/// Move selection up.
MoveUp,
/// Move selection down.
Expand Down
18 changes: 18 additions & 0 deletions src/core/state/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -53,6 +54,23 @@ fn update_query(state: &mut State, character: Option<char>, reason: &str) {
state.filter();
}

fn delete_word(state: &mut State) {
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 let Some(old_query) = old_query {
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 {
Expand Down
30 changes: 30 additions & 0 deletions src/modes/app_launcher/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 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),
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,
Expand Down
84 changes: 84 additions & 0 deletions src/modes/cclip/events/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ enum KeyAction {
Select,
Input(char),
Backspace,
DeleteWord,
HalfPageDown,
HalfPageUp,
First,
Last,
Down,
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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 { .. } => {
Expand Down Expand Up @@ -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
);
}
}
59 changes: 59 additions & 0 deletions src/modes/dmenu/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 oriented_forward = forward != (options.panels.rotation >= 180);
ui.selected = Some(crate::ui::half_page_target(
selected,
ui.shown.len(),
step,
oriented_forward,
));

options.result_layout(terminal_area, ui);
}

#[cfg(test)]
mod tests {
#[test]
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod app_list;
mod app_ui;
mod dmenu_ui;
mod editing;
mod graphics;
pub(crate) mod graphics_probe;
mod input;
Expand All @@ -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};
Expand Down
2 changes: 2 additions & 0 deletions src/ui/dmenu_ui/content/cclip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ fn decode_content_for_view(mime_type: &str, bytes: Vec<u8>, 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<AtomicUsize>) -> Option<ContentPermit> {
in_flight
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
Expand Down
Loading
Loading