From 072d26ed79663a6bf322d57391594959895887e2 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 26 Aug 2026 00:28:26 +0200 Subject: [PATCH] feat(gui): drag the divider to resize the two panes The panes were a fixed 3:2, which is a guess about somebody else's screen. On a narrow window the right pane's tab strip runs out of room and clips the last tab - "Diagnostics (2)" was the one that prompted this - and there was nothing to do about it: the space between the lists was a gap, not a handle. It is a handle now. Six pixels, full height, the toolbars' muted line at rest and the panel border's burgundy while held, so a drag in progress is visible rather than inferred. The ratio is clamped to 0.15..0.85 in both the live drag and the settings parser. A pane dragged to zero takes the divider with it, and then neither pane can be recovered - so an out-of-range value in `settings.ini` reads as the default rather than as a preference somebody chose, the same policy `drag_scroll_speed` already applies. It is written to `settings.ini` on RELEASE, not on every pointer move: the drag emits a message per frame and the settings are a file. Nothing new was needed to carry it. `app.cursor` and `app.window` were already tracked, `mouse_area` was already imported, and `PointerAt` was already ambient - which matters, because resizing the furniture must not disarm a two-click confirmation armed on a row. Tested through the real message loop: the pointer alone does not resize, a grab makes it follow, both clamps hold at the edges, release stops the drag and hands the value to the preferences, and a move after release is inert again. --- crates/eidos-gui/src/main.rs | 41 +++++++++++++++++++++++++++ crates/eidos-gui/src/modinfo.rs | 27 ++++++++++++++++-- crates/eidos-gui/src/state.rs | 7 ++++- crates/eidos-gui/src/theme.rs | 9 ++++++ crates/eidos-gui/src/update.rs | 22 +++++++++++++- crates/eidos-gui/src/view.rs | 5 +++- crates/eidos-instance/src/settings.rs | 39 ++++++++++++++++++++++++- 7 files changed, 144 insertions(+), 6 deletions(-) diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index 8fbfbe7..2d5b193 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -700,6 +700,8 @@ enum Message { KeyNav(Nav), /// The pointer moved, or the window was resized. Only stored. PointerAt(iced::Point), + /// The divider between the mod list and the right pane was grabbed. + SplitGrab, WindowResized(iced::Size), /// The pointer entered a FOMOD option; drives the preview pane. FomodHover(Option<(usize, usize)>), @@ -1807,6 +1809,12 @@ struct App { // ---- menu-bar UI toggles + About ---- /// The toolbar / status bar are visible (View menu toggles). ui_toolbar_visible: bool, + /// Fraction of the window width given to the mod list, 0.15 to 0.85. + /// Mirrors `prefs.split`; kept here because it changes on every pointer + /// move during a drag and the preferences are only written when it stops. + split: f32, + /// Whether the divider is being dragged right now. + split_drag: bool, ui_statusbar_visible: bool, /// The View dropdown is open (iced has no native menu, so it's a floating card). view_menu_open: bool, @@ -4916,6 +4924,39 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn the_divider_resizes_the_panes_and_refuses_to_collapse_one() { + let mut app = app_for_game("skyrimse"); + app.window = iced::Size::new(1000.0, 800.0); + let before = app.split; + + // Moving the pointer without grabbing must not move anything: the + // divider is a handle, not a hover target. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(300.0, 400.0))); + assert_eq!(app.split, before, "the pointer alone must not resize"); + + let _ = update_inner(&mut app, Message::SplitGrab); + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(300.0, 400.0))); + assert!((app.split - 0.3).abs() < 0.001, "split={}", app.split); + + // Dragged past the edge, both panes must survive: a pane at zero width + // takes the divider with it and there is no way to get either back. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(-500.0, 400.0))); + assert_eq!(app.split, 0.15); + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(5000.0, 400.0))); + assert_eq!(app.split, 0.85); + + // Releasing stops the drag and hands the value to the preferences, which + // is what makes it survive a restart. + let _ = update_inner(&mut app, Message::PointerReleased); + assert!(!app.split_drag); + assert_eq!(app.prefs.split, 0.85); + + // And a pointer move after release is inert again. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(100.0, 400.0))); + assert_eq!(app.split, 0.85); + } + #[test] fn a_backup_is_inert_and_can_be_restored_over_the_mod_it_came_from() { let root = temp_portable("skyrimse"); diff --git a/crates/eidos-gui/src/modinfo.rs b/crates/eidos-gui/src/modinfo.rs index 1663bb1..ee07ffa 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -2708,7 +2708,8 @@ pub(crate) fn right_pane<'a>(app: &App) -> Element<'a, Message> { }; let inner = Column::new().spacing(8).push(top).push(tabs).push(content); - container(inner).width(Length::FillPortion(2)).height(Length::Fill).padding(8).style(panel_style).into() + let portion = 1000_u16.saturating_sub((app.split * 1000.0) as u16); + container(inner).width(Length::FillPortion(portion)).height(Length::Fill).padding(8).style(panel_style).into() } pub(crate) fn status_bar<'a>(app: &App) -> Element<'a, Message> { @@ -2773,10 +2774,32 @@ pub(crate) fn main_screen(app: &App) -> Element<'_, Message> { .push(Space::new().width(Length::Fill)) .push(tool_btn("New instance", Message::Restart)); + // A grabbable divider rather than a gap. The panes were a fixed 3:2, which is + // a guess about somebody else's screen: on a narrow window the right pane's + // tab strip runs out of room and clips "Diagnostics", with nothing to do + // about it. Six pixels wide because a hairline is hard to hit and a bar is + // furniture; it lights up while held so the drag is visibly in progress. + let held = app.split_drag; + let divider = mouse_area( + container(Space::new().width(Length::Fixed(6.0)).height(Length::Fill)) + .height(Length::Fill) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(if held { + crate::theme::DIVIDER_HELD + } else { + crate::theme::DIVIDER + })), + border: iced::Border { radius: 3.0.into(), ..Default::default() }, + ..Default::default() + }), + ) + .on_press(Message::SplitGrab); + let body = Row::new() - .spacing(8) + .spacing(4) .height(Length::Fill) .push(modlist_pane(app)) + .push(divider) .push(right_pane(app)); let mut base = Column::new().spacing(4).padding(4).push(header).push(menu_bar()); diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 872eb51..0727e59 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -204,6 +204,9 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { // If Steam launched us with the game's command (`eidos-gui %command%`), // identify the game and open straight to its instance, like MO2 does. let auto = identify_game(&games, &launch_command); + // Read once: the struct needs it twice, and reading the file twice could give + // two different answers. + let prefs = if cfg!(test) { Settings::default() } else { Settings::load() }; let mut app = App { screen: Screen::Welcome, games, @@ -259,7 +262,7 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { // reading it makes every assertion about a default depend on what // happens to be in that person's file. A default has no path, so // saving it is a no-op. - prefs: if cfg!(test) { Settings::default() } else { Settings::load() }, + prefs: prefs.clone(), executables: None, backups: None, dropped: Vec::new(), @@ -322,6 +325,8 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { update_in_progress: false, sorting: false, ui_toolbar_visible: true, + split: prefs.split, + split_drag: false, ui_statusbar_visible: true, view_menu_open: false, about_open: false, diff --git a/crates/eidos-gui/src/theme.rs b/crates/eidos-gui/src/theme.rs index c71679a..7fab461 100644 --- a/crates/eidos-gui/src/theme.rs +++ b/crates/eidos-gui/src/theme.rs @@ -51,6 +51,15 @@ pub(crate) fn bar_style(_theme: &Theme) -> container::Style { } } +/// The bar between the two panes at rest: the same muted line the toolbars use, +/// so it reads as furniture rather than as content. +pub(crate) const DIVIDER: Color = Color::from_rgb8(0xC9, 0xB8, 0x90); + +/// The same bar while it is being dragged - the panel border's burgundy, which is +/// the strongest colour in this palette and the one already used for "this is the +/// edge of something". +pub(crate) const DIVIDER_HELD: Color = Color::from_rgb8(0x7A, 0x1F, 0x2B); + pub(crate) fn row_bg(even: bool) -> Color { if even { Color::from_rgb8(0xF3, 0xEA, 0xD3) diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index 3dda395..f198397 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -48,6 +48,9 @@ pub(crate) fn is_ambient(app: &App, m: &Message) -> bool { match m { Message::PointerAt(_) | Message::WindowResized(_) + // Grabbing the divider resizes the window's furniture; it is not the + // user deciding anything about the row a confirmation is armed on. + | Message::SplitGrab | Message::FomodHover(_) | Message::FomodUnhover(..) // The downloads tick fires twice a second on its own. Left out of @@ -5782,6 +5785,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { // from a drop exactly as they do from the Install button. return update(app, Message::ModPicked(Some(d.path))); } + Message::PointerReleased if app.split_drag => { + app.split_drag = false; + // Written once, on release, rather than on every pointer move: the + // drag emits a message per frame and settings.ini is a file. + app.prefs.split = app.split; + if let Err(e) = app.prefs.save() { + app.status = Some(format!("Could not save the pane width: {e}")); + } + } Message::PointerReleased => { // Letting go is a DROP wherever a gap is aimed, and a cancel // otherwise - regardless of where the pointer happens to be. A user @@ -6018,7 +6030,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { } commit_plugin_order(app, &spec); } - Message::PointerAt(p) => app.cursor = p, + Message::PointerAt(p) => { + app.cursor = p; + if app.split_drag && app.window.width > 1.0 { + // Clamped well inside the edges: a pane dragged to zero is a + // pane the user cannot grab again, and the divider goes with it. + app.split = (p.x / app.window.width).clamp(0.15, 0.85); + } + } + Message::SplitGrab => app.split_drag = true, Message::WindowResized(s) => { app.window = s; // "Remember the window size" is a real setting now: it was stored, diff --git a/crates/eidos-gui/src/view.rs b/crates/eidos-gui/src/view.rs index b919c06..c8873d8 100644 --- a/crates/eidos-gui/src/view.rs +++ b/crates/eidos-gui/src/view.rs @@ -1281,7 +1281,10 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> { .push(list_area) .push(overwrite); - container(inner).width(Length::FillPortion(3)).height(Length::Fill).padding(8).style(panel_style).into() + // The two panes share the width by a ratio the user drags, not by a constant. + // A thousandth of the window is finer than a pixel on any screen this runs on. + let portion = (app.split * 1000.0) as u16; + container(inner).width(Length::FillPortion(portion)).height(Length::Fill).padding(8).style(panel_style).into() } /// A single left-aligned action in the mod context menu. diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index debd0fc..29c56d5 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -297,6 +297,11 @@ pub struct Settings { /// its edges. 1.0 is the tuned default; the range a user can pick from is /// the GUI's business, this only stores what they picked. pub drag_scroll_speed: f32, + /// How much of the main window's width the mod list gets, 0.0 to 1.0. The + /// two panes were a fixed 3:2, which is a guess about somebody else's screen: + /// the right pane's tab strip runs out of room and "Diagnostics (2)" gets + /// clipped on a narrow window, with no way to give it more. + pub split: f32, /// Restore the window to its last size on launch (on by default). Off means /// the size is neither read nor written, so the compositor decides. pub remember_window: bool, @@ -336,6 +341,7 @@ impl Default for Settings { // MO2 defaults `lock_gui` to true, and an absent key means "on". lock_gui: true, drag_scroll_speed: 1.0, + split: 0.6, remember_window: true, offline: false, tools_dir: None, @@ -418,6 +424,17 @@ impl Settings { "window_width" => width = v.parse().ok(), "window_height" => height = v.parse().ok(), // Any value other than an explicit off keeps locking on (default). + "split" => { + // Same policy as the scroll speed: out of range or + // unparseable reads as the default, because a bad number + // here is a window with one pane collapsed and no obvious + // way back. + if let Ok(n) = v.parse::() { + if (0.15..=0.85).contains(&n) { + s.split = n; + } + } + } "drag_scroll_speed" => { // Out-of-range or unparseable reads as the default rather // than as a value: a bad number here is a list that either @@ -481,10 +498,11 @@ impl Settings { /// Render these settings as a `settings.ini` body. Split out for unit tests. pub fn to_ini(&self) -> String { let mut out = format!( - "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nremember_window={}\n", + "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nremember_window={}\n", self.theme.as_str(), self.lock_gui, self.drag_scroll_speed, + self.split, self.remember_window ); if self.offline { @@ -727,6 +745,7 @@ mod tests { window_size: Some((1280, 720)), lock_gui: false, drag_scroll_speed: 1.0, + split: 0.6, remember_window: false, }; let parsed = Settings::parse(&s.to_ini()); @@ -740,6 +759,23 @@ mod tests { assert_eq!(parsed, s); } + #[test] + fn the_split_round_trips_and_refuses_a_collapsed_pane() { + assert_eq!(Settings::default().split, 0.6); + assert_eq!(Settings::parse("split=0.35\n").split, 0.35); + // A pane pushed off the edge is not a preference anyone chose; it is a + // window with no way back, so it reads as the default. + for bad in ["0", "1", "0.05", "0.99", "-1", "abc", ""] { + assert_eq!( + Settings::parse(&format!("split={bad}\n")).split, + 0.6, + "split={bad} should have been refused" + ); + } + let s = Settings { split: 0.42, ..Settings::default() }; + assert_eq!(Settings::parse(&s.to_ini()).split, 0.42); + } + #[test] fn the_drag_scroll_speed_round_trips_and_refuses_nonsense() { assert_eq!(Settings::default().drag_scroll_speed, 1.0); @@ -811,6 +847,7 @@ mod tests { window_size: Some((1600, 900)), lock_gui: false, drag_scroll_speed: 1.0, + split: 0.6, remember_window: false, }; fs::write(&path, s.to_ini()).unwrap();