Skip to content
Merged
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
41 changes: 41 additions & 0 deletions crates/eidos-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)>),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
27 changes: 25 additions & 2 deletions crates/eidos-gui/src/modinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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());
Expand Down
7 changes: 6 additions & 1 deletion crates/eidos-gui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
// 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,
Expand Down Expand Up @@ -259,7 +262,7 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
// 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(),
Expand Down Expand Up @@ -322,6 +325,8 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
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,
Expand Down
9 changes: 9 additions & 0 deletions crates/eidos-gui/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion crates/eidos-gui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -5782,6 +5785,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
// 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
Expand Down Expand Up @@ -6018,7 +6030,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
}
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,
Expand Down
5 changes: 4 additions & 1 deletion crates/eidos-gui/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 38 additions & 1 deletion crates/eidos-instance/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<f32>() {
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading