diff --git a/README.md b/README.md index d6c11fe..8ead389 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ That's it. Type to search, arrow keys to navigate, Enter to launch. Need install variants, launch methods, or mode-specific examples? See [USAGE.md](./USAGE.md). +Make the layout yours: dock panels around the results, add independent dmenu previews, move them +interactively, or switch the launcher to an icon grid with distinct pinned-row colors. +All layouts are opt-in; see [panel and grid options](./USAGE.md#panel-layouts). + ## Install diff --git a/USAGE.md b/USAGE.md index 0c258f2..722086c 100644 --- a/USAGE.md +++ b/USAGE.md @@ -686,7 +686,7 @@ Note: Bare `FSEL_*` launcher keys set root defaults. `[app_launcher]` in `config **`[app_launcher]` overrides (`FSEL_APP_LAUNCHER_*`):** -`FILTER_DESKTOP`, `FILTER_ACTIONS`, `LIST_EXECUTABLES_IN_PATH`, `HIDE_BEFORE_TYPING`, `LAUNCH_PREFIX`, `MATCH_MODE`, `RANKING_MODE`, `PINNED_ORDER`, `CONFIRM_FIRST_LAUNCH`, `PREFIX_DEPTH`, `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_`) +`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_`) 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. @@ -721,7 +721,7 @@ This means you've placed a **color/UI option inside the [app_launcher] section** - General: `terminal_launcher` (use `"tty"` for TTY mode, same as -t/--tty), `keybinds` **[app_launcher] Section (strict validation):** -- `filter_desktop`, `filter_actions`, `auto_hide_duplicates`, `list_executables_in_path`, `hide_before_typing`, `match_mode`, `ranking_mode`, `pinned_order`, `confirm_first_launch`, `prefix_depth`, `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` +- `filter_desktop`, `filter_actions`, `auto_hide_duplicates`, `list_executables_in_path`, `hide_before_typing`, `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` **[dmenu] Section:** - Colors: `highlight_color`, `main_border_color`, `items_border_color`, `input_border_color`, `main_text_color`, `items_text_color`, `input_text_color`, `header_title_color` @@ -738,7 +738,41 @@ This means you've placed a **color/UI option inside the [app_launcher] section** - Images: `image_preview`, `hide_inline_image_message` # Panel layouts -## Pinned application colors +## Application grid + +The launcher has an opt-in grid; ordinary lists, dmenu, and cclip retain their layouts. + +```sh +fsel --no-exec --app-grid 4 --desktop-icons=both --grid-row-height 4 +fsel --no-exec --app-grid 4 --desktop-icons=list --info-position left --info-size 30 +``` + +`--app-grid` requests up to 64 columns; zero disables the grid. Narrow panels reduce the column +count to keep cells at least eight columns wide where possible. `--grid-row-height` sets cell +height from 2–16 terminal rows (default 4). With list icons enabled, artwork occupies the upper +rows and the label/selection marker occupies the last row. Without list icons, the grid shows +text only. Names are clipped to their own cells; icons retain proportional sizing and the existing +normalization/cache behavior. `--icon-list-width` controls artwork width; the ordinary list's +height, gap, and arrow-before placement do not change grid geometry. Negative artwork alignment +retains its documented opt-in overflow behavior. + +At rotation zero, Left/Right move one item and Up/Down move one grid row. Quarter turns use +column-first ordering, and arrow movement follows the visible orientation. Tab/Shift+Tab move +one logical item. Search, mouse selection, pins, pinned colors, and backgrounds work normally. +Scrolling advances a complete grid row (or column after a quarter turn); resizing keeps the +selected item visible. With `hard_stop`, moves beyond available items stop; otherwise they wrap. + +```toml +[app_launcher] +grid_columns = 4 +grid_row_height = 4 +icon_mode = "both" +``` + +Environment overrides are `FSEL_APP_LAUNCHER_GRID_COLUMNS` and +`FSEL_APP_LAUNCHER_GRID_ROW_HEIGHT`. These launcher-specific settings do not change dmenu or cclip. + +## Pinned row styling Pinned launcher rows can use their own text and background colors, independently of the pin glyph. Put these optional settings at the root of the configuration, outside `[app_launcher]`: diff --git a/config.toml b/config.toml index 5f26ddd..8683439 100644 --- a/config.toml +++ b/config.toml @@ -91,6 +91,11 @@ title_panel_position = "top" # WARNING: DO NOT put color/UI options here - they belong at the root level! [app_launcher] +# Grid columns: 0 keeps the normal list; 1-64 requests a grid that fits the available width +grid_columns = 0 +# Rows per grid cell, including the label (2-16); ignored while the grid is disabled +grid_row_height = 4 + # Filter apps based on OnlyShowIn/NotShowIn and $XDG_CURRENT_DESKTOP # Hides apps not meant for your desktop environment filter_desktop = true diff --git a/fsel.1 b/fsel.1 index 514550a..2f14d57 100644 --- a/fsel.1 +++ b/fsel.1 @@ -552,6 +552,9 @@ Set to \fBtrue\fR to enable deterministic duplicate suppression in app launcher .TP .B FSEL_APP_LAUNCHER_ICON_* Override icon mode, position, preview width, lookup size, or theme. See config.toml for the corresponding keys. +.TP +.B FSEL_APP_LAUNCHER_GRID_COLUMNS, FSEL_APP_LAUNCHER_GRID_ROW_HEIGHT +Set grid columns (0 disables, maximum 64) and cell height (2-16 rows). .SH FILES .TP .I ~/.config/fsel/config.toml @@ -614,3 +617,7 @@ placeholders and cancellation behavior as --preview. Zero percent hides a panel. --panel-edit enables Alt+P layout editing. Tab cycles panels, arrows dock, plus/minus resize, and Escape returns to selection. Mouse dragging docks panels; the wheel resizes the focused panel. Changes are session-only. +.PP +--app-grid COLUMNS enables a launcher grid (0 disables, maximum 64). +--grid-row-height ROWS sets cell height (2-16, default 4). List icons appear +above labels. Arrow navigation and mouse selection follow grid cells. diff --git a/src/cli/from_config.rs b/src/cli/from_config.rs index 0cfea0b..f9ace22 100644 --- a/src/cli/from_config.rs +++ b/src/cli/from_config.rs @@ -92,6 +92,12 @@ fn apply_app_launcher_overrides(default: &mut Opts, fsel_config: &FselConfig) { if let Some(height) = fsel_config.app_launcher.icon_list_height { default.desktop_icon_list_height = height; } + if let Some(columns) = fsel_config.app_launcher.grid_columns { + default.app_grid_columns = columns; + } + if let Some(height) = fsel_config.app_launcher.grid_row_height { + default.app_grid_row_height = height; + } if let Some(gap) = fsel_config.app_launcher.icon_list_gap { default.desktop_icon_list_gap = gap; } diff --git a/src/cli/help.rs b/src/cli/help.rs index b6b4942..4aafe07 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -10,7 +10,9 @@ Usage: │ ├─ --info-size Information panel share, 0-90 │ ├─ --input-size Input thickness in rows or columns │ ├─ --layout-rotation Rotate layout: 0, 90, 180, 270 -│ └─ --item-width Horizontal result width (default 24) +│ ├─ --item-width Horizontal result width (default 24) +│ ├─ --app-grid Opt-in app grid, 0 disables (maximum 64) +│ └─ --grid-row-height Grid cell height, 2-16 (default 4) │ ├─ Core Modes │ ├─ -p, --program Launch one app immediately; exact mode refuses near matches @@ -83,7 +85,9 @@ Usage: │ ├─ --info-size Information panel share, 0-90 │ ├─ --input-size Input thickness in rows or columns │ ├─ --layout-rotation Rotate layout: 0, 90, 180, 270 -│ └─ --item-width Horizontal result width (default 24) +│ ├─ --item-width Horizontal result width (default 24) +│ ├─ --app-grid Opt-in app grid, 0 disables (maximum 64) +│ └─ --grid-row-height Grid cell height, 2-16 (default 4) │ ├─ Core Modes │ ├─ -p, --program Launch one app immediately; exact mode requires an exact hit diff --git a/src/cli/parse/overrides.rs b/src/cli/parse/overrides.rs index 753c22c..4b3e272 100644 --- a/src/cli/parse/overrides.rs +++ b/src/cli/parse/overrides.rs @@ -19,6 +19,16 @@ pub(super) fn parse_cli_overrides( while let Some(arg) = parser.next()? { match arg { + Long("app-grid") => { + default.app_grid_columns = value_as_string(parser, "Invalid grid columns")? + .parse() + .map_err(|_| CliError::message("grid columns must be an integer"))?; + } + Long("grid-row-height") => { + default.app_grid_row_height = value_as_string(parser, "Invalid grid height")? + .parse() + .map_err(|_| CliError::message("grid row height must be an integer"))?; + } Long("panel-edit") => { default.dmenu_panel_edit = true; default.dmenu_mode = true; diff --git a/src/cli/types/opts.rs b/src/cli/types/opts.rs index 2a64114..2f6c429 100644 --- a/src/cli/types/opts.rs +++ b/src/cli/types/opts.rs @@ -8,6 +8,8 @@ use crate::ui::{HorizontalPosition, InputPanelStyle, PanelPosition}; pub struct Opts { pub dmenu_panels: Vec, pub dmenu_panel_edit: bool, + pub app_grid_columns: u16, + pub app_grid_row_height: u16, pub panels: crate::ui::PanelSettings, pub pinned_text_color: Option, pub pinned_background_color: Option, @@ -157,6 +159,8 @@ impl Default for Opts { Self { dmenu_panels: Vec::new(), dmenu_panel_edit: false, + app_grid_columns: 0, + app_grid_row_height: 4, panels: crate::ui::PanelSettings::default(), pinned_text_color: None, pinned_background_color: None, diff --git a/src/cli/validate.rs b/src/cli/validate.rs index 2a803b7..5fc1fa7 100644 --- a/src/cli/validate.rs +++ b/src/cli/validate.rs @@ -37,6 +37,14 @@ pub(super) fn validate(default: &mut Opts, cli_launch_methods: usize) -> Result< && !default.clear_history && !default.clear_cache && !default.refresh_cache; + if uses_desktop_icons + && default.app_grid_columns > 0 + && (default.app_grid_columns > 64 || !(2..=16).contains(&default.app_grid_row_height)) + { + return Err(CliError::message( + "grid columns must be 1-64 and grid row height must be 2-16", + )); + } if uses_desktop_icons && default.desktop_icon_mode.shows_preview() && !(10..=90).contains(&default.desktop_icon_preview_width_percent) @@ -79,6 +87,7 @@ pub(super) fn validate(default: &mut Opts, cli_launch_methods: usize) -> Result< } if uses_desktop_icons && default.desktop_icon_mode.shows_list() + && default.app_grid_columns == 0 && !(1..=8).contains(&default.desktop_icon_list_height) { return Err(CliError::message( @@ -87,6 +96,7 @@ pub(super) fn validate(default: &mut Opts, cli_launch_methods: usize) -> Result< } if uses_desktop_icons && default.desktop_icon_mode.shows_list() + && default.app_grid_columns == 0 && default.desktop_icon_list_gap > 16 { return Err(CliError::message( @@ -200,6 +210,31 @@ mod tests { use super::validate; use crate::cli::{DesktopIconMode, Opts}; + #[test] + fn grid_dimensions_are_validated_only_when_active() { + for (columns, height) in [(65, 4), (4, 1), (4, 17)] { + let mut cli = Opts { + app_grid_columns: columns, + app_grid_row_height: height, + ..Default::default() + }; + assert!(validate(&mut cli, 0).is_err()); + } + let mut disabled = Opts { + app_grid_row_height: 0, + ..Default::default() + }; + assert!(validate(&mut disabled, 0).is_ok()); + let mut grid = Opts { + app_grid_columns: 4, + desktop_icon_mode: DesktopIconMode::List, + desktop_icon_list_height: 0, + desktop_icon_list_gap: 99, + ..Default::default() + }; + assert!(validate(&mut grid, 0).is_ok()); + } + #[test] fn reject_both_index_modes() { let mut cli = Opts { diff --git a/src/config/env/app_launcher.rs b/src/config/env/app_launcher.rs index d7fbe4f..df32b3b 100644 --- a/src/config/env/app_launcher.rs +++ b/src/config/env/app_launcher.rs @@ -12,6 +12,18 @@ pub(super) fn apply(cfg: &mut FselConfig, source: &impl OverrideSource) -> Resul &mut cfg.app_launcher.icon_description_position, "top, right, bottom, or left", )?; + set_optional_parsed( + source, + "FSEL_APP_LAUNCHER_GRID_COLUMNS", + &mut cfg.app_launcher.grid_columns, + INTEGER_EXPECTED, + )?; + set_optional_parsed( + source, + "FSEL_APP_LAUNCHER_GRID_ROW_HEIGHT", + &mut cfg.app_launcher.grid_row_height, + INTEGER_EXPECTED, + )?; set_optional_parsed( source, "FSEL_APP_LAUNCHER_FILTER_DESKTOP", diff --git a/src/config/schema.rs b/src/config/schema.rs index a479e2b..11efb56 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -27,6 +27,8 @@ pub struct FselConfig { /// Legacy `[app_launcher]` section for backward compatibility. #[derive(Debug, Deserialize, Clone, Default)] pub struct AppLauncherConfig { + pub grid_columns: Option, + pub grid_row_height: Option, pub filter_desktop: Option, pub filter_actions: Option, pub auto_hide_duplicates: Option, diff --git a/src/modes/app_launcher/events.rs b/src/modes/app_launcher/events.rs index df246f6..aac8893 100644 --- a/src/modes/app_launcher/events.rs +++ b/src/modes/app_launcher/events.rs @@ -99,6 +99,34 @@ fn handle_key_event( state.should_exit = true; } + if cli.app_grid_columns > 0 && matches!(msg, Message::MoveUp | Message::MoveDown) { + let layout = crate::ui::launcher_result_layout(terminal_area, cli); + if let Some(selected) = state.selected { + let vertical = cli.keybinds.matches_up(key.code, key.modifiers) + || cli.keybinds.matches_down(key.code, key.modifiers); + let backwards = matches!(msg, Message::MoveUp) ^ (cli.panels.rotation >= 180); + let next = grid_neighbor( + selected, + state.shown.len(), + if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) { + 1 + } else { + layout.navigation_step(vertical) + }, + backwards, + cli.hard_stop, + ); + crate::core::state::update( + state, + Message::SelectIndex(next), + cli.hard_stop, + max_visible, + ); + } + layout.keep_visible(state.selected, &mut state.scroll_offset); + refresh_info(state, cli); + return; + } let oriented = if cli.panels.rotation >= 180 { match msg { Message::MoveUp => Message::MoveDown, @@ -109,6 +137,8 @@ fn handle_key_event( msg }; crate::core::state::update(state, oriented, cli.hard_stop, max_visible); + crate::ui::launcher_result_layout(terminal_area, cli) + .keep_visible(state.selected, &mut state.scroll_offset); refresh_info(state, cli); } @@ -149,7 +179,7 @@ fn handle_mouse_event( && !state.shown.is_empty() && state.scroll_offset + metrics.capacity() < state.shown.len() { - state.scroll_offset += 1; + state.scroll_offset = state.scroll_offset.saturating_add(metrics.scroll_step()); if let Some(index) = metrics.hit(mouse_event.column, mouse_event.row) { state.selected = Some((state.scroll_offset + index).min(state.shown.len() - 1)); } @@ -162,7 +192,7 @@ fn handle_mouse_event( && !state.shown.is_empty() && state.scroll_offset > 0 { - state.scroll_offset -= 1; + state.scroll_offset = state.scroll_offset.saturating_sub(metrics.scroll_step()); if let Some(index) = metrics.hit(mouse_event.column, mouse_event.row) { state.selected = Some((state.scroll_offset + index).min(state.shown.len() - 1)); } @@ -183,6 +213,36 @@ fn handle_mouse_event( } } +fn grid_neighbor( + selected: usize, + len: usize, + step: usize, + backwards: bool, + hard_stop: bool, +) -> usize { + if len == 0 { + return selected; + } + let next = if backwards { + selected.checked_sub(step) + } else { + selected.checked_add(step).filter(|index| *index < len) + }; + if let Some(next) = next { + return next; + } + if hard_stop { + return selected; + } + let step = step.max(1); + let axis_start = selected % step; + if backwards { + axis_start + ((len - 1 - axis_start) / step) * step + } else { + axis_start + } +} + fn toggle_selected_pin(state: &mut State, db: &std::sync::Arc) { let Some(index) = state.selected else { return; @@ -277,3 +337,15 @@ fn list_metrics( ) -> crate::ui::result_layout::ResultLayout { crate::ui::launcher_result_layout(terminal_area, cli) } + +#[cfg(test)] +mod grid_tests { + #[test] + fn grid_navigation_steps_by_row_and_respects_hard_stop() { + assert_eq!(super::grid_neighbor(2, 11, 4, false, false), 6); + assert_eq!(super::grid_neighbor(2, 11, 4, true, true), 2); + assert_eq!(super::grid_neighbor(2, 11, 4, true, false), 10); + assert_eq!(super::grid_neighbor(9, 11, 4, false, false), 1); + assert_eq!(super::grid_neighbor(9, 11, 4, false, true), 9); + } +} diff --git a/src/ui/app_list.rs b/src/ui/app_list.rs index e608bfb..3e85260 100644 --- a/src/ui/app_list.rs +++ b/src/ui/app_list.rs @@ -24,11 +24,31 @@ pub(crate) fn launcher_visible_rows(size: Rect, cli: &Opts) -> usize { } pub(crate) fn launcher_result_layout(size: Rect, cli: &Opts) -> ResultLayout { - ResultLayout::new( - launcher_list_content_area(size, cli), - app_row_height(cli), - &cli.panels, - ) + result_layout(launcher_list_content_area(size, cli), cli) +} + +fn result_layout(area: Rect, cli: &Opts) -> super::result_layout::ResultLayout { + if cli.app_grid_columns > 0 { + let pin_width = if cli.show_pin_icons { + UnicodeWidthStr::width(cli.pin_icon.as_str()).saturating_add(1) + } else { + 0 + }; + let minimum_width = (usize::from(marker_gutter_width(cli)) + pin_width) + .saturating_mul(2) + .saturating_add(8) + .min(usize::from(u16::MAX)) as u16; + let columns = cli + .app_grid_columns + .min((area.width / minimum_width).max(1)); + return super::result_layout::ResultLayout::grid( + area, + app_row_height(cli), + columns, + &cli.panels, + ); + } + super::result_layout::ResultLayout::new(area, app_row_height(cli), &cli.panels) } pub(crate) fn launcher_list_content_area(size: Rect, cli: &Opts) -> Rect { @@ -37,7 +57,9 @@ pub(crate) fn launcher_list_content_area(size: Rect, cli: &Opts) -> Rect { } pub(crate) fn app_row_height(cli: &Opts) -> u16 { - if cli.desktop_icon_mode.shows_list() { + if cli.app_grid_columns > 0 { + cli.app_grid_row_height.max(2) + } else if cli.desktop_icon_mode.shows_list() { cli.desktop_icon_list_height.max(1) } else { 1 @@ -46,13 +68,12 @@ pub(crate) fn app_row_height(cli: &Opts) -> u16 { pub(crate) fn launcher_list_icon_area(size: Rect, cli: &Opts) -> Rect { let inner = launcher_list_content_area(size, cli); - let slot = - super::result_layout::ResultLayout::new(inner, app_row_height(cli), &cli.panels).slot(0); + let slot = result_layout(inner, cli).slot(0); let content = list_content_area(slot, cli); let Some(icon_strip) = list_areas(content, cli).icon else { return Rect::default(); }; - Rect::new(0, 0, icon_strip.width, app_row_height(cli)) + Rect::new(0, 0, icon_strip.width, icon_strip.height) } pub(super) fn render( @@ -65,7 +86,7 @@ pub(super) fn render( let block = apps_block(cli); let inner = block.inner(area); frame.render_widget(block, area); - let layout = super::result_layout::ResultLayout::new(inner, app_row_height(cli), &cli.panels); + let layout = result_layout(inner, cli); let mut render_failed = false; for (index, app) in state .shown @@ -102,8 +123,12 @@ pub(super) fn render( )); spans.push(Span::raw(" ")); } - spans.push(Span::raw(&app.name)); - frame.render_widget(Paragraph::new(Line::from(spans)).style(style), areas.text); + if cli.app_grid_columns > 0 { + render_grid_label(frame, areas.text, cli, &app.name, spans, style); + } else { + spans.push(Span::raw(&app.name)); + frame.render_widget(Paragraph::new(Line::from(spans)).style(style), areas.text); + } if selected && let Some(marker) = areas.selection { frame.render_widget( Paragraph::new(format!("{} ", cli.selection_marker)).style(style), @@ -175,6 +200,44 @@ fn row_style( ) } +fn render_grid_label( + frame: &mut Frame, + area: Rect, + cli: &Opts, + name: &str, + decorations: Vec>, + style: Style, +) { + // Reserve identical side gutters for every cell, regardless of pin/selection state. + let pin_width = if cli.show_pin_icons { + UnicodeWidthStr::width(cli.pin_icon.as_str()) + 1 + } else { + 0 + }; + let gutter = (usize::from(marker_gutter_width(cli)) + pin_width) + .min(usize::from(area.width.saturating_sub(1) / 2)) as u16; + let text = Rect::new( + area.x + gutter, + area.y, + area.width.saturating_sub(gutter * 2), + area.height, + ); + frame.render_widget( + Paragraph::new(name) + .style(style) + .alignment(ratatui::layout::Alignment::Center), + text, + ); + let name_width = UnicodeWidthStr::width(name).min(usize::from(text.width)) as u16; + let name_x = text.x + (text.width - name_width) / 2; + frame.render_widget( + Paragraph::new(Line::from(decorations)) + .style(style) + .alignment(ratatui::layout::Alignment::Right), + Rect::new(area.x, area.y, name_x.saturating_sub(area.x), area.height), + ); +} + fn overflow_icon_area(item_area: Rect, top_overflow_rows: u16) -> Rect { Rect::new( item_area.x, @@ -226,6 +289,19 @@ fn apps_block(cli: &Opts) -> Block<'static> { } fn list_areas(area: Rect, cli: &Opts) -> ListAreas { + if cli.app_grid_columns > 0 && !area.is_empty() { + let icon_width = cli.desktop_icon_list_width.min(area.width); + return ListAreas { + text: Rect::new(area.x, area.bottom() - 1, area.width, 1), + icon: cli.desktop_icon_mode.shows_list().then_some(Rect::new( + area.x + (area.width - icon_width) / 2, + area.y, + icon_width, + area.height - 1, + )), + selection: None, + }; + } if !cli.desktop_icon_mode.shows_list() || area.width < 4 { return ListAreas { text: area, diff --git a/src/ui/app_list/tests.rs b/src/ui/app_list/tests.rs index ee5eff9..f68ac3b 100644 --- a/src/ui/app_list/tests.rs +++ b/src/ui/app_list/tests.rs @@ -275,3 +275,104 @@ fn pinned_colors_are_opt_in_and_do_not_change_unpinned_rows() { Some(Color::Black) ); } +#[test] +fn grid_reserves_separate_artwork_and_label_rows() { + let cli = crate::cli::Opts { + app_grid_columns: 4, + app_grid_row_height: 4, + desktop_icon_mode: crate::cli::DesktopIconMode::List, + ..crate::cli::Opts::default() + }; + let areas = super::list_areas(Rect::new(2, 5, 20, 4), &cli); + assert_eq!(areas.text, Rect::new(2, 8, 20, 1)); + let icon = areas.icon.expect("list mode should show grid artwork"); + assert_eq!(icon.height, 3); + assert!(icon.intersection(areas.text).is_empty()); + assert_eq!(super::app_row_height(&cli), 4); +} + +#[test] +fn short_grid_labels_are_centered_but_list_labels_stay_left() { + let app = crate::desktop::App::parse( + "[Desktop Entry]\nType=Application\nName=Zed\nExec=zed\n", + false, + ) + .expect("minimal desktop fixture parses"); + let state = crate::core::state::State::new( + vec![app], + Default::default(), + Default::default(), + 0, + Default::default(), + Default::default(), + Default::default(), + ); + for columns in [0, 1] { + let cli = Opts { + app_grid_columns: columns, + app_grid_row_height: 4, + show_items_border: false, + show_panel_titles: false, + show_selection_marker: false, + ..Opts::default() + }; + let mut terminal = Terminal::new(TestBackend::new(21, 4)).expect("test terminal"); + terminal + .draw(|frame| { + super::render(frame, &state, &cli, frame.area(), None).expect("labels render"); + }) + .expect("test frame"); + let expected = if columns == 0 { (0, 0) } else { (9, 3) }; + assert_eq!(terminal.backend().buffer()[expected].symbol(), "Z"); + } +} + +#[test] +fn grid_decorations_do_not_shift_or_shorten_names() { + use ratatui::{style::Style, text::Span}; + let cli = Opts { + pin_icon: "📌".into(), + ..Opts::default() + }; + for name in ["Zed", "A longer application name"] { + let mut rendered_names = Vec::new(); + for prefix in ["", "> ", "📌 ", "> 📌 "] { + let mut terminal = Terminal::new(TestBackend::new(21, 1)).expect("test terminal"); + terminal + .draw(|frame| { + super::render_grid_label( + frame, + frame.area(), + &cli, + name, + vec![Span::raw(prefix)], + Style::default(), + ); + }) + .expect("frame renders"); + let buffer = terminal.backend().buffer(); + let start = if name == "Zed" { 9 } else { 5 }; + assert_eq!(buffer[(start, 0)].symbol(), &name[..1]); + rendered_names.push( + (start..16) + .map(|x| buffer[(x, 0)].symbol()) + .collect::(), + ); + } + assert!(rendered_names.windows(2).all(|pair| pair[0] == pair[1])); + } +} + +#[test] +fn dense_grids_keep_room_for_names_and_decorations() { + let cli = Opts { + app_grid_columns: 10, + ..Opts::default() + }; + let layout = super::result_layout(Rect::new(0, 0, 80, 20), &cli); + let slot = layout.slot(0); + let decoration_width = super::marker_gutter_width(&cli) + + unicode_width::UnicodeWidthStr::width(cli.pin_icon.as_str()) as u16 + + 1; + assert!(slot.width >= decoration_width * 2 + 8); +} diff --git a/src/ui/result_layout.rs b/src/ui/result_layout.rs index 4e54504..16faa6f 100644 --- a/src/ui/result_layout.rs +++ b/src/ui/result_layout.rs @@ -8,18 +8,21 @@ pub(crate) struct ResultLayout { width: u16, height: u16, horizontal: bool, + grid_columns: Option, reverse: bool, } impl ResultLayout { pub(crate) fn keep_visible(self, selected: Option, offset: &mut usize) { if let Some(index) = selected { + let step = self.scroll_step(); + *offset = *offset / step * step; if index < *offset { - *offset = index; + *offset = index / step * step; } let capacity = self.capacity(); if capacity > 0 && index >= offset.saturating_add(capacity) { - *offset = index.saturating_sub(capacity - 1); + *offset = (index - capacity + step) / step * step; } } } @@ -34,11 +37,46 @@ impl ResultLayout { }, height: row_height.max(1), horizontal, + grid_columns: None, reverse: settings.rotation >= 180, } } + pub(crate) fn grid( + area: Rect, + row_height: u16, + columns: u16, + settings: &super::PanelSettings, + ) -> Self { + let columns = columns.max(1).min((area.width / 8).max(1)).min(area.width); + let mut layout = Self::new(area, row_height, settings); + layout.width = area.width.checked_div(columns).unwrap_or(1); + layout.grid_columns = Some(columns); + layout + } + + pub(crate) fn scroll_step(self) -> usize { + self.grid_columns.map_or(1, |columns| { + if self.horizontal { + usize::from(self.area.height / self.height).max(1) + } else { + usize::from(columns).max(1) + } + }) + } + + pub(crate) fn navigation_step(self, vertical: bool) -> usize { + if self.grid_columns.is_some() && vertical != self.horizontal { + self.scroll_step() + } else { + 1 + } + } + pub(crate) fn capacity(self) -> usize { + if let Some(columns) = self.grid_columns { + return usize::from(columns) * usize::from(self.area.height / self.height); + } if self.area.width == 0 || self.area.height < self.height { return 0; } @@ -58,11 +96,26 @@ impl ResultLayout { capacity - 1 - index } else { index - } as u16; + }; + if let Some(columns) = self.grid_columns { + let rows = usize::from(self.area.height / self.height); + let columns = usize::from(columns); + let (column, row) = if self.horizontal { + (physical / rows, physical % rows) + } else { + (physical % columns, physical / columns) + }; + return Rect::new( + self.area.x + column as u16 * self.width, + self.area.y + row as u16 * self.height, + self.width, + self.height, + ); + } Rect::new( self.area.x + if self.horizontal { - physical * self.width + physical as u16 * self.width } else { 0 }, @@ -70,7 +123,7 @@ impl ResultLayout { + if self.horizontal { 0 } else { - physical * self.height + physical as u16 * self.height }, self.width.min(self.area.width), self.height, @@ -82,6 +135,24 @@ impl ResultLayout { if !self.area.contains(position) { return None; } + if let Some(columns) = self.grid_columns { + let x = (column - self.area.x) / self.width; + let y = (row - self.area.y) / self.height; + let rows = self.area.height / self.height; + if x >= columns || y >= rows { + return None; + } + let physical = if self.horizontal { + usize::from(x) * usize::from(rows) + usize::from(y) + } else { + usize::from(y) * usize::from(columns) + usize::from(x) + }; + return Some(if self.reverse { + self.capacity() - 1 - physical + } else { + physical + }); + } let physical = usize::from(if self.horizontal { if row >= self.area.y + self.height { return None; @@ -106,6 +177,31 @@ impl ResultLayout { mod tests { use super::*; + #[test] + fn grid_slots_and_scroll_offsets_remain_aligned_when_resized() { + for rotation in [0, 90, 180, 270] { + let settings = super::super::PanelSettings { + rotation, + ..Default::default() + }; + for width in [0, 1, 7, 19, 80] { + let layout = ResultLayout::grid(Rect::new(3, 5, width, 21), 4, 4, &settings); + for index in 0..layout.capacity() { + let area = layout.slot(index); + assert_eq!(layout.hit(area.x, area.y), Some(index)); + assert_eq!(layout.hit(area.right() - 1, area.bottom() - 1), Some(index)); + } + if layout.capacity() > 0 { + let mut offset = 0; + layout.keep_visible(Some(41), &mut offset); + assert_eq!(offset % layout.scroll_step(), 0); + assert!(offset <= 41 && 41 < offset + layout.capacity()); + } + assert_eq!(layout.hit(3 + width, 5), None); + } + } + } + #[test] fn pointer_and_rendering_agree_in_all_orientations() { for rotation in [0, 90, 180, 270] {