diff --git a/migrations/20260726120000_add_sidebar_width/down.sql b/migrations/20260726120000_add_sidebar_width/down.sql new file mode 100644 index 0000000..788a8c3 --- /dev/null +++ b/migrations/20260726120000_add_sidebar_width/down.sql @@ -0,0 +1 @@ +ALTER TABLE preferences DROP COLUMN sidebar_width; diff --git a/migrations/20260726120000_add_sidebar_width/up.sql b/migrations/20260726120000_add_sidebar_width/up.sql new file mode 100644 index 0000000..f0bd4e6 --- /dev/null +++ b/migrations/20260726120000_add_sidebar_width/up.sql @@ -0,0 +1 @@ +ALTER TABLE preferences ADD COLUMN sidebar_width INTEGER NOT NULL DEFAULT 276; diff --git a/src/app.rs b/src/app.rs index 6426042..8546e98 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,7 +25,7 @@ use crate::settings::{ StreamingCacheBudget, }; use iced::widget::{markdown, scrollable, text_input}; -use iced::{Size, Subscription, Task, keyboard, window}; +use iced::{Size, Subscription, Task, keyboard, mouse, window}; use rfd::AsyncFileDialog; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; @@ -38,6 +38,8 @@ use std::time::{Duration, Instant}; const APP_ID: &str = "de.rfc1437.ds4server"; const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200); +pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180; +pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520; pub(crate) struct App { main_window: window::Id, @@ -64,6 +66,8 @@ pub(crate) struct App { session_rename: Option<(i32, String)>, /// Projects whose archived sessions are expanded in the sidebar. expanded_archives: HashSet, + /// True while the sidebar divider is being dragged. + sidebar_drag: bool, choosing_folder: bool, pending_project_path: Option, project_name_input: String, @@ -194,6 +198,9 @@ pub(crate) enum Message { ShowChat, ShowStats, ToggleSidebar, + StartSidebarDrag, + DragSidebar(f32), + EndSidebarDrag, MetricsTick, } @@ -242,6 +249,7 @@ impl App { session_menu: None, session_rename: None, expanded_archives: HashSet::new(), + sidebar_drag: false, choosing_folder: false, pending_project_path: None, project_name_input: String::new(), @@ -323,6 +331,7 @@ impl App { session_menu: None, session_rename: None, expanded_archives: HashSet::new(), + sidebar_drag: false, choosing_folder: false, pending_project_path: None, project_name_input: String::new(), @@ -414,6 +423,26 @@ impl App { } Message::ShowChat => self.detail_tab = DetailTab::Chat, Message::ShowStats => self.detail_tab = DetailTab::Stats, + // The sidebar starts at the window's left edge, so the cursor's x is + // the width the user is asking for. Only the final width is stored. + Message::StartSidebarDrag => self.sidebar_drag = true, + Message::DragSidebar(x) => { + if self.sidebar_drag { + self.preferences.sidebar_width = + (x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); + } + } + Message::EndSidebarDrag => { + if self.sidebar_drag { + self.sidebar_drag = false; + if let Some(database) = self.database.as_mut() + && let Err(error) = + database.set_sidebar_width(self.preferences.sidebar_width) + { + self.error = Some(error); + } + } + } Message::ToggleSidebar => { let collapsed = !self.preferences.sidebar_collapsed; self.preferences.sidebar_collapsed = collapsed; @@ -969,6 +998,19 @@ impl App { iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick), ); } + // The cursor leaves the thin divider as soon as it moves, so the drag is + // followed through raw window events instead of the widget. + if self.sidebar_drag { + subscriptions.push(iced::event::listen_with(|event, _, _| match event { + iced::Event::Mouse(mouse::Event::CursorMoved { position }) => { + Some(Message::DragSidebar(position.x)) + } + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + Some(Message::EndSidebarDrag) + } + _ => None, + })); + } Subscription::batch(subscriptions) } diff --git a/src/app/view.rs b/src/app/view.rs index e46c97d..b8a620c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -6,8 +6,8 @@ mod stats; use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ - ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation, - chat_scroll_id, composer_id, models_path, + ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint, + ModelDownload, ModelOperation, chat_scroll_id, composer_id, models_path, }; use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ @@ -17,7 +17,8 @@ use crate::settings::{GIB, REASONING_MODES}; use iced::theme::{Palette, palette}; use iced::widget::{ Button, Space, Svg, Tooltip, button, checkbox, column, container, horizontal_rule, markdown, - opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, tooltip, + mouse_area, opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, + tooltip, }; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme, window}; use std::collections::VecDeque; @@ -65,6 +66,17 @@ impl App { let mut body = row![].width(Length::Fill).height(Length::Fill); if !self.preferences.sidebar_collapsed { body = body.push(self.sidebar()); + body = body.push( + mouse_area( + container(Space::with_width(Length::Fill)) + .width(5) + .height(Length::Fill) + .style(divider_style), + ) + .interaction(iced::mouse::Interaction::ResizingHorizontally) + .on_press(Message::StartSidebarDrag) + .on_release(Message::EndSidebarDrag), + ); } body = body.push( container(self.detail()) @@ -259,7 +271,11 @@ impl App { ] .spacing(10), ) - .width(276) + .width( + self.preferences + .sidebar_width + .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32, + ) .height(Length::Fill) .padding(Padding::new(16.0).top(16.0 + TITLE_BAR_HEIGHT)) .style(sidebar_style) @@ -808,6 +824,10 @@ fn muted_text() -> Color { Color::from_rgb8(174, 174, 178) } +fn divider_style(_: &Theme) -> container::Style { + container::Style::default().background(Color::from_rgb8(38, 38, 40)) +} + fn sidebar_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(23, 23, 25)) } diff --git a/src/database.rs b/src/database.rs index 2c49c42..bb9ad2f 100644 --- a/src/database.rs +++ b/src/database.rs @@ -57,6 +57,7 @@ pub struct AppPreferences { pub sidebar_collapsed: bool, /// Project the app reopens on. Cleared when that project goes away. pub last_project_id: Option, + pub sidebar_width: i32, } impl Default for AppPreferences { @@ -101,6 +102,7 @@ impl Default for AppPreferences { endpoint_cors: false, sidebar_collapsed: false, last_project_id: None, + sidebar_width: 276, } } } @@ -446,6 +448,14 @@ impl Database { .map_err(|error| error.to_string()) } + pub fn set_sidebar_width(&mut self, width: i32) -> Result<(), String> { + diesel::update(preferences::table.find(1)) + .set(preferences::sidebar_width.eq(width)) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + pub fn set_last_project(&mut self, project_id: Option) -> Result<(), String> { diesel::update(preferences::table.find(1)) .set(preferences::last_project_id.eq(project_id)) @@ -877,6 +887,7 @@ mod tests { database.set_sidebar_collapsed(true).unwrap(); database.set_last_project(Some(project.id)).unwrap(); + database.set_sidebar_width(320).unwrap(); assert!(!loaded[0].project.collapsed); database.set_project_collapsed(project.id, true).unwrap(); assert!(database.load_projects().unwrap()[0].project.collapsed); @@ -889,6 +900,7 @@ mod tests { let preferences = reopened.load_preferences().unwrap(); assert!(preferences.sidebar_collapsed); assert_eq!(preferences.last_project_id, Some(project.id)); + assert_eq!(preferences.sidebar_width, 320); assert_eq!(preferences.selected_model, "glm-5.2"); assert_eq!(preferences.idle_timeout_minutes, 30); assert_eq!(preferences.endpoint_port, 4567); diff --git a/src/schema.rs b/src/schema.rs index ec57583..bdeb944 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -60,6 +60,7 @@ diesel::table! { endpoint_cors -> Bool, sidebar_collapsed -> Bool, last_project_id -> Nullable, + sidebar_width -> Integer, } }