From 6df7c650cfb9cd60fd76ab78b1d9defe0d68d595 Mon Sep 17 00:00:00 2001 From: Marshall Cottrell Date: Thu, 14 May 2026 14:24:40 -0400 Subject: [PATCH] implement draggable split panes Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.rs | 87 ++++++++++++++- src/pane_drag.rs | 252 ++++++++++++++++++++++++++++++++++++++++++++ src/terminal_box.rs | 51 ++++++++- 3 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 src/pane_drag.rs diff --git a/src/main.rs b/src/main.rs index 231e9675..b76497b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,9 @@ mod menu; use terminal::{Terminal, TerminalPaneGrid, TerminalScroll}; mod terminal; +use pane_drag::{DropRegion, PaneDropPreview}; +mod pane_drag; + use terminal_box::terminal_box; use crate::dnd::DndDrop; @@ -88,6 +91,9 @@ pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon { icon_cache.get(name, size) } +// Modifier the user holds with Left-Click to start a pane drag +const PANE_DRAG_MODIFIERS: Modifiers = Modifiers::ALT; + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { @@ -402,6 +408,9 @@ pub enum Message { PaneResized(pane_grid::ResizeEvent), PaneSplit(pane_grid::Axis), PaneToggleMaximized, + PaneDragPicked(pane_grid::Pane), + PaneDragHover(pane_grid::Pane, DropRegion), + PaneDragReleased, #[cfg(feature = "password_manager")] PasswordManager(password_manager::PasswordManagerMessage), #[cfg(feature = "password_manager")] @@ -528,6 +537,12 @@ pub struct App { widget::Id, cosmic::iced::Point, )>, + // Set while the user is dragging a pane; cleared on mouse release. + pane_drag: Option, + // Last pane and region under the cursor; together they resolve the drop + // target when a pane drag is released. + hovered_pane: Option, + hovered_region: Option, #[cfg(feature = "password_manager")] password_mgr: password_manager::PasswordManager, } @@ -1883,6 +1898,9 @@ impl Application for App { shortcut_search_value: String::new(), modifiers: Modifiers::empty(), context_menu_popup: None, + pane_drag: None, + hovered_pane: None, + hovered_region: None, #[cfg(feature = "password_manager")] password_mgr: Default::default(), }; @@ -2507,8 +2525,11 @@ impl Application for App { self.modifiers = modifiers; } Message::MouseEnter(pane) => { - self.pane_model.set_focus(pane); - return self.update_focus(); + self.hovered_pane = Some(pane); + if self.config.focus_follow_mouse { + self.pane_model.set_focus(pane); + return self.update_focus(); + } } Message::ShortcutCaptureCancel => { self.shortcut_capture = None; @@ -2609,6 +2630,38 @@ impl Application for App { self.pane_model.panes.drop(pane, target); } Message::PaneDragged(_) => {} + Message::PaneDragPicked(pane) => { + // Nowhere to drop with a single pane or a maximized layout. + if self.pane_model.panes.iter().count() > 1 + && self.pane_model.panes.maximized().is_none() + { + self.pane_model.set_focus(pane); + self.pane_drag = Some(pane); + self.hovered_pane = Some(pane); + self.hovered_region = Some(DropRegion::Center); + } + } + Message::PaneDragHover(pane, region) => { + if self.pane_drag.is_some() { + self.hovered_pane = Some(pane); + self.hovered_region = Some(region); + } + } + Message::PaneDragReleased => { + if let Some(source) = self.pane_drag.take() { + // Drop on self is a no-op (Center) or, worse, an Edge drop + // would close source and fail to re-insert it. + if let (Some(target), Some(region)) = + (self.hovered_pane, self.hovered_region) + && target != source + { + self.pane_model + .panes + .drop(source, pane_grid::Target::Pane(target, region.into())); + } + self.hovered_region = None; + } + } #[cfg(feature = "password_manager")] Message::PasswordManager(msg) => { return self.password_mgr.update(msg); @@ -3445,6 +3498,8 @@ impl Application for App { .cloned() .unwrap_or_else(widget::Id::unique); if let Some(terminal) = tab_model.data::>(entity) { + let drag_active = self.pane_drag.is_some(); + let mut terminal_box = terminal_box(terminal, &self.key_binds) .id(terminal_id) .disabled(self.core.window.show_context) @@ -3453,13 +3508,17 @@ impl Application for App { .on_open_hyperlink(Some(Box::new(Message::LaunchUrl))) .on_window_focused(|| Message::WindowFocused) .on_window_unfocused(|| Message::WindowUnfocused) + .on_drag_start(PANE_DRAG_MODIFIERS, move || Message::PaneDragPicked(pane)) + .on_mouse_enter(move || Message::MouseEnter(pane)) .opacity(self.config.opacity_ratio()) .padding(space_xxs) .sharp_corners(self.core.window.sharp_corners) .show_headerbar(self.config.show_headerbar); - if self.config.focus_follow_mouse { - terminal_box = terminal_box.on_mouse_enter(move || Message::MouseEnter(pane)); + // Only listen for region changes while a drag is active. + if drag_active { + terminal_box = terminal_box + .on_drop_region(move |region| Message::PaneDragHover(pane, region)); } // If a context menu popup is active for this pane, inform the @@ -3590,7 +3649,18 @@ impl Application for App { .on_drag(Message::PaneDragged); //TODO: apply window border radius xs at bottom of window - pane_grid.into() + // Stack is always present so the widget tree doesn't change shape + // between drags; otherwise iced rebuilds the subtree and + // terminal_box's modifier state resets, breaking consecutive drags. + let drag = self + .pane_drag + .zip(self.hovered_pane) + .zip(self.hovered_region) + .map(|((source, hovered), region)| (source, hovered, region)); + cosmic::iced::widget::Stack::new() + .push(pane_grid) + .push(PaneDropPreview::new(self.pane_model.panes.layout(), drag)) + .into() } fn system_theme_update( @@ -3618,6 +3688,13 @@ impl Application for App { } _ => None, }), + // Pane-drag release runs alongside CopyPrimary above. + event::listen_with(|event, _status, _window_id| match event { + Event::Mouse(MouseEvent::ButtonReleased(MouseButton::Left)) => { + Some(Message::PaneDragReleased) + } + _ => None, + }), Subscription::run_with(TypeId::of::(), |_| { stream::channel( 100, diff --git a/src/pane_drag.rs b/src/pane_drag.rs new file mode 100644 index 00000000..e33ac320 --- /dev/null +++ b/src/pane_drag.rs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// Pane-drag-from-anywhere support for the pane_grid. Triggered by a +// configurable modifier + left-click, since iced's built-in pane drag +// only fires from a TitleBar pick area (which we don't render). + +use cosmic::Renderer; +use cosmic::iced::core::{ + Border, Color, Element, Length, Point, Rectangle, Size, + layout::{self, Layout}, + mouse, + renderer::{self, Quad, Renderer as _}, + widget::{Tree, Widget}, +}; +use cosmic::theme::Theme; +use cosmic::widget::pane_grid; + +// Flattened mirror of pane_grid::Region; derives PartialEq for +// change-detection while tracking the cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DropRegion { + Center, + Top, + Right, + Bottom, + Left, +} + +impl DropRegion { + // Mirrors iced's hovered_region heuristic: left/right thirds win over + // top/bottom thirds; the center column has its own top/center/bottom. + pub fn from_local_position(local: Point, bounds: Size) -> Self { + if local.x < bounds.width / 3.0 { + Self::Left + } else if local.x > 2.0 * bounds.width / 3.0 { + Self::Right + } else if local.y < bounds.height / 3.0 { + Self::Top + } else if local.y > 2.0 * bounds.height / 3.0 { + Self::Bottom + } else { + Self::Center + } + } + + pub fn preview_bounds(self, bounds: Rectangle) -> Rectangle { + match self { + Self::Center => bounds, + Self::Top => Rectangle { + height: bounds.height / 2.0, + ..bounds + }, + Self::Bottom => Rectangle { + y: bounds.y + bounds.height / 2.0, + height: bounds.height / 2.0, + ..bounds + }, + Self::Left => Rectangle { + width: bounds.width / 2.0, + ..bounds + }, + Self::Right => Rectangle { + x: bounds.x + bounds.width / 2.0, + width: bounds.width / 2.0, + ..bounds + }, + } + } +} + +impl From for pane_grid::Region { + fn from(region: DropRegion) -> Self { + match region { + DropRegion::Center => pane_grid::Region::Center, + DropRegion::Top => pane_grid::Region::Edge(pane_grid::Edge::Top), + DropRegion::Right => pane_grid::Region::Edge(pane_grid::Edge::Right), + DropRegion::Bottom => pane_grid::Region::Edge(pane_grid::Edge::Bottom), + DropRegion::Left => pane_grid::Region::Edge(pane_grid::Edge::Left), + } + } +} + +// Some((parent_axis, source_first)) when source and target are direct +// children of the same Split. source_first means source is the left/top +// child. None otherwise. +pub fn sibling_split_info( + layout: &pane_grid::Node, + source: pane_grid::Pane, + target: pane_grid::Pane, +) -> Option<(pane_grid::Axis, bool)> { + fn walk( + node: &pane_grid::Node, + source: pane_grid::Pane, + target: pane_grid::Pane, + ) -> Option<(pane_grid::Axis, bool)> { + match node { + pane_grid::Node::Pane(_) => None, + pane_grid::Node::Split { + axis, + a: lhs, + b: rhs, + .. + } => { + if let (pane_grid::Node::Pane(pa), pane_grid::Node::Pane(pb)) = (&**lhs, &**rhs) { + if *pa == source && *pb == target { + return Some((*axis, true)); + } + if *pa == target && *pb == source { + return Some((*axis, false)); + } + } + walk(lhs, source, target).or_else(|| walk(rhs, source, target)) + } + } + } + if source == target { + None + } else { + walk(layout, source, target) + } +} + +// Overlay widget stacked above the PaneGrid that draws a single drop-preview +// rectangle. Always present in the view (so the widget tree shape doesn't +// shift between drags, which would reset terminal_box's tracked modifier +// state); renders nothing when `drag` is None. +pub struct PaneDropPreview<'a> { + layout: &'a pane_grid::Node, + drag: Option<(pane_grid::Pane, pane_grid::Pane, DropRegion)>, + // Must match the values the host configures on its PaneGrid or the + // computed bounds drift from the pane bounds the user sees. + spacing: f32, + min_size: f32, +} + +impl<'a> PaneDropPreview<'a> { + pub fn new( + layout: &'a pane_grid::Node, + drag: Option<(pane_grid::Pane, pane_grid::Pane, DropRegion)>, + ) -> Self { + Self { + layout, + drag, + spacing: 0.0, + min_size: 50.0, + } + } + + // Returns the preview rectangle in grid-local coordinates; the caller + // offsets by its own layout origin to produce screen coordinates. + fn preview_rect(&self, total_size: Size) -> Option { + let (source, hovered, region) = self.drag?; + let regions = self + .layout + .pane_regions(self.spacing, self.min_size, total_size); + + let source_bounds = *regions.get(&source)?; + + // Hovering the source pane = no-op-on-release indicator. + if source == hovered { + return Some(source_bounds); + } + + let target_bounds = *regions.get(&hovered)?; + + // Center on a different pane is a swap; source lands on target. + if region == DropRegion::Center { + return Some(target_bounds); + } + + // Sibling Edge drops reflow the parent split's whole area, so the + // destination is half of (source + target), not half of target. + let parent_bounds = if sibling_split_info(self.layout, source, hovered).is_some() { + source_bounds.union(&target_bounds) + } else { + target_bounds + }; + + Some(region.preview_bounds(parent_bounds)) + } +} + +impl Widget for PaneDropPreview<'_> { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.max()) + } + + fn mouse_interaction( + &self, + _tree: &Tree, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + // Pass through; the pane_grid underneath drives the cursor. + mouse::Interaction::None + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let Some(local) = self.preview_rect(bounds.size()) else { + return; + }; + let rect = Rectangle { + x: bounds.x + local.x, + y: bounds.y + local.y, + width: local.width, + height: local.height, + }; + let accent: Color = theme.cosmic().accent.base.into(); + let fill = Color { a: 0.25, ..accent }; + let border_color = Color { a: 0.9, ..accent }; + renderer.fill_quad( + Quad { + bounds: rect, + border: Border { + radius: [0.0; 4].into(), + width: 2.0, + color: border_color, + }, + snap: true, + ..Default::default() + }, + fill, + ); + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message, Theme, Renderer> { + fn from(preview: PaneDropPreview<'a>) -> Self { + Self::new(preview) + } +} diff --git a/src/terminal_box.rs b/src/terminal_box.rs index 9be9f0d8..e92e072c 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -47,7 +47,7 @@ use std::{ use crate::{ Action, Terminal, TerminalScroll, menu::MenuState, mouse_reporter::MouseReporter, - terminal::Metadata, + pane_drag::DropRegion, terminal::Metadata, }; const AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(100); @@ -116,6 +116,9 @@ pub struct TerminalBox<'a, Message> { context_menu: Option, on_context_menu: Option) -> Message + 'a>>, on_mouse_enter: Option Message + 'a>>, + on_drag_start: Option Message + 'a>>, + drag_start_modifiers: Modifiers, + on_drop_region: Option Message + 'a>>, opacity: Option, mouse_inside_boundary: Option, on_middle_click: Option Message + 'a>>, @@ -142,6 +145,9 @@ where context_menu: None, on_context_menu: None, on_mouse_enter: None, + on_drag_start: None, + drag_start_modifiers: Modifiers::empty(), + on_drop_region: None, opacity: None, mouse_inside_boundary: None, on_middle_click: None, @@ -197,6 +203,24 @@ where self } + pub fn on_drag_start( + mut self, + modifiers: Modifiers, + callback: impl Fn() -> Message + 'a, + ) -> Self { + self.drag_start_modifiers = modifiers; + self.on_drag_start = Some(Box::new(callback)); + self + } + + pub fn on_drop_region( + mut self, + on_drop_region: impl Fn(DropRegion) -> Message + 'a, + ) -> Self { + self.on_drop_region = Some(Box::new(on_drop_region)); + self + } + pub fn on_middle_click(mut self, on_middle_click: impl Fn() -> Message + 'a) -> Self { self.on_middle_click = Some(Box::new(on_middle_click)); self @@ -1229,6 +1253,16 @@ where }, Event::Mouse(MouseEvent::ButtonPressed(button)) => { if let Some(p) = cursor_position.position_in(layout.bounds()) { + // Intercept Modifier+left-click pane drag before mouse-mode reporting + if *button == Button::Left + && state.modifiers == self.drag_start_modifiers + && let Some(on_drag_start) = &self.on_drag_start + { + shell.publish(on_drag_start()); + shell.capture_event(); + return; + } + let x = p.x - self.padding.left; let y = p.y - self.padding.top; //TODO: better calculation of position @@ -1463,6 +1497,19 @@ where self.mouse_inside_boundary = Some(mouse_is_inside); } } + + // Emit on every region change while a drop callback is wired. + if let Some(on_drop_region) = &self.on_drop_region + && let Some(local) = cursor_position.position_in(layout.bounds()) + { + let region = DropRegion::from_local_position(local, layout.bounds().size()); + if state.last_drop_region != Some(region) { + state.last_drop_region = Some(region); + shell.publish(on_drop_region(region)); + } + } else if cursor_position.position_in(layout.bounds()).is_none() { + state.last_drop_region = None; + } if let Some(p_global) = cursor_position.position() { let bounds = layout.bounds(); let col_row_opt = if let Some(p) = cursor_position.position_in(bounds) { @@ -1937,6 +1984,7 @@ pub struct State { scrollbar_rect: Cell>, autoscroll: DragAutoscroll, preedit: Option, + last_drop_region: Option, } impl State { @@ -1951,6 +1999,7 @@ impl State { scrollbar_rect: Cell::new(Rectangle::default()), autoscroll: DragAutoscroll::new(AUTOSCROLL_INTERVAL), preedit: None, + last_drop_region: None, } } }