diff --git a/locales/en.yml b/locales/en.yml index c332656..9c635e3 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -125,6 +125,8 @@ software_builtin: "Built-in" retry: "Retry" right_click_copy_paste: "Right-click Copy/Paste" keyword_highlight: "Terminal Keyword Highlighting" +lock_layout: "Lock Window Layout" +lock_layout_hint: "Lock the position of the sidebar and the SFTP panel." copy_paste_hint: "Selected text can also be copied by left-clicking." settings_copy: "Copy Selection" settings_paste: "Paste Clipboard" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index bd742cd..81e29a0 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -126,6 +126,8 @@ software_builtin: "软件内置" retry: "重试" right_click_copy_paste: "右键复制/粘贴" keyword_highlight: "终端关键词高亮" +lock_layout: "锁定窗口布局" +lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。" copy_paste_hint: "选中后左键点击也可复制。" settings_copy: "复制选区" settings_paste: "粘贴剪贴板" diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 2725659..9132287 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1666,6 +1666,25 @@ impl Ashell { }) ) ) + .item( + SettingItem::new( + t!("lock_layout").to_string(), + SettingField::render({ + let view = view_clone_for_general.clone(); + move |_, window, cx| { + Switch::new("lock-layout") + .small() + .checked(view.read(cx).config.lock_layout()) + .on_click(window.listener_for(&view, |this, checked, _, cx| { + this.config.set_lock_layout(*checked); + let _ = this.config.save(); + cx.notify(); + })) + .into_any_element() + } + }) + ).description(t!("lock_layout_hint").to_string()) + ) .item( SettingItem::new( t!("monitoring_position").to_string(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 0be7d2b..bbb71d1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,6 +6,7 @@ pub mod search; pub mod startup; pub mod theme; pub mod ui; +pub mod resizable; use std::{ cell::{Cell, RefCell}, @@ -23,9 +24,9 @@ use gpui::{ use gpui_component::{ Theme, ThemeMode, ThemeRegistry, input::{InputEvent, InputState}, - resizable::ResizableState, scroll::ScrollbarHandle, }; +use crate::app::resizable::ResizableState; use rust_i18n::t; use tokio::runtime::Runtime; diff --git a/src/app/resizable/mod.rs b/src/app/resizable/mod.rs new file mode 100644 index 0000000..e7d5739 --- /dev/null +++ b/src/app/resizable/mod.rs @@ -0,0 +1,329 @@ +#![allow(dead_code)] + +use std::ops::Range; + +use gpui::{ + Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px, +}; + +mod panel; +mod resize_handle; +pub use panel::*; +pub(crate) use resize_handle::*; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); + +pub enum ResizablePanelEvent { + Resized, +} + +/// Create a [`ResizablePanelGroup`] with horizontal resizing +pub fn h_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Horizontal) +} + +/// Create a [`ResizablePanelGroup`] with vertical resizing +pub fn v_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Vertical) +} + +/// Create a [`ResizablePanel`]. +pub fn resizable_panel() -> ResizablePanel { + ResizablePanel::new() +} + +/// State for a [`ResizablePanel`] +#[derive(Debug, Clone)] +pub struct ResizableState { + /// The `axis` will sync to actual axis of the ResizablePanelGroup in use. + axis: Axis, + pub(crate) panels: Vec, + pub(crate) sizes: Vec, + pub(crate) resizing_panel_ix: Option, + pub(crate) bounds: Bounds, +} + +impl Default for ResizableState { + fn default() -> Self { + Self { + axis: Axis::Horizontal, + panels: vec![], + sizes: vec![], + resizing_panel_ix: None, + bounds: Bounds::default(), + } + } +} + +impl ResizableState { + /// Get the size of the panels. + pub fn sizes(&self) -> &Vec { + &self.sizes + } + + /// Programmatically resize the panel at `ix` to `size`, redistributing + /// space among siblings using the same logic as a drag. + /// + /// Sizes are clamped to the panel's `size_range` and to the container. + /// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference + /// persistence) see the change just as if the user had dragged a handle. + /// + /// Out-of-range indices are a no-op. For the last panel, space is taken + /// from the previous sibling (the last panel has no handle of its own). + pub fn resize_panel( + &mut self, + ix: usize, + size: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if ix >= self.sizes.len() { + return; + } + if ix + 1 < self.sizes.len() { + self.resize_panel_at_handle(ix, size, window, cx); + } else if ix > 0 { + // Last panel: drive its size by resizing the previous sibling so + // the freed space lands here. + let delta = self.sizes[ix] - size; + let prev = self.sizes[ix - 1]; + self.resize_panel_at_handle(ix - 1, prev + delta, window, cx); + } + self.done_resizing(cx); + } + + pub(crate) fn insert_panel( + &mut self, + size: Option, + ix: Option, + cx: &mut Context, + ) { + let panel_state = ResizablePanelState { + size, + ..Default::default() + }; + + let size = size.unwrap_or(PANEL_MIN_SIZE); + + // We make sure that the size always sums up to the container size + // by reducing the size of all other panels first. + let container_size = self.container_size().max(px(1.)); + let total_leftover_size = (container_size - size).max(px(1.)); + + for (i, panel) in self.panels.iter_mut().enumerate() { + let ratio = self.sizes[i] / container_size; + self.sizes[i] = total_leftover_size * ratio; + panel.size = Some(self.sizes[i]); + } + + if let Some(ix) = ix { + self.panels.insert(ix, panel_state); + self.sizes.insert(ix, size); + } else { + self.panels.push(panel_state); + self.sizes.push(size); + }; + + cx.notify(); + } + + pub(crate) fn sync_panels_count( + &mut self, + axis: Axis, + panels_count: usize, + cx: &mut Context, + ) { + let mut changed = self.axis != axis; + self.axis = axis; + + if panels_count > self.panels.len() { + let diff = panels_count - self.panels.len(); + self.panels + .extend(vec![ResizablePanelState::default(); diff]); + self.sizes.extend(vec![PANEL_MIN_SIZE; diff]); + changed = true; + } + + if panels_count < self.panels.len() { + self.panels.truncate(panels_count); + self.sizes.truncate(panels_count); + changed = true; + } + + if changed { + // We need to make sure the total size is in line with the container size. + self.adjust_to_container_size(cx); + } + } + + pub(crate) fn update_panel_size( + &mut self, + panel_ix: usize, + bounds: Bounds, + size_range: Range, + cx: &mut Context, + ) { + let size = bounds.size.along(self.axis); + // This check is only necessary to stop the very first panel from resizing on its own + // it needs to be passed when the panel is freshly created so we get the initial size, + // but its also fine when it sometimes passes later. + if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() { + self.sizes[panel_ix] = size; + self.panels[panel_ix].size = Some(size); + } + self.panels[panel_ix].bounds = bounds; + self.panels[panel_ix].size_range = size_range; + cx.notify(); + } + + pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { + self.panels.remove(panel_ix); + self.sizes.remove(panel_ix); + if let Some(resizing_panel_ix) = self.resizing_panel_ix { + if resizing_panel_ix > panel_ix { + self.resizing_panel_ix = Some(resizing_panel_ix - 1); + } + } + self.adjust_to_container_size(cx); + } + + pub(crate) fn replace_panel( + &mut self, + panel_ix: usize, + panel: ResizablePanelState, + cx: &mut Context, + ) { + let old_size = self.sizes[panel_ix]; + + self.panels[panel_ix] = panel; + self.sizes[panel_ix] = old_size; + self.adjust_to_container_size(cx); + } + + pub(crate) fn clear(&mut self) { + self.panels.clear(); + self.sizes.clear(); + } + + #[inline] + pub(crate) fn container_size(&self) -> Pixels { + self.bounds.size.along(self.axis) + } + + pub(crate) fn done_resizing(&mut self, cx: &mut Context) { + self.resizing_panel_ix = None; + cx.emit(ResizablePanelEvent::Resized); + } + + fn panel_size_range(&self, ix: usize) -> Range { + let Some(panel) = self.panels.get(ix) else { + return PANEL_MIN_SIZE..Pixels::MAX; + }; + + panel.size_range.clone() + } + + fn sync_real_panel_sizes(&mut self, _: &App) { + for (i, panel) in self.panels.iter().enumerate() { + self.sizes[i] = panel.bounds.size.along(self.axis); + } + } + + pub(crate) fn resize_panel_at_handle( + &mut self, + ix: usize, + size: Pixels, + _: &mut Window, + cx: &mut Context, + ) { + let old_sizes = self.sizes.clone(); + + let mut ix = ix; + if ix >= old_sizes.len() - 1 { + return; + } + let container_size = self.container_size(); + self.sync_real_panel_sizes(cx); + + let move_changed = size - old_sizes[ix]; + if move_changed == px(0.) { + return; + } + + let size_range = self.panel_size_range(ix); + let new_size = size.clamp(size_range.start, size_range.end); + let is_expand = move_changed > px(0.); + + let main_ix = ix; + let mut new_sizes = old_sizes.clone(); + + if is_expand { + let mut changed = new_size - old_sizes[ix]; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix < old_sizes.len() - 1 { + ix += 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + new_sizes[ix] -= to_reduce; + changed -= to_reduce; + } + } else { + let mut changed = new_size - size; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix > 0 { + ix -= 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + changed -= to_reduce; + new_sizes[ix] -= to_reduce; + } + + new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed; + } + + let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::().into(); + if total_size > container_size { + let overflow = total_size - container_size; + new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start); + } + + for (i, _) in old_sizes.iter().enumerate() { + let size = new_sizes[i]; + self.panels[i].size = Some(size); + } + self.sizes = new_sizes; + cx.notify(); + } + + pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context) { + if self.container_size().is_zero() { + return; + } + + let container_size = self.container_size(); + let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::()); + + for i in 0..self.panels.len() { + let size = self.sizes[i]; + let ratio = size / total_size; + let new_size = container_size * ratio; + + self.sizes[i] = new_size; + self.panels[i].size = Some(new_size); + } + cx.notify(); + } +} + +impl EventEmitter for ResizableState {} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResizablePanelState { + pub size: Option, + pub size_range: Range, + pub(crate) bounds: Bounds, +} diff --git a/src/app/resizable/panel.rs b/src/app/resizable/panel.rs new file mode 100644 index 0000000..1d3dee9 --- /dev/null +++ b/src/app/resizable/panel.rs @@ -0,0 +1,430 @@ +use std::{ + ops::{Deref, Range}, + rc::Rc, +}; + +use gpui::{ + Along, AnyElement, App, Axis, Bounds, Context, Element, ElementId, Empty, Entity, + EventEmitter, InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder, AppContext as _, IsZero as _, +}; + +use gpui_component::{ + AxisExt, ElementExt, h_flex, v_flex, StyledExt, +}; + +use super::{ResizableState, resizable_panel, resize_handle, PANEL_MIN_SIZE, ResizablePanelEvent}; + +#[derive(Clone)] +pub(crate) struct DragPanel; +impl Render for DragPanel { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + Empty + } +} + +/// A group of resizable panels. +#[derive(IntoElement)] +pub struct ResizablePanelGroup { + id: ElementId, + state: Option>, + axis: Axis, + size: Option, + children: Vec, + on_resize: Rc, &mut Window, &mut App)>, + locked: bool, +} + +impl ResizablePanelGroup { + /// Create a new resizable panel group. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + axis: Axis::Horizontal, + children: vec![], + state: None, + size: None, + on_resize: Rc::new(|_, _, _| {}), + locked: false, + } + } + + /// Bind yourself to a resizable state entity. + /// + /// If not provided, it will handle its own state internally. + pub fn with_state(mut self, state: &Entity) -> Self { + self.state = Some(state.clone()); + self + } + + /// Set the axis of the resizable panel group, default is horizontal. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Set lock status of the resizable panel group. + pub fn lock(mut self, locked: bool) -> Self { + self.locked = locked; + self + } + + /// Add a panel to the group. + /// + /// - The `axis` will be set to the same axis as the group. + /// - The `initial_size` will be set to the average size of all panels if not provided. + /// - The `group` will be set to the group entity. + pub fn child(mut self, panel: impl Into) -> Self { + self.children.push(panel.into()); + self + } + + /// Add multiple panels to the group. + pub fn children(mut self, panels: impl IntoIterator) -> Self + where + I: Into, + { + self.children = panels.into_iter().map(|panel| panel.into()).collect(); + self + } + + /// Set size of the resizable panel group + /// + /// - When the axis is horizontal, the size is the height of the group. + /// - When the axis is vertical, the size is the width of the group. + pub fn size(mut self, size: Pixels) -> Self { + self.size = Some(size); + self + } + + /// Set the callback to be called when the panels are resized. + /// + /// ## Callback arguments + /// + /// - Entity: The state of the ResizablePanelGroup. + pub fn on_resize( + mut self, + on_resize: impl Fn(&Entity, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Rc::new(on_resize); + self + } +} + +impl From for ResizablePanel +where + T: Into, +{ + fn from(value: T) -> Self { + resizable_panel().child(value.into()) + } +} + +impl From for ResizablePanel { + fn from(value: ResizablePanelGroup) -> Self { + resizable_panel().child(value) + } +} + +impl EventEmitter for ResizablePanelGroup {} + +impl RenderOnce for ResizablePanelGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.unwrap_or_else(|| { + window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()) + }); + let container = if self.axis.is_horizontal() { + h_flex() + } else { + v_flex() + }; + + // Sync panels to the state + let panels_count = self.children.len(); + state.update(cx, |state, cx| { + state.sync_panels_count(self.axis, panels_count, cx); + }); + + container + .id(self.id) + .size_full() + .children( + self.children + .into_iter() + .enumerate() + .map(|(ix, mut panel)| { + panel.panel_ix = ix; + panel.axis = self.axis; + panel.state = Some(state.clone()); + panel.locked = self.locked; + panel + }), + ) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + let size_changed = + state.bounds.size.along(self.axis) != bounds.size.along(self.axis); + + state.bounds = bounds; + + if size_changed { + state.adjust_to_container_size(cx); + } + }) + } + }) + .child(ResizePanelGroupElement { + state: state.clone(), + axis: self.axis, + on_resize: self.on_resize.clone(), + }) + } +} + +/// A resizable panel inside a [`ResizablePanelGroup`]. +#[derive(IntoElement)] +pub struct ResizablePanel { + axis: Axis, + panel_ix: usize, + state: Option>, + /// Initial size is the size that the panel has when it is created. + initial_size: Option, + /// size range limit of this panel. + size_range: Range, + children: Vec, + visible: bool, + style: StyleRefinement, + locked: bool, +} + +impl ResizablePanel { + /// Create a new resizable panel. + pub(super) fn new() -> Self { + Self { + panel_ix: 0, + initial_size: None, + state: None, + size_range: (PANEL_MIN_SIZE..Pixels::MAX), + axis: Axis::Horizontal, + children: vec![], + visible: true, + style: StyleRefinement::default(), + locked: false, + } + } + + /// Set the visibility of the panel, default is true. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Set the initial size of the panel. + pub fn size(mut self, size: impl Into) -> Self { + self.initial_size = Some(size.into()); + self + } + + /// Set the size range to limit panel resize. + /// + /// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`]. + pub fn size_range(mut self, range: impl Into>) -> Self { + self.size_range = range.into(); + self + } +} + +impl Styled for ResizablePanel { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for ResizablePanel { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ResizablePanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if !self.visible { + return div().id(("resizable-panel", self.panel_ix)); + } + + let state = self + .state + .expect("BUG: The `state` in ResizablePanel should be present."); + let panel_state = state + .read(cx) + .panels + .get(self.panel_ix) + .expect("BUG: The `index` of ResizablePanel should be one of in `state`."); + let size_range = self.size_range.clone(); + + div() + .id(("resizable-panel", self.panel_ix)) + .flex() + .flex_grow_1() + .size_full() + .relative() + .refine_style(&self.style) + .when(self.axis.is_vertical(), |this| { + this.min_h(size_range.start).max_h(size_range.end) + }) + .when(self.axis.is_horizontal(), |this| { + this.min_w(size_range.start).max_w(size_range.end) + }) + .when(self.initial_size.is_none(), |this| this.flex_shrink_1()) + .when_some(self.initial_size, |this, initial_size| { + this.when( + panel_state.size.is_none() && !initial_size.is_zero(), + |this| this.flex_none(), + ) + .flex_basis(initial_size) + }) + .map(|this| match panel_state.size { + Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)), + None => this, + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + state.update_panel_size(self.panel_ix, bounds, self.size_range, cx) + }) + } + }) + .children(self.children) + .when(self.panel_ix > 0, |this| { + let ix = self.panel_ix - 1; + let handle = resize_handle(("resizable-handle", ix), self.axis); + let handle = if self.locked { + handle + } else { + handle.on_drag( + DragPanel, + move |drag_panel, _, _, cx| { + cx.stop_propagation(); + state.update(cx, |state, _| { + state.resizing_panel_ix = Some(ix); + }); + cx.new(|_| drag_panel.deref().clone()) + }, + ) + }; + this.child(handle) + }) + } +} + +struct ResizePanelGroupElement { + state: Entity, + on_resize: Rc, &mut Window, &mut App)>, + axis: Axis, +} + +impl IntoElement for ResizePanelGroupElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizePanelGroupElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + () + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let state = self.state.clone(); + let axis = self.axis; + let current_ix = state.read(cx).resizing_panel_ix; + move |e: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + let Some(ix) = current_ix else { return }; + + state.update(cx, |state, cx| { + let panel = state.panels.get(ix).expect("BUG: invalid panel index"); + + match axis { + Axis::Horizontal => state.resize_panel_at_handle( + ix, + e.position.x - panel.bounds.left(), + window, + cx, + ), + Axis::Vertical => state.resize_panel_at_handle( + ix, + e.position.y - panel.bounds.top(), + window, + cx, + ), + } + cx.notify(); + }) + } + }); + + // When any mouse up, stop dragging + window.on_mouse_event({ + let state = self.state.clone(); + let current_ix = state.read(cx).resizing_panel_ix; + let on_resize = self.on_resize.clone(); + move |_: &MouseUpEvent, phase, window, cx| { + if current_ix.is_none() { + return; + } + if phase.bubble() { + state.update(cx, |state, cx| state.done_resizing(cx)); + on_resize(&state, window, cx); + } + } + }) + } +} diff --git a/src/app/resizable/resize_handle.rs b/src/app/resizable/resize_handle.rs new file mode 100644 index 0000000..3244f06 --- /dev/null +++ b/src/app/resizable/resize_handle.rs @@ -0,0 +1,222 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, Element, ElementId, Entity, + GlobalElementId, InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, + ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window, +}; + +use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement}; + +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +/// Create a resize handle for a resizable panel. +pub(crate) fn resize_handle( + id: impl Into, + axis: Axis, +) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +pub(crate) struct ResizeHandle { + id: ElementId, + axis: Axis, + drag_value: Option>, + placement: Option, + on_drag: Option, &mut Window, &mut App) -> Entity>>, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + let id = id.into(); + Self { + id: id.clone(), + on_drag: None, + drag_value: None, + placement: None, + axis, + } + } + + pub(crate) fn on_drag( + mut self, + value: T, + f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, + ) -> Self { + let value = Rc::new(value); + self.drag_value = Some(value.clone()); + self.on_drag = Some(Rc::new(move |p, window, cx| { + f(value.clone(), p, window, cx) + })); + self + } + + pub(crate) fn placement(mut self, placement: DockPlacement) -> Self { + self.placement = Some(placement); + self + } +} + +#[derive(Default, Debug, Clone)] +struct ResizeHandleState { + active: Cell, +} + +impl ResizeHandleState { + fn set_active(&self, active: bool) { + self.active.set(active); + } + + fn is_active(&self) -> bool { + self.active.get() + } +} + +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let neg_offset = -HANDLE_PADDING; + let axis = self.axis; + + window.with_element_state(id.unwrap(), |state, window| { + let state: ResizeHandleState = state.unwrap_or_default(); + + let bg_color = if state.is_active() { + cx.theme().drag_border + } else { + cx.theme().border + }; + + let has_drag = self.on_drag.is_some(); + + let mut el = div() + .id(self.id.clone()) + .occlude() + .absolute() + .flex_shrink_0() + .group("handle") + .when_some(self.on_drag.clone(), |this, on_drag| { + this.on_drag( + self.drag_value.clone().unwrap(), + move |_, position, window, cx| on_drag(&position, window, cx), + ) + }) + .map(|this| match self.placement { + Some(DockPlacement::Left) => { + this.when(has_drag, |this| this.cursor_col_resize()) + .top_0() + .right(px(1.)) + .h_full() + .w(HANDLE_SIZE) + .pl(HANDLE_PADDING) + } + _ => this + .when(axis.is_horizontal(), |this| { + this.when(has_drag, |this| this.cursor_col_resize()) + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(axis.is_vertical(), |this| { + this.when(has_drag, |this| this.cursor_row_resize()) + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }), + }) + .child( + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .into_any_element(); + + let layout_id = el.request_layout(window, cx); + + ((layout_id, el), state) + }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + if self.on_drag.is_some() { + window.on_mouse_event({ + let state = state.clone(); + move |ev: &MouseDownEvent, phase, window, _| { + if bounds.contains(&ev.position) && phase.bubble() { + state.set_active(true); + window.refresh(); + } + } + }); + + window.on_mouse_event({ + let state = state.clone(); + move |_: &MouseUpEvent, _, window, _| { + if state.is_active() { + state.set_active(false); + window.refresh(); + } + } + }); + } + + ((), state) + }); + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index cfcac01..87bad41 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -13,11 +13,11 @@ use gpui_component::{ input::Input, menu::{ContextMenuExt as _, PopupMenuItem}, progress::Progress, - resizable::{h_resizable, resizable_panel, v_resizable}, scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow}, tab::{Tab, TabBar}, v_flex, }; +use crate::app::resizable::{h_resizable, resizable_panel, v_resizable}; use rust_i18n::t; use crate::{ @@ -2647,6 +2647,7 @@ impl Render for Ashell { }; let body_panel = v_resizable("ashell-body") + .lock(self.config.lock_layout()) .with_state(&self.body_panels) .child(resizable_panel().child(self.render_terminal_panel(window, cx))) .child( @@ -2733,6 +2734,7 @@ impl Render for Ashell { ); h_resizable("ashell-workspace") + .lock(self.config.lock_layout()) .with_state(&self.workspace_panels) .child(sidebar_area) .child(main_area) diff --git a/src/session/config.rs b/src/session/config.rs index 6a16e04..74686be 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -176,6 +176,8 @@ pub struct ConfigFile { pub transfers: Vec, #[serde(default)] pub show_hidden_files: bool, + #[serde(default)] + pub lock_layout: bool, #[serde(default = "default_monitoring_position")] pub monitoring_position: String, #[serde(default)] @@ -288,6 +290,7 @@ impl Default for ConfigFile { body_panels: None, transfers: Vec::new(), show_hidden_files: false, + lock_layout: false, monitoring_position: default_monitoring_position(), sidebar_collapsed: false, sftp_panel_minimized: false, @@ -702,6 +705,14 @@ impl ConfigStore { self.cache.show_hidden_files = val; } + pub fn lock_layout(&self) -> bool { + self.cache.lock_layout + } + + pub fn set_lock_layout(&mut self, val: bool) { + self.cache.lock_layout = val; + } + pub fn sidebar_collapsed(&self) -> bool { self.cache.sidebar_collapsed } diff --git a/src/session/mod.rs b/src/session/mod.rs index e8fb618..404cba4 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -354,8 +354,8 @@ impl Ashell { let _ = self.config.save(); self.is_layout_reset = true; - self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default()); - self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default()); + self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default()); + self.body_panels = cx.new(|_| crate::app::resizable::ResizableState::default()); cx.notify(); }