aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorkangal-bot <kangal@herdr.dev>2026-05-27 15:09:31 +0300
committerGitHub <noreply@github.com>2026-05-27 15:09:31 +0300
commit858c36974bad616e9e6c1897882b8c9a97396bec (patch)
tree63a6b332599596708dc7a66cead14af474755fd2 /src
parent9a89e3dc181bdf35101c2066bea114394afb93e2 (diff)
feat: configure mobile width threshold (#317)
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs29
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config.rs1
-rw-r--r--src/config/model.rs13
-rw-r--r--src/main.rs4
-rw-r--r--src/ui.rs20
-rw-r--r--src/ui/mobile.rs5
7 files changed, 68 insertions, 6 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index cad09a71..12fb704e 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -478,6 +478,7 @@ impl App {
sidebar_width,
sidebar_min_width,
sidebar_max_width,
+ mobile_width_threshold: config.ui.mobile_width_threshold,
sidebar_width_source,
sidebar_width_auto: false,
sidebar_collapsed: false,
@@ -1079,6 +1080,7 @@ impl App {
}
self.state.sidebar_min_width = config.ui.sidebar_min_width;
self.state.sidebar_max_width = config.ui.sidebar_max_width;
+ self.state.mobile_width_threshold = config.ui.mobile_width_threshold;
// Re-clamp the live width to the new bounds. No source guard — bounds
// always apply, including to widths owned by Persisted or Manual.
self.state.sidebar_width = self
@@ -1778,6 +1780,10 @@ mod tests {
// Default bounds.
assert_eq!(app.state.sidebar_min_width, 18);
assert_eq!(app.state.sidebar_max_width, 36);
+ assert_eq!(
+ app.state.mobile_width_threshold,
+ crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD
+ );
// Manually set a width and flip the source so the existing
// sidebar_width-only-when-config-owned guard does NOT update it.
@@ -1818,6 +1824,29 @@ mod tests {
}
#[test]
+ fn reload_config_updates_mobile_width_threshold() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-mobile-width-threshold");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ assert_eq!(
+ app.state.mobile_width_threshold,
+ crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD
+ );
+
+ std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap();
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.mobile_width_threshold, 96);
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn app_new_falls_back_to_default_bounds_on_inverted_config() {
let mut config = Config::default();
config.ui.sidebar_min_width = 50;
diff --git a/src/app/state.rs b/src/app/state.rs
index de1db052..7c47973c 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1119,6 +1119,7 @@ pub struct AppState {
pub sidebar_width: u16,
pub sidebar_min_width: u16,
pub sidebar_max_width: u16,
+ pub mobile_width_threshold: u16,
pub sidebar_width_source: SidebarWidthSource,
pub sidebar_width_auto: bool,
pub sidebar_collapsed: bool,
@@ -1424,6 +1425,7 @@ impl AppState {
sidebar_width: 26,
sidebar_min_width: 18,
sidebar_max_width: 36,
+ mobile_width_threshold: crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD,
sidebar_width_source: SidebarWidthSource::ConfigDefault,
sidebar_width_auto: false,
sidebar_collapsed: false,
diff --git a/src/config.rs b/src/config.rs
index d874455e..b4fcf1ef 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -30,6 +30,7 @@ pub(crate) use self::io::upsert_top_level_bool;
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
pub const DEFAULT_MOUSE_SCROLL_LINES: usize = 3;
+pub const DEFAULT_MOBILE_WIDTH_THRESHOLD: u16 = 64;
#[cfg(test)]
pub(crate) fn app_dir_name() -> &'static str {
diff --git a/src/config/model.rs b/src/config/model.rs
index d4830df1..ac304973 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -3,8 +3,8 @@ use std::num::NonZeroUsize;
use serde::{Deserialize, Deserializer, Serialize};
use super::{
- BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_MOUSE_SCROLL_LINES,
- DEFAULT_SCROLLBACK_LIMIT_BYTES,
+ BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD,
+ DEFAULT_MOUSE_SCROLL_LINES, DEFAULT_SCROLLBACK_LIMIT_BYTES,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
@@ -259,6 +259,8 @@ pub struct UiConfig {
pub sidebar_min_width: u16,
/// Maximum sidebar width (columns) when expanded. Default: 36.
pub sidebar_max_width: u16,
+ /// Terminal width at or below which Herdr uses the mobile single-column layout. Default: 64.
+ pub mobile_width_threshold: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
/// Force a full host-terminal redraw when the outer terminal regains focus. Default: true.
@@ -420,6 +422,7 @@ impl Default for UiConfig {
sidebar_width: 26,
sidebar_min_width: 18,
sidebar_max_width: 36,
+ mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD,
mouse_capture: true,
redraw_on_focus_gained: true,
mouse_scroll_lines: None,
@@ -642,15 +645,21 @@ cjk_ime_agents = ["claude", "codex"]
let default_config = Config::default();
assert_eq!(default_config.ui.sidebar_min_width, 18);
assert_eq!(default_config.ui.sidebar_max_width, 36);
+ assert_eq!(
+ default_config.ui.mobile_width_threshold,
+ DEFAULT_MOBILE_WIDTH_THRESHOLD
+ );
let toml = r#"
[ui]
sidebar_min_width = 12
sidebar_max_width = 80
+mobile_width_threshold = 96
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.ui.sidebar_min_width, 12);
assert_eq!(config.ui.sidebar_max_width, 80);
+ assert_eq!(config.ui.mobile_width_threshold, 96);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 0d42effe..5f20d3a3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -178,6 +178,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Maximum sidebar width when expanded (columns)
# sidebar_max_width = 36
+# Terminal width at or below which Herdr uses the mobile single-column layout.
+# Increase this for foldables, tablets, or wide phone terminals.
+# mobile_width_threshold = 64
+
# Capture mouse input for Herdr's mouse UI.
# Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs.
# Pane apps like lazygit and btop can still receive mouse when they request it.
diff --git a/src/ui.rs b/src/ui.rs
index 7b45c8c4..78c4b2e7 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -170,7 +170,7 @@ fn compute_view_internal(
resize_panes: bool,
cell_size: crate::kitty_graphics::HostCellSize,
) {
- if is_mobile_width(area) {
+ if is_mobile_width(area, app.mobile_width_threshold) {
compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size);
return;
}
@@ -544,6 +544,24 @@ mod tests {
}
#[test]
+ fn configured_mobile_width_threshold_controls_layout_switch() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+ assert_eq!(app.view.layout, ViewLayout::Desktop);
+
+ app.mobile_width_threshold = 90;
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+ assert_eq!(app.view.layout, ViewLayout::Mobile);
+ assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 80, 2));
+ assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18));
+ }
+
+ #[test]
fn product_announcement_renders_above_config_diagnostic() {
let mut app = crate::app::state::AppState::test_new();
app.workspaces = vec![Workspace::test_new("one")];
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 2de79d23..c1dd57b6 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -14,7 +14,6 @@ use crate::detect::AgentState;
use crate::layout::PaneId;
use crate::terminal::TerminalRuntimeRegistry;
-pub(crate) const MOBILE_WIDTH_THRESHOLD: u16 = 64;
const SWITCH_BUTTON_WIDTH: u16 = 10;
#[derive(Debug, Clone, Copy, Default)]
@@ -42,8 +41,8 @@ pub(crate) enum MobileSwitcherTarget {
Menu(usize),
}
-pub(crate) fn is_mobile_width(area: Rect) -> bool {
- area.width > 0 && area.width <= MOBILE_WIDTH_THRESHOLD
+pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool {
+ area.width > 0 && area.width <= threshold
}
pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas {