Skip to content
Merged
328 changes: 235 additions & 93 deletions crates/eidos-gui/src/dialogs.rs

Large diffs are not rendered by default.

274 changes: 273 additions & 1 deletion crates/eidos-gui/src/main.rs

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions crates/eidos-gui/src/modinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1737,10 +1737,11 @@ pub(crate) fn plugins_panel<'a>(app: &App) -> Element<'a, Message> {
));
}

// Releasing outside the list drops nothing, as in the mod list.
let list_area = mouse_area(scrollable(rows).id(plugin_scroll_id()).height(Length::Fill))
.on_exit(Message::PluginDragCancel)
.on_release(Message::PluginDragCancel);
// Same as the mod list: the global release listener decides, and nothing
// here second-guesses it. `on_exit` cancelled a drag that merely left the
// bounds - the gesture that reaches an earlier row - and `on_release` raced
// the listener to cancel what it was about to drop.
let list_area = mouse_area(scrollable(rows).id(plugin_scroll_id()).height(Length::Fill));

Column::new().spacing(6).push(head).push(header).push(list_area).into()
}
Expand Down Expand Up @@ -2029,9 +2030,17 @@ pub(crate) fn status_bar<'a>(app: &App) -> Element<'a, Message> {
}

pub(crate) fn main_screen<'a>(app: &App) -> Element<'a, Message> {
// The name is the way into Settings, as Colony's is. It was decoration
// before, and the only route in was the toolbar button - which is a long way
// to travel for the thing a window's own title usually opens.
let header = Row::new()
.spacing(10)
.push(text("Eidos").size(20.0))
.push(
button(text("Eidos").size(20.0))
.padding([2, 6])
.on_press(Message::OpenSettings)
.style(button::text),
)
.push(Space::new().width(Length::Fill))
.push(tool_btn("New instance", Message::Restart));

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 @@ -109,7 +109,10 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
collapsed: HashSet::new(),
category_filter: None,
settings_open: false,
settings_tab: SettingsTab::Nexus,
settings_tab: SettingsTab::General,
// Open on first sight: a settings page whose sections are all shut asks
// the user to click five times before it says anything.
settings_expanded: SettingsTab::DEFAULT_OPEN.iter().copied().collect(),
// Prefill the key field from the shared store (the same key `eidos nexus
// key` writes), so it survives across sessions without a network round trip.
settings_api_key: eidos_instance::settings::load_nexus_key().unwrap_or_default(),
Expand Down Expand Up @@ -140,6 +143,8 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
confirm_batch_remove: false,
modifiers: iced::keyboard::Modifiers::default(),
drag_state: None,
drag_scroll: None,
drag_scroll_depth: 0.5,
selected_plugin: None,
selected_plugins: HashSet::new(),
plugin_anchor: None,
Expand Down
80 changes: 80 additions & 0 deletions crates/eidos-gui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,7 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
app.selected_plugins.clear();
app.drag_state = None;
app.plugin_drag = None;
app.drag_scroll = None;
app.menu_mod = None;
}
Message::OpenModMenu(i) => {
Expand Down Expand Up @@ -1934,6 +1935,29 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
}
}
}
Message::DragScrollSpeedChanged(v) => {
app.prefs.drag_scroll_speed = v.clamp(0.25, 4.0);
if let Err(e) = app.prefs.save() {
app.status = Some(format!("Could not save preferences: {e}"));
}
}
Message::SettingsToggleSection(key) => {
if !app.settings_expanded.remove(key) {
app.settings_expanded.insert(key);
}
}
Message::ToggleConflictMarks(on) => {
app.prefs.conflict_marks = on;
if let Err(e) = app.prefs.save() {
app.status = Some(format!("Could not save preferences: {e}"));
}
}
Message::ToggleRememberWindow(on) => {
app.prefs.remember_window = on;
if let Err(e) = app.prefs.save() {
app.status = Some(format!("Could not save preferences: {e}"));
}
}
Message::ThemeChanged(t) => {
app.prefs.theme = t;
if let Err(e) = app.prefs.save() {
Expand Down Expand Up @@ -2727,6 +2751,62 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
Message::DragCancel => {
app.drag_state = None;
}
Message::DragScrollEdge(edge) => {
// Only meaningful mid-drag: the bands are not rendered otherwise, but
// a stale message must not start a scroll on its own.
app.drag_scroll = edge.filter(|_| app.drag_state.is_some());
// Entering starts mid-range: `on_move` has not fired yet, and a band
// that began at full speed would lurch before the user had aimed.
if app.drag_scroll.is_some() {
app.drag_scroll_depth = 0.5;
}
}
Message::DragScrollDepth(d) => {
app.drag_scroll_depth = d.clamp(0.0, 1.0);
}
Message::DragScrollTick => {
let Some(edge) = app.drag_scroll else { return Task::none() };
if app.drag_state.is_none() {
app.drag_scroll = None;
return Task::none();
}
// RELATIVE, so this never needs to know where the list already is -
// which is exactly what the first version got wrong.
// Speed follows how deep into the band the pointer is: a nudge
// creeps a row at a time, the very edge crosses the list.
let px = (DRAG_SCROLL_SLOW_PX
+ (DRAG_SCROLL_FAST_PX - DRAG_SCROLL_SLOW_PX) * app.drag_scroll_depth)
* app.prefs.drag_scroll_speed;
let y = match edge {
ScrollEdge::Up => -px,
ScrollEdge::Down => px,
};
return operation::scroll_by(
mod_scroll_id(),
iced::widget::scrollable::AbsoluteOffset { x: 0.0, y },
);
}
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
// who dragged upward to scroll and released over the toolbar means
// the gap they were aiming at, not "nowhere".
// Cleared FIRST, on every path: the drop branches below return
// early, so leaving it to the end meant a drag that ended on a gap
// kept its timer running with nothing left to scroll for.
app.drag_scroll = None;
if app.drag_state.is_some_and(|d| d.aimed) {
return update(app, Message::DragDrop);
}
if app.plugin_drag.as_ref().is_some_and(|d| d.aimed) {
return update(app, Message::PluginDragDrop);
}
// Through the cancel messages rather than clearing the fields here,
// so "what disarming means" has one definition and the keyboard path
// (Escape) and this one cannot drift apart.
let _ = update(app, Message::DragCancel);
return update(app, Message::PluginDragCancel);
}
Message::SelectPlugin(i) => {
if app.modifiers.control() || app.modifiers.command() {
return update(app, Message::SelectPluginToggle(i));
Expand Down
77 changes: 67 additions & 10 deletions crates/eidos-gui/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,9 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> {
// No spacing: the insertion strips below provide the separation, and they
// must be part of the flow so the layout is identical with and without a drag.
let mut list = Column::new();
// One entry per row actually drawn, feeding the conflict strip beside the
// scrollbar. Filled in the same order the rows are pushed.
let mut tints: Vec<Option<Color>> = Vec::new();
let mut shown = 0usize;
if app.mods.is_empty() {
list = list.push(text("No mods yet. Drop mod folders into the instance's mods/ dir.").size(12.0));
Expand Down Expand Up @@ -662,6 +665,7 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> {
// slot just before a group header would be unreachable.
list = list.push(drop_gap(i, live_gap == Some(i), dragging, Message::DragOverGap, Message::DragDrop));
list = list.push(separator_row(i, m, color, collapsed, selected));
tints.push(None); // a separator has no conflict of its own
continue;
}
if !vis[i] {
Expand Down Expand Up @@ -701,6 +705,7 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> {
// Computed once and handed to both: the row paints this colour, and the
// name cell fades into it.
let conflict = conflict_tint(app, i);
tints.push(conflict);
let bg = row_background(i % 2 == 0, selected, conflict);
list = list.push(list_row(
mod_row(i, m, meta, flag_icon, hidden_icon, bg),
Expand Down Expand Up @@ -737,16 +742,68 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> {
.on_press(Message::SelectTab(Tab::Overwrite))
.style(button::text);

// Wrap the list so the pointer leaving its bounds during a drag cancels it
// (MO2 drops nothing when you release outside the list).
// `on_release` here is the catch-all: a row or a strip that handles the
// release captures it and this never fires, but a release landing anywhere
// else in the list - a header, a gap the layout moved, empty space below the
// last row - disarms instead of leaving a drag live for the next click to
// commit. `on_exit` covers releasing outside the list entirely.
let list_area = mouse_area(scrollable(list).id(mod_scroll_id()).height(Length::Fill))
.on_exit(Message::DragCancel)
.on_release(Message::DragCancel);
// `on_release` is the catch-all: a row or a strip that handles the release
// captures it and this never fires, but a release landing anywhere else in
// the list - a header, a gap the layout moved, empty space below the last
// row - disarms instead of leaving a drag live for the next click to commit.
//
// A release OUTSIDE the list is caught globally instead of by `on_exit`, and
// that is the whole difference: `mouse_area` cannot tell "left while
// dragging" from "let go out there", so cancelling on exit meant dragging
// upward past the header dropped the mod every time.
// No release handler here. Every release is decided in ONE place -
// `Message::PointerReleased`, from the global listener - which drops at the
// aimed gap or disarms if none was aimed.
//
// There used to be an `on_release(DragCancel)` as a catch-all, from before
// that listener existed. With both, a release that did not land exactly on a
// drop strip raced: this one cancelled the drag before the global one could
// commit it. Releasing over a row lost the drop, and once the auto-scroll
// bands existed - which are never drop strips - dragging any distance made
// it certain.
let list_area = mouse_area(
scrollable(list).id(mod_scroll_id()).width(Length::Fill).height(Length::Fill),
);
// The conflict marks go ON the scrollbar, at the same fraction of the list,
// so the mod a tint refers to can be found without reading every row on the
// way. Stacked rather than placed beside it: a strip in the flow pushed the
// whole list sideways to make room, which is a lot of shifted UI for a hint.
// Nothing in the strip handles events, so the scrollbar keeps the pointer.
let mut layers = Stack::new().push(list_area).push(
Row::new().push(Space::new().width(Length::Fill)).push(if app.prefs.conflict_marks {
conflict_map(&tints)
} else {
Space::new().width(Length::Fixed(0.0)).into()
}),
);
// Auto-scroll bands, and only once the drag is REALLY under way. `DragStart`
// fires on press, so keying these off "a drag exists" put them under the
// pointer on every click - and `mouse_area` publishes `on_enter` the first
// time it is laid out beneath a stationary cursor. `aimed` means the pointer
// has crossed an insertion point, which no plain click does.
if app.drag_state.is_some_and(|d| d.aimed) {
// `on_move` gives the pointer's position INSIDE the band, so depth is
// just its height normalised - 1.0 hard against the edge of the list.
let band = |edge: ScrollEdge| {
mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
.on_enter(Message::DragScrollEdge(Some(edge)))
.on_move(move |p| {
let t = (p.y / DRAG_SCROLL_BAND).clamp(0.0, 1.0);
Message::DragScrollDepth(match edge {
ScrollEdge::Up => 1.0 - t,
ScrollEdge::Down => t,
})
})
.on_exit(Message::DragScrollEdge(None))
};
layers = layers.push(
Column::new()
.push(container(band(ScrollEdge::Up)).height(Length::Fixed(DRAG_SCROLL_BAND)))
.push(Space::new().height(Length::Fill))
.push(container(band(ScrollEdge::Down)).height(Length::Fixed(DRAG_SCROLL_BAND))),
);
}
let list_area = layers;

// ALWAYS in the flow, at a fixed height, even when it has nothing to say.
// Appearing and disappearing moved every row below it by its own height, so
Expand Down
Loading
Loading