Skip to content
Closed
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
12 changes: 4 additions & 8 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1339,8 +1339,7 @@ impl AppState {
}
}

#[cfg(test)]
pub fn next_workspace(&mut self) {
pub(crate) fn next_workspace(&mut self) {
if self.workspaces.is_empty() {
return;
}
Expand All @@ -1351,8 +1350,7 @@ impl AppState {
self.switch_workspace(next);
}

#[cfg(test)]
pub fn previous_workspace(&mut self) {
pub(crate) fn previous_workspace(&mut self) {
if self.workspaces.is_empty() {
return;
}
Expand Down Expand Up @@ -1513,8 +1511,7 @@ impl AppState {
self.cycle_agent_entry(false);
}

#[cfg(test)]
pub fn focus_agent_entry(&mut self, idx: usize) -> bool {
pub(crate) fn focus_agent_entry(&mut self, idx: usize) -> bool {
let entries = crate::ui::agent_panel_entries(self);
let Some(target) = entries.get(idx) else {
return false;
Expand All @@ -1535,8 +1532,7 @@ impl AppState {
false
}

#[cfg(test)]
fn cycle_agent_entry(&mut self, forward: bool) {
pub(crate) fn cycle_agent_entry(&mut self, forward: bool) {
let entries = crate::ui::agent_panel_entries(self);
if entries.is_empty() {
return;
Expand Down
97 changes: 95 additions & 2 deletions src/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,13 @@ impl AppState {
let over_agent_panel = agent_area != Rect::default()
&& mouse.row >= agent_area.y
&& mouse.row < agent_area.y + agent_area.height;
if over_agent_panel {
if self.sidebar_wheel == crate::config::SidebarWheelConfig::Switch {
if over_agent_panel {
self.cycle_agent_entry(false);
} else {
self.previous_workspace();
}
} else if over_agent_panel {
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
self, agent_area,
)) {
Expand All @@ -1007,7 +1013,13 @@ impl AppState {
let over_agent_panel = agent_area != Rect::default()
&& mouse.row >= agent_area.y
&& mouse.row < agent_area.y + agent_area.height;
if over_agent_panel {
if self.sidebar_wheel == crate::config::SidebarWheelConfig::Switch {
if over_agent_panel {
self.cycle_agent_entry(true);
} else {
self.next_workspace();
}
} else if over_agent_panel {
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
self, agent_area,
)) {
Expand Down Expand Up @@ -4777,4 +4789,85 @@ mod tests {

assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
}

#[test]
fn wheel_over_agent_panel_switches_agent() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
let root = ws.tabs[0].root_pane;
let second = ws.test_split(Direction::Horizontal);
ws.tabs[0].layout.focus_pane(root);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.ensure_test_terminals();

// Agent panel entries only include panes whose terminal has an agent
// label; give both panes one so cycle_agent_entry has entries to cycle.
let tids: Vec<_> = app.state.workspaces[0].tabs[0]
.panes
.values()
.map(|pane| pane.attached_terminal_id.clone())
.collect();
for tid in tids {
app.state
.terminals
.get_mut(&tid)
.expect("test terminal present")
.set_agent_name("test-agent".into());
}

crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let agent_area = app.state.agent_panel_rect();
assert_ne!(agent_area, Rect::default());

app.handle_mouse(mouse(MouseEventKind::ScrollDown, agent_area.x + 1, agent_area.y + 2));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(second));

app.handle_mouse(mouse(MouseEventKind::ScrollDown, agent_area.x + 1, agent_area.y + 2));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));

app.handle_mouse(mouse(MouseEventKind::ScrollUp, agent_area.x + 1, agent_area.y + 2));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(second));
}

#[test]
fn wheel_over_workspace_list_switches_workspace() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;

crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let ws_area = app.state.workspace_list_rect();
assert_ne!(ws_area, Rect::default());

app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2));
assert_eq!(app.state.active, Some(1));

app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2));
assert_eq!(app.state.active, Some(0)); // cycles back around

app.handle_mouse(mouse(MouseEventKind::ScrollUp, ws_area.x + 1, ws_area.y + 2));
assert_eq!(app.state.active, Some(1));
}

#[test]
fn wheel_scroll_mode_preserves_scrolling() {
let mut app = app_for_mouse_test();
app.state.sidebar_wheel = crate::config::SidebarWheelConfig::Scroll;
app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;

crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let ws_area = app.state.workspace_list_rect();

// Scroll mode only moves the highlight; it never switches the active workspace.
app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2));
assert_eq!(app.state.active, Some(0));
}
}
2 changes: 2 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ impl App {
agent_view_override: None,
sidebar_agents: config.ui.sidebar.agents.clone(),
sidebar_spaces: config.ui.sidebar.spaces.clone(),
sidebar_wheel: config.ui.sidebar.wheel,
next_agent_state_change_seq: 0,
mouse_capture: config.ui.mouse_capture,
copy_on_select: config.ui.copy_on_select,
Expand Down Expand Up @@ -1503,6 +1504,7 @@ impl App {
self.state.status_indicators = config.ui.status_indicators;
self.state.sidebar_agents = config.ui.sidebar.agents.clone();
self.state.sidebar_spaces = config.ui.sidebar.spaces.clone();
self.state.sidebar_wheel = config.ui.sidebar.wheel;
self.state.agent_panel_scroll = 0;
self.state.accent = crate::config::parse_color(&config.ui.accent);
if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
Expand Down
2 changes: 2 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,7 @@ pub struct AppState {
pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>,
pub sidebar_agents: crate::config::AgentsSidebarConfig,
pub sidebar_spaces: crate::config::SpacesSidebarConfig,
pub sidebar_wheel: crate::config::SidebarWheelConfig,
pub next_agent_state_change_seq: u64,
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
Expand Down Expand Up @@ -1792,6 +1793,7 @@ impl AppState {
agent_view_override: None,
sidebar_agents: crate::config::AgentsSidebarConfig::default(),
sidebar_spaces: crate::config::SpacesSidebarConfig::default(),
sidebar_wheel: crate::config::SidebarWheelConfig::default(),
next_agent_state_change_seq: 0,
mouse_capture: true,
copy_on_select: true,
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub use self::{
},
sidebar::{
AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle,
SpaceSidebarToken, SpacesSidebarConfig,
SidebarWheelConfig, SpaceSidebarToken, SpacesSidebarConfig,
},
sound::SoundConfig,
tab_bar::TabBarRightEntryConfig,
Expand Down
33 changes: 33 additions & 0 deletions src/config/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,30 @@ impl Default for SpacesSidebarConfig {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
pub enum SidebarWheelConfig {
#[default]
#[serde(rename = "switch")]
Switch,
#[serde(rename = "scroll")]
Scroll,
}

impl SidebarWheelConfig {
pub fn as_str(&self) -> &'static str {
match self {
Self::Switch => "switch",
Self::Scroll => "scroll",
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct SidebarConfig {
pub agents: AgentsSidebarConfig,
pub spaces: SpacesSidebarConfig,
pub wheel: SidebarWheelConfig,
}

#[cfg(test)]
Expand Down Expand Up @@ -645,4 +664,18 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru
);
}
}

#[test]
fn wheel_defaults_to_switch() {
let config = SidebarConfig::default();
assert_eq!(config.wheel, SidebarWheelConfig::Switch);
}

#[test]
fn wheel_parses_switch_and_scroll() {
let switch: SidebarConfig = toml::from_str("wheel = 'switch'").unwrap();
assert_eq!(switch.wheel, SidebarWheelConfig::Switch);
let scroll: SidebarConfig = toml::from_str("wheel = 'scroll'").unwrap();
assert_eq!(scroll.wheel, SidebarWheelConfig::Scroll);
}
}
Loading