diff --git a/docs/modules/Launcher.md b/docs/modules/Launcher.md index 2c93432..3d6dfa8 100644 --- a/docs/modules/Launcher.md +++ b/docs/modules/Launcher.md @@ -13,22 +13,23 @@ Optionally displays a launchable set of favourites. > Type: `launcher` -| | Type | Default | Description | -|-----------------------------|---------------------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------| -| `favorites` | `string[]` | `[]` | List of app IDs (or classes) to always show at the start of the launcher. | -| `show_names` | `boolean` | `false` | Whether to show app names on the button label. Names will still show on tooltips when set to false. | -| `show_icons` | `boolean` | `true` | Whether to show app icons on the button. | -| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). | -| `reversed` | `boolean` | `false` | Whether to reverse the order of favorites/items | -| `minimize_focused` | `boolean` | `true` | Whether to minimize a focused window when its icon is clicked. Only minimizes single windows. | -| `truncate.mode` | `'start'` or `'middle'` or `'end'` or `off` | `end` | The location of the ellipses and where to truncate text from. Applies to application names when `show_names` is enabled. | -| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | -| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | -| `truncate_popup.mode` | `'start'` or `'middle'` or `'end'` or `off` | `middle` | The location of the ellipses and where to truncate text from. Applies to window names within a group popup. | -| `truncate_popup.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | -| `truncate_popup.max_length` | `integer` | `25` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | - - +| | Type | Default | Description | +|-----------------------------|---------------------------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `favorites` | `string[]` | `[]` | List of app IDs (or classes) to always show at the start of the launcher. | +| `show_names` | `boolean` | `false` | Whether to show app names on the button label. Names will still show on tooltips when set to false. | +| `show_icons` | `boolean` | `true` | Whether to show app icons on the button. | +| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). | +| `reversed` | `boolean` | `false` | Whether to reverse the order of favorites/items | +| `minimize_focused` | `boolean` | `true` | Whether to minimize a focused window when its icon is clicked. Only minimizes single windows. | +| `truncate.mode` | `'start'` or `'middle'` or `'end'` or `off` | `end` | Location of the ellipses and where to truncate text from. Applies to application names when `show_names` is enabled. | +| `truncate.length` | `integer` | `null` | Fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate.max_length` | `integer` | `null` | Maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| `truncate_popup.mode` | `'start'` or `'middle'` or `'end'` or `off` | `middle` | Location of the ellipses and where to truncate text from. Applies to window names within a group popup. | +| `truncate_popup.length` | `integer` | `null` | Fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate_popup.max_length` | `integer` | `25` | Maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| `page_size` | `integer` | `1000` | Number of items to show on a page. When the number of items is reached, controls appear which can be used to move forward/back through the list of items. | +| `icons.page_back` | `string` or [image](images) | `󰅁` | Icon to show for page back button. | +| `icons.page_forward` | `string` or [image](images) | `󰅂` | Icon to show for page forward button. |
JSON @@ -104,14 +105,17 @@ start: ## Styling -| Selector | Description | -|-------------------------------|--------------------------| -| `.launcher` | Launcher widget box | -| `.launcher .item` | App button | -| `.launcher .item.open` | App button (open app) | -| `.launcher .item.focused` | App button (focused app) | -| `.launcher .item.urgent` | App button (urgent app) | -| `.popup-launcher` | Popup container | -| `.popup-launcher .popup-item` | Window button in popup | +| Selector | Description | +|--------------------------------------|---------------------------| +| `.launcher` | Launcher widget box | +| `.launcher .item` | App button | +| `.launcher .item.open` | App button (open app) | +| `.launcher .item.focused` | App button (focused app) | +| `.launcher .item.urgent` | App button (urgent app) | +| `.launcher .pagination` | Pagination controls box | +| `.launcher .pagination .btn-back` | Pagination back button | +| `.launcher .pagination .btn-forward` | Pagination forward button | +| `.popup-launcher` | Popup container | +| `.popup-launcher .popup-item` | Window button in popup | For more information on styling, please see the [styling guide](styling-guide). diff --git a/src/modules/launcher/mod.rs b/src/modules/launcher/mod.rs index fa8c206..12d35f8 100644 --- a/src/modules/launcher/mod.rs +++ b/src/modules/launcher/mod.rs @@ -1,5 +1,6 @@ mod item; mod open_state; +mod pagination; use self::item::{AppearanceOptions, Item, ItemButton, Window}; use self::open_state::OpenState; @@ -9,12 +10,14 @@ use crate::config::{CommonConfig, EllipsizeMode, TruncateMode}; use crate::desktop_file::find_desktop_file; use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt}; use crate::modules::launcher::item::ImageTextButton; +use crate::modules::launcher::pagination::{IconContext, Pagination}; use crate::{arc_mut, glib_recv, lock, module_impl, send_async, spawn, try_send, write_lock}; use color_eyre::{Help, Report}; use gtk::prelude::*; use gtk::{Button, Orientation}; use indexmap::IndexMap; use serde::Deserialize; +use std::ops::Deref; use std::process::{Command, Stdio}; use std::sync::Arc; use tokio::sync::{broadcast, mpsc}; @@ -62,6 +65,31 @@ pub struct LauncherModule { #[serde(default = "crate::config::default_true")] minimize_focused: bool, + /// The number of items to show on a page. + /// + /// When the number of items reaches the page size, + /// pagination controls appear at the start of the widget + /// which can be used to move forward/back through the list of items. + /// + /// If there are too many to fit, the overflow will be truncated + /// by the next widget. + /// + /// **Default**: `1000`. + #[serde(default = "default_page_size")] + page_size: usize, + + /// Module UI icons (separate from app icons shown for items). + /// + /// See [icons](#icons). + #[serde(default)] + icons: Icons, + + /// Size in pixels to render pagination icons at (image icons only). + /// + /// **Default**: `16` + #[serde(default = "default_icon_size_pagination")] + pagination_icon_size: i32, + // -- common -- /// Truncate application names on the bar if they get too long. /// See [truncate options](module-level-options#truncate-mode). @@ -82,10 +110,51 @@ pub struct LauncherModule { pub common: Option, } +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct Icons { + /// Icon to show for page back button. + /// + /// **Default**: `󰅁` + #[serde(default = "default_icon_page_back")] + page_back: String, + + /// Icon to show for page back button. + /// + /// **Default**: `>` + #[serde(default = "default_icon_page_forward")] + page_forward: String, +} + +impl Default for Icons { + fn default() -> Self { + Self { + page_back: default_icon_page_back(), + page_forward: default_icon_page_forward(), + } + } +} + const fn default_icon_size() -> i32 { 32 } +const fn default_icon_size_pagination() -> i32 { + default_icon_size() / 2 +} + +const fn default_page_size() -> usize { + 1000 +} + +fn default_icon_page_back() -> String { + String::from("󰅁") +} + +fn default_icon_page_forward() -> String { + String::from("󰅂") +} + const fn default_truncate_popup() -> TruncateMode { TruncateMode::Length { mode: EllipsizeMode::Middle, @@ -386,6 +455,19 @@ impl Module for LauncherModule { let icon_theme = info.icon_theme; let container = gtk::Box::new(info.bar_position.orientation(), 0); + let page_size = self.page_size; + + let pagination = Pagination::new( + &container, + self.page_size, + info.bar_position.orientation(), + IconContext { + icon_back: &self.icons.page_back, + icon_fwd: &self.icons.page_forward, + icon_size: self.pagination_icon_size, + icon_theme: info.icon_theme, + }, + ); { let container = container.clone(); @@ -407,7 +489,15 @@ impl Module for LauncherModule { let tx = context.tx.clone(); let rx = context.subscribe(); - glib_recv!(rx, event => { + + let mut handle_event = move |event: LauncherUpdate| { + // all widgets show by default + // so check if pagination should be shown + // to ensure correct state on init. + if buttons.len() <= page_size { + pagination.hide(); + } + match event { LauncherUpdate::AddItem(item) => { debug!("Adding item with id '{}' to the bar: {item:?}", item.app_id); @@ -426,9 +516,18 @@ impl Module for LauncherModule { ); if self.reversed { - container.pack_end(&button.button.button, false, false, 0); + container.pack_end(button.button.deref(), false, false, 0); } else { - container.add(&button.button.button); + container.add(button.button.deref()); + } + + if buttons.len() + 1 >= pagination.offset() + page_size { + button.button.set_visible(false); + pagination.set_sensitive_fwd(true); + } + + if buttons.len() + 1 > page_size { + pagination.show_all(); } buttons.insert(item.app_id, button); @@ -456,6 +555,14 @@ impl Module for LauncherModule { buttons.shift_remove(&app_id); } } + + if buttons.len() < pagination.offset() + page_size { + pagination.set_sensitive_fwd(false); + } + + if buttons.len() <= page_size { + pagination.hide(); + } } LauncherUpdate::RemoveWindow(app_id, win_id) => { debug!("Removing window {win_id} with id {app_id}"); @@ -485,7 +592,9 @@ impl Module for LauncherModule { } LauncherUpdate::Hover(_) => {} }; - }); + }; + + glib_recv!(rx, handle_event); } let rx = context.subscribe(); diff --git a/src/modules/launcher/pagination.rs b/src/modules/launcher/pagination.rs new file mode 100644 index 0000000..f94d10d --- /dev/null +++ b/src/modules/launcher/pagination.rs @@ -0,0 +1,140 @@ +use crate::gtk_helpers::IronbarGtkExt; +use crate::image::new_icon_button; +use gtk::prelude::*; +use gtk::{Button, IconTheme, Orientation}; +use std::cell::RefCell; +use std::ops::Deref; +use std::rc::Rc; + +pub struct Pagination { + offset: Rc>, + + controls_container: gtk::Box, + btn_fwd: Button, +} + +pub struct IconContext<'a> { + pub icon_back: &'a str, + pub icon_fwd: &'a str, + pub icon_size: i32, + pub icon_theme: &'a IconTheme, +} + +impl Pagination { + pub fn new( + container: >k::Box, + page_size: usize, + orientation: Orientation, + icon_context: IconContext, + ) -> Self { + let scroll_box = gtk::Box::new(orientation, 0); + + let scroll_back = new_icon_button( + icon_context.icon_back, + icon_context.icon_theme, + icon_context.icon_size, + ); + + let scroll_fwd = new_icon_button( + icon_context.icon_fwd, + icon_context.icon_theme, + icon_context.icon_size, + ); + + scroll_back.set_sensitive(false); + scroll_fwd.set_sensitive(false); + + scroll_box.add_class("pagination"); + scroll_back.add_class("btn-back"); + scroll_fwd.add_class("btn-forward"); + + scroll_box.add(&scroll_back); + scroll_box.add(&scroll_fwd); + container.add(&scroll_box); + + let offset = Rc::new(RefCell::new(1)); + + { + let offset = offset.clone(); + let container = container.clone(); + let scroll_back = scroll_back.clone(); + + scroll_fwd.connect_clicked(move |btn| { + let mut offset = offset.borrow_mut(); + let child_count = container.children().len(); + + *offset = std::cmp::min(child_count - 1, *offset + page_size); + + Self::update_page(&container, *offset, page_size); + + if *offset + page_size >= child_count { + btn.set_sensitive(false); + } + + scroll_back.set_sensitive(true); + }); + } + + { + let offset = offset.clone(); + let container = container.clone(); + let scroll_fwd = scroll_fwd.clone(); + + scroll_back.connect_clicked(move |btn| { + let mut offset = offset.borrow_mut(); + // avoid using std::cmp::max due to possible overflow + if page_size < *offset { + *offset -= page_size; + } else { + *offset = 1 + }; + + Self::update_page(&container, *offset, page_size); + + if *offset == 1 || *offset - page_size < 1 { + btn.set_sensitive(false); + } + + scroll_fwd.set_sensitive(true); + }); + } + + Self { + offset, + + controls_container: scroll_box, + btn_fwd: scroll_fwd, + } + } + + fn update_page(container: >k::Box, offset: usize, page_size: usize) { + for (i, btn) in container.children().iter().enumerate() { + // skip offset buttons + if i == 0 { + continue; + } + + if i >= offset && i < offset + page_size { + btn.show(); + } else { + btn.hide(); + } + } + } + + pub fn set_sensitive_fwd(&self, sensitive: bool) { + self.btn_fwd.set_sensitive(sensitive); + } + + pub fn offset(&self) -> usize { + *self.offset.borrow() + } +} + +impl Deref for Pagination { + type Target = gtk::Box; + + fn deref(&self) -> &Self::Target { + &self.controls_container + } +}