diff --git a/Cargo.toml b/Cargo.toml index aaf0535..ad49afc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "next-ui" -description = "The desktop GUI for Bottles Next, built with iced on top of next-core." +description = "The Bottles Next UI toolkit and Iced desktop application." version.workspace = true authors.workspace = true edition.workspace = true @@ -14,14 +14,14 @@ exclude = [""] [dependencies] async-trait.workspace = true bottles-core.workspace = true -iced = { version = "0.14", features = ["advanced", "canvas", "svg", "image-without-codecs", "tokio", "linux-theme-detection"] } +directories.workspace = true +iced = { version = "0.14", features = ["advanced", "canvas", "svg", "image-without-codecs", "linux-theme-detection", "tokio"] } next-config.workspace = true rust-embed = { version = "8.12.0", features = ["include-exclude"] } -uuid.workspace = true -directories.workspace = true serde.workspace = true tokio-util = { workspace = true, features = ["rt"] } url.workspace = true +uuid.workspace = true [dev-dependencies] futures-lite.workspace = true diff --git a/README.md b/README.md index fe6d044..ed4fd31 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,224 @@ # next-ui -The desktop GUI for Bottles Next, built with [`iced`](https://iced.rs) on top -of [`next-core`](../next-core). +The Bottles Next UI toolkit, desktop application, and component gallery, built +with Iced 0.14. -- `components` — reusable UI components/widgets. -- `icons` — embedded icon assets (via `rust-embed`). -- `theme` — application theming. -- `operation` — UI-facing wrappers around long-running `bottles-core` operations. +## Package boundary -Enable the `debug` feature to turn on `iced`'s hot-reloading during development. +`next-ui` remains one Cargo package with three targets: + +- `next_ui`, the reusable library; +- `next-ui`, the desktop application; +- `gallery`, the executable catalog of toolkit behavior and appearance. + +The supported library facade is deliberately small: + +```rust +use next_ui::{theme, widget, Icon}; +``` + +The facade contains no Bottles domain types. Bottles-specific composition stays +inside the application presentation modules. There is no speculative public +`component` layer; one should be introduced only when Classic and Next share a +concrete visual recipe that cannot remain a small function. + +Window chrome belongs to the application shell and is not part of the public +toolkit. + +## Architecture + +```text +App +├── persistent theme +└── Session + ├── persistent: core, config, canonical domain snapshots + ├── lifecycle: Active, Draining, Saving, ShuttingDown + ├── global overlay + └── screen + ├── Onboarding: setup coordinator and presentation + └── Running + ├── disposable features::State + │ ├── bottles and program launch + │ ├── library search + │ ├── profiles and account linking + │ ├── bottle settings + │ └── snapshots (with `fvs`) + └── disposable Presentation + ├── Classic: routes, panels, tabs, dialogs, view composition + └── Next: routes and view composition +``` + +Features are concrete state/update modules, not services or traits. Each feature +has one message path and may return a small typed output when a completed +workflow must affect presentation. Presentation-only actions, such as opening +profile settings, never round-trip through a feature. + +The root keeps `Action` and `Event` separate only to enforce lifecycle safety. +User actions are accepted only while active. Task and subscription events keep +flowing while draining so pending operations can reach terminal state. Feature +events carry a run generation; events from a discarded experience are ignored. + +Canonical bottle and profile snapshots are session-owned and continue updating +during onboarding, saving, and shutdown. Feature and presentation state are +recreated when the experience changes. + +The onboarding coordinator owns catalog refresh, required-runtime downloads, +cancellation, generations, stale-event rejection, and terminal phase changes. +Its view only selects the experience/step and renders coordinator state. + +## State ownership + +State belongs to the lowest layer that has enough information and authority to +change it. + +| State | Owner | +| --- | --- | +| Hover, press, keyboard focus | Widget tree | +| Popover disclosure, dismissal, popup focus | `Popover` | +| Expander disclosure and overlap eviction | `RowGroup`/standalone expander widget tree | +| Log disclosure, resize ratio, resize focus | `LogPanel` widget tree | +| Search query, loading/results/error data | Feature or screen | +| Selector value, switch value, active tab | Feature or screen | +| Dialog and side-panel presence | Presentation or root overlay | +| Drafts, pending operations, feature errors | `features::State` | +| Bottles, profiles, status and log contents | Canonical domain snapshots/features | +| Core, configuration, selected experience | Session/root | + +Local widget state is discarded when a presentation is recreated. Domain and +workflow state is never hidden inside toolkit widgets. + +Settings commands identify their target bottle. Their terminal completion does +not: completion must always reach the feature and clear its pending state even +if the bottle disappeared meanwhile. + +## Toolkit contracts + +- `Button` provides labeled, icon, disabled, loading, pill, and custom-content + recipes. Icon-only buttons require an accessible label. +- Action rows are a single activation target and must not contain interactive + descendants. Control rows are not themselves clickable. +- `ActionRow::new` accepts a message or `Option`; + `ActionRow::progress` represents non-interactive progress. +- `SelectorRow` is an in-flow connected selector. `Popover` and `Search` use the + shared private anchored overlay. +- `ExpanderRow` accepts ordinary `Into` children. `RowGroup` owns the + connected header/panel drawing, column footprints, and eviction of overlapping + panels while allowing non-overlapping panels to remain open. +- Search data is caller-owned and represented by `Hidden`, `Loading`, `Empty`, + `Error`, or `Results`. The optional footer is a supported action. +- `StatusBar` is domain-neutral. `LogPanel` is the Bottles convenience recipe + that supplies architecture, runner, status, and optional log contents while + retaining local disclosure and resizing. +- Dialog presence is controlled by the caller. `WindowModal` makes the base + inert and owns modal focus, Tab traversal, Escape, and outside dismissal. +- Animations use Iced's native animation support. Canvas is retained only for + actual geometry: the dashed `ActionTile`, animated `Switcher` knob, and + `ProgressRing`. + +## Theme + +`theme::dark()` and `theme::light()` construct custom Iced themes using Iced's +extended palette. `theme::colors()` exposes the toolkit's semantic names from +that palette so widget recipes do not reinterpret palette slots independently. + +The original light and dark muted colors are retained. There is no automated +contrast assertion or claim that every muted-text pairing meets a fixed ratio. +Keyboard operation, meaningful icon labels, and visible focus remain required +interaction behavior. + +## Gallery coverage + +The gallery is an external consumer of the complete public facade. + +| Area | Covered concepts | +| --- | --- | +| Typography | `TextExt`, `Title` subtitle/status | +| Actions | `Button` variants, `ActionTile` enabled/disabled | +| Cards | `Card`, `ArtworkCard`, `CardAction`, every `InfoCardKind` | +| Navigation | `Tabs`, `Tab`, `Popover`, `PopoverItem` | +| Search | hidden, loading, empty/results, error, result actions, footer | +| Rows | `ListRow`, `ActionRow`, `InfoRow`, `TextRow`, `SelectorRow`, `SwitcherRow`, `CycleRow`, `PickerRow` | +| Disclosure | standalone `ExpanderRow`, connected `RowGroup`, overlapping and non-overlapping matrices | +| Status | generic `StatusBar`, `LogPanel`, danger and no-log states | +| Standalone controls | `Switcher`, `ProgressRing` | +| Shell | `HeaderBar`, `Dialog`, `WindowModal` | + +Run it with: + +```bash +cargo run -p next-ui --example gallery --no-default-features +``` + +## Experience switching and shutdown + +Switching experiences: + +1. Confirms the target. +2. Rejects new UI actions. +3. Cancels disposable workflows that support cancellation. +4. Continues accepting terminal events until every operation drains. +5. Atomically saves the target experience. +6. Recreates feature and presentation state over the same core and canonical + snapshots. +7. Restores the source experience if saving fails. + +A close request during draining or saving converts the transition into shutdown. +A failed shutdown restores the retained session. Window actions are handled +exhaustively by the root shell. + +## Features + +- Default: `fvs`. +- `fvs`: enables `bottles-core/fvs`. +- `debug`: enables Iced hot reloading. + +`--no-default-features` builds the complete package without FVS support. + +## Custom widget inventory + +The toolkit contains eight private Iced `Widget` implementations and one shared +private `Overlay` implementation. Public callers interact only with recipes. + +| Private implementation | Single responsibility | +| --- | --- | +| `FocusableAction` | Shared pointer, touch, Enter/Space, and focus activation | +| `RowGroupCore` | Connected grid layout and overlap-aware disclosure | +| `Selector` | Connected in-flow selector disclosure and keyboard interaction | +| `SearchWidget` | Search-input focus and anchored result disclosure | +| `PopoverWidget` | Trigger disclosure, dismissal, and popup focus | +| `LogWidget` | Local log disclosure and pointer/keyboard resizing | +| `ModalBase` | Preserve/draw the base while making it inert | +| `ModalScope` | Capture modal input, focus traversal, and dismissal | +| `PopupCore` (`Overlay`) | Shared anchored placement and outside dismissal | + +Everything else is native Iced composition, styling, or a canvas drawing +program. + +## Size audit + +Toolkit size is measured as physical lines in `src/lib.rs`, `src/icons.rs`, +`src/theme.rs`, and `src/widgets/*.rs`, including inline tests. + +- Before the architecture rewrite: 9,445 lines. +- After the architecture rewrite: 7,684 lines (1,761 fewer, an 18.6% reduction). + +The remaining total is above the aspirational 6,000–7,000 range because the +required connected multi-column expanders, original in-flow selector, accessible +anchored overlays/modal behavior, animations, and resizable log panel are +implemented locally. These are deliberate cohesive responsibilities rather than +public interaction frameworks. + +## Verification + +Every implementation commit must compile independently. Phase and final gates: + +```bash +cargo fmt -p next-ui -- --check +cargo check -p next-ui --all-targets +cargo test -p next-ui --all-targets +cargo test -p next-ui --all-targets --no-default-features +cargo test -p next-ui --all-targets --all-features +cargo clippy -p next-ui --all-targets --all-features --no-deps -- -D warnings +cargo doc -p next-ui --lib --no-deps +git diff --check +``` diff --git a/examples/gallery.rs b/examples/gallery.rs index 66a1a1d..258b193 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -1,21 +1,23 @@ +#[path = "../src/chrome.rs"] +mod chrome; + use iced::{ Center, Element, Fill, Subscription, Task, Theme, - keyboard::{self, key}, widget::{Space, column, container, image, row, scrollable, text}, }; -use next_ui::widgets::text::TextExt as _; -use next_ui::widgets::{ - action_row, artwork_card, button, card, cycle_row, dialog, drop_target, expander_row, - header_bar, - info_card::{self, Kind}, - info_row, picker_row, popover, row_group, search, selector_row, status_bar, switcher_row, tabs, - text_row, title, +use next_ui::{ + Icon, theme, + widget::{ + ActionRow, ActionTile, ArtworkCard, Button, ButtonKind, Card, CardAction, CycleRow, Dialog, + ExpanderRow, InfoCard, InfoCardKind, InfoRow, ListRow, LogPanel, PickerRow, Popover, + PopoverItem, ProgressRing, RowGroup, Search, SearchResult, SearchState, SelectorRow, + StatusBar, Switcher, SwitcherRow, Tab, Tabs, TextExt as _, TextRow, Title, WindowModal, + }, }; -use next_ui::{icons::Icon, theme, ui::chrome}; const SELECTOR_OPTIONS: &[&str] = &["Option 1", "Option 2", "Option 3"]; const EMPTY_OPTIONS: &[&str] = &[]; -const TAB_LABELS: &[&str] = &["Bottles", "Library", "Settings"]; +const GALLERY_TABS: &[&str] = &["Bottles", "Library", "Settings"]; const DLSS_LEVELS: &[&str] = &["Off", "Quality", "Balanced", "Performance"]; const SEARCH_CATALOG: &[(&str, &str, Icon)] = &[ ("Epic Games Store", "Install", Icon::Arrow), @@ -82,7 +84,6 @@ enum Message { DismissDialog, Previous, Next, - MoveFocus(bool), Noop, } @@ -97,19 +98,20 @@ impl Gallery { Message::TabSelected(index) => self.selected_tab = index, Message::Switched(value) => self.switched_on = value, Message::GroupSwitched(value) => self.group_switched_on = value, - Message::Window(chrome::Action::RequestClose) => return iced::exit(), - Message::Window(action) => return action.task().unwrap_or_else(Task::none), + Message::Window(action) => { + return match action { + chrome::Action::Drag => iced::window::latest().and_then(iced::window::drag), + chrome::Action::Resize(direction) => iced::window::latest() + .and_then(move |id| iced::window::drag_resize(id, direction)), + chrome::Action::RequestClose => iced::exit(), + chrome::Action::FocusPrevious => iced::widget::operation::focus_previous(), + chrome::Action::FocusNext => iced::widget::operation::focus_next(), + }; + } Message::OpenDialog => self.dialog_open = true, Message::DismissDialog => self.dialog_open = false, Message::Previous => self.value = self.value.saturating_sub(1), Message::Next => self.value = (self.value + 1).min(DLSS_LEVELS.len() - 1), - Message::MoveFocus(previous) => { - return if previous { - iced::widget::operation::focus_previous() - } else { - iced::widget::operation::focus_next() - }; - } Message::Noop => {} } @@ -117,15 +119,7 @@ impl Gallery { } fn subscription(&self) -> Subscription { - keyboard::listen().filter_map(|event| match event { - keyboard::Event::KeyPressed { - key: keyboard::Key::Named(key::Named::Tab), - modifiers, - repeat: false, - .. - } => Some(Message::MoveFocus(modifiers.shift())), - _ => None, - }) + chrome::subscription().map(Message::Window) } fn view(&self) -> Element<'_, Message> { @@ -139,29 +133,27 @@ impl Gallery { .spacing(6); let titles = row![ - title::Title::new("Title").subtitle("Subtitle"), - title::Title::new("Title").status("Status"), + Title::new("Title").subtitle("Subtitle"), + Title::new("Title").status("Status"), ] .spacing(24); let buttons = row![ - button::Button::new("Play") - .icon(Icon::Play) + Button::new("Play").icon(Icon::Play).on_press(Message::Noop), + Button::new("Pill").pill().on_press(Message::Noop), + Button::icon_only("Play", Icon::Play).on_press(Message::Noop), + Button::custom(row![Icon::Wand.view(), text("Custom")].spacing(6)) .on_press(Message::Noop), - button::Button::new("Pill").pill().on_press(Message::Noop), - button::Button::icon_only("Play", Icon::Play).on_press(Message::Noop), - button::Button::new("Disabled"), - button::Button::new("Loading") - .on_press(Message::Noop) - .loading(true), - button::Button::new("Open dialog").on_press(Message::OpenDialog), + Button::new("Disabled"), + Button::new("Loading").on_press(Message::Noop).loading(true), + Button::new("Open dialog").on_press(Message::OpenDialog), ] .spacing(12); let cards = column![ - drop_target_example(), + row![action_tile_example(true), action_tile_example(false)].spacing(18), row![ - card::Card::new( + Card::new( column![ text("Text card").title(), text("Subtitle").subtitle().muted(), @@ -171,22 +163,17 @@ impl Gallery { ) .width(Fill) .padding(24), - artwork_card::ArtworkCard::new("Artwork card", "Ready") + ArtworkCard::new("Artwork card", "Ready") .menu( - artwork_card::CardAction::new("More actions", Icon::EllipsisVertical) + CardAction::new("More actions", Icon::EllipsisVertical) .on_press(Message::Noop), ) - .primary( - artwork_card::CardAction::new("Play", Icon::Play).on_press(Message::Noop), - ) + .primary(CardAction::new("Play", Icon::Play).on_press(Message::Noop)) .banner(sample_image()), - artwork_card::ArtworkCard::new("Program card", "Last played today") - .secondary( - artwork_card::CardAction::new("Settings", Icon::Gear) - .on_press(Message::Noop), - ) + ArtworkCard::new("Program card", "Last played today") + .secondary(CardAction::new("Settings", Icon::Gear).on_press(Message::Noop),) .primary( - artwork_card::CardAction::new("Play", Icon::Play) + CardAction::new("Play", Icon::Play) .on_press(Message::Noop) .loading(true), ) @@ -194,21 +181,28 @@ impl Gallery { ] .spacing(18), row![ - info_card::InfoCard::new(Kind::Hint, "Hint", "Helpful contextual information.") - .width(Fill), - info_card::InfoCard::new(Kind::Info, "Info", "General information for the user.") - .width(Fill), + InfoCard::new( + InfoCardKind::Hint, + "Hint", + "Helpful contextual information.", + ) + .width(Fill), + InfoCard::new( + InfoCardKind::Info, + "Info", + "General information for the user.", + ) + .width(Fill), ] .spacing(12), row![ - info_card::InfoCard::new(Kind::Error, "Error", "Something needs attention.") - .width(Fill), - info_card::InfoCard::new(Kind::Warning, "Warning", "Proceed with care.") + InfoCard::new(InfoCardKind::Error, "Error", "Something needs attention.") .width(Fill), + InfoCard::new(InfoCardKind::Warning, "Warning", "Proceed with care.").width(Fill), ] .spacing(12), row![ - info_card::InfoCard::new(Kind::Success, "Success", "The operation completed.") + InfoCard::new(InfoCardKind::Success, "Success", "The operation completed.",) .width(Fill), Space::new().width(Fill), ] @@ -216,210 +210,212 @@ impl Gallery { ] .spacing(18); - let tabs = tabs::Tabs::new( - TAB_LABELS + let tabs = Tabs::new( + GALLERY_TABS .iter() .enumerate() - .map(|(index, label)| tabs::Tab::new(index, label)), + .map(|(index, label)| Tab::new(index, label)), Some(self.selected_tab), Message::TabSelected, ); let search = column![ - search::Search::new( + Search::new( "Search for software and games…", &self.search, Message::SearchChanged, - ), - search::Search::new( + ) + .on_submit(Message::Noop), + Search::new( "Search for software and games…", &self.search, Message::SearchChanged, ) .state(self.search_state()) .footer("Not listed, install manually", Message::Noop), - search::Search::new( + Search::new( "Focus to see loading state…", &self.search, Message::SearchChanged, ) - .state(search::SearchState::Loading), - search::Search::new( + .state(SearchState::Loading), + Search::new( "Focus to see error state…", &self.search, Message::SearchChanged, ) - .state(search::SearchState::Error( - "The catalog could not be loaded" - )), + .state(SearchState::Error("The catalog could not be loaded")), ] .spacing(18); let selected = self .selected_option .and_then(|selected| SELECTOR_OPTIONS.iter().find(|option| **option == selected)); - let popover = popover::Popover::new( - button::Button::new("Open menu") - .trailing_icon(Icon::DownCaret) - .on_press(()), - ) - .item( - popover::PopoverItem::new("Current profile") - .subtitle("Selected") - .icon(Icon::Person) - .selected(true) - .on_select(Message::Noop), - ) - .item( - popover::PopoverItem::new("Available account") - .subtitle("Child action captures the row click") - .action("Link", Message::Noop), - ) - .item( - popover::PopoverItem::new("Unavailable account") - .disabled_action("Taken") - .tooltip(text("Already linked to another profile")), - ) - .item(popover::PopoverItem::new("Manage profiles").on_select(Message::Noop)); + let popover = Popover::new(Button::new("Open menu").trailing_icon(Icon::DownCaret)) + .item( + PopoverItem::new("Current profile") + .subtitle("Selected") + .icon(Icon::Person) + .selected(true) + .on_select(Message::Noop), + ) + .item( + PopoverItem::new("Available account") + .subtitle("Trailing action is the only interactive target") + .action("Link", Message::Noop), + ) + .item( + PopoverItem::new("Unavailable account") + .disabled_action("Taken") + .tooltip(text("Already linked to another profile")), + ) + .item(PopoverItem::new("Manage profiles").on_select(Message::Noop)); let popovers = column![popover].spacing(12); let fields = column![ - text_row::TextRow::new("Input Name", &self.text_rows[0]) + TextRow::new("Input Name", &self.text_rows[0]) .placeholder("Placeholder") .icon(Icon::Person) .on_input(|value| Message::TextRowChanged(0, value)), - text_row::TextRow::new("Input Name", &self.text_rows[1]) + TextRow::new("Input Name", &self.text_rows[1]) .placeholder("Placeholder") .icon(Icon::Person) .secure(true) .on_input(|value| Message::TextRowChanged(1, value)), - text_row::TextRow::new("Input Name", &self.text_rows[2]) + TextRow::new("Input Name", &self.text_rows[2]) .placeholder("Placeholder") .icon(Icon::Person) .on_input(|value| Message::TextRowChanged(2, value)) .error(Some("Example validation error")), - selector_row::SelectorRow::new("Selector Name", SELECTOR_OPTIONS, selected,) + SelectorRow::new("Selector Name", SELECTOR_OPTIONS, selected) .on_selected(Message::OptionSelected) .placeholder("Placeholder") .icon(Icon::Person), - selector_row::SelectorRow::new("Empty selector", EMPTY_OPTIONS, None) + SelectorRow::new("Empty selector", EMPTY_OPTIONS, None) .placeholder("No options available"), - action_row::ActionRow::new("Title", action_row::State::Ready(Message::Noop)) - .description("Description"), - action_row::ActionRow::new("Unavailable action", action_row::State::Disabled) + ActionRow::new("Title", Message::Noop).description("Description"), + ActionRow::new("Unavailable action", None) .description("This action cannot currently run"), - info_row::InfoRow::new("Title") + ActionRow::progress("In progress", 0.65).description("65% complete"), + InfoRow::new("Title") .description("Description") .icon(Icon::Timer), - switcher_row::SwitcherRow::new("Title", self.switched_on) + ListRow::new(text("Direct ListRow with arbitrary content")) + .leading(Icon::Info.view().width(16).height(16)) + .trailing(Button::new("Action").on_press(Message::Noop)), + SwitcherRow::new("Title", self.switched_on) .on_toggle(Message::Switched) .description("Description"), - cycle_row::CycleRow::new("DLSS Level", DLSS_LEVELS[self.value]) + CycleRow::new("DLSS Level", DLSS_LEVELS[self.value]) .on_previous_maybe((self.value > 0).then_some(Message::Previous)) .on_next_maybe((self.value + 1 < DLSS_LEVELS.len()).then_some(Message::Next),), - picker_row::PickerRow::new("Title") + PickerRow::new("Title") .description("Choose the location") .on_press(Message::Noop), - expander_row::ExpanderRow::with_header( - switcher_row::SwitcherRow::new("FSR", self.switched_on) + ExpanderRow::with_header( + SwitcherRow::new("FSR", self.switched_on) .on_toggle(Message::Switched) .description("FidelityFX Super Resolution"), ) .columns(2) - .add( - action_row::ActionRow::new("Quality", action_row::State::Ready(Message::Noop),) + .row( + ActionRow::new("Quality", self.switched_on.then_some(Message::Noop)) .description("Balanced"), ) - .add( - cycle_row::CycleRow::new("Sharpening", "5") - .on_previous_maybe((self.value > 0).then_some(Message::Previous)) - .on_next_maybe((self.value + 1 < DLSS_LEVELS.len()).then_some(Message::Next),), - ) - .content_enabled(self.switched_on), + .row( + CycleRow::new("Sharpening", "5") + .on_previous_maybe( + (self.switched_on && self.value > 0).then_some(Message::Previous), + ) + .on_next_maybe( + (self.switched_on && self.value + 1 < DLSS_LEVELS.len()) + .then_some(Message::Next), + ), + ), ] .spacing(24); - let row_group = row_group::RowGroup::new() + let standalone_controls = row![ + column![text("Progress ring").medium(), ProgressRing::new(0.65),].spacing(12), + column![ + text("Switcher").medium(), + Switcher::new(self.switched_on).on_toggle(Message::Switched), + ] + .spacing(12), + ] + .spacing(48); + + let row_group = RowGroup::new() .title("Graphics") .description("Rows wrap according to the configured column count.") .columns(2) .row( - switcher_row::SwitcherRow::new("DLSS", self.switched_on) + SwitcherRow::new("DLSS", self.switched_on) .on_toggle(Message::Switched) .description("Deep Learning Super Sampling"), ) .row( - picker_row::PickerRow::new("Shader directory") + PickerRow::new("Shader directory") .description("Choose the location") .on_press(Message::Noop), ) .row( - action_row::ActionRow::new("Discrete GPU", action_row::State::Ready(Message::Noop)) + ActionRow::new("Discrete GPU", Message::Noop) .description("Configure graphics adapter"), ) .expander( - expander_row::ExpanderRow::with_header( - switcher_row::SwitcherRow::new("FSR", self.group_switched_on) + ExpanderRow::with_header( + SwitcherRow::new("FSR", self.group_switched_on) .on_toggle(Message::GroupSwitched) .description("FidelityFX Super Resolution"), ) .columns(2) - .add( - action_row::ActionRow::new("Quality", action_row::State::Ready(Message::Noop)) + .row( + ActionRow::new("Quality", self.group_switched_on.then_some(Message::Noop)) .description("Balanced"), ) - .add( - cycle_row::CycleRow::new("Sharpening", DLSS_LEVELS[self.value]) - .on_previous_maybe((self.value > 0).then_some(Message::Previous)) + .row( + CycleRow::new("Sharpening", DLSS_LEVELS[self.value]) + .on_previous_maybe( + (self.group_switched_on && self.value > 0).then_some(Message::Previous), + ) .on_next_maybe( - (self.value + 1 < DLSS_LEVELS.len()).then_some(Message::Next), + (self.group_switched_on && self.value + 1 < DLSS_LEVELS.len()) + .then_some(Message::Next), ), - ) - .content_enabled(self.group_switched_on), + ), ); - let multiple_expanders = row_group::RowGroup::new() + let multiple_expanders = RowGroup::new() .title("Non-overlapping expanders") .description("Both expanders can remain open because their panels do not overlap") .columns(3) .expander( - expander_row::ExpanderRow::new("First expander") + ExpanderRow::new("First expander") .description("One-column panel") - .add( - action_row::ActionRow::new( - "First action", - action_row::State::Ready(Message::Noop), - ) - .description("Inside the first column"), + .row( + ActionRow::new("First action", Message::Noop) + .description("Inside the first column"), ), ) .expander( - expander_row::ExpanderRow::new("Second expander") + ExpanderRow::new("Second expander") .description("Two-column panel") .columns(2) - .add( - action_row::ActionRow::new( - "Second action", - action_row::State::Ready(Message::Noop), - ) - .description("First panel column"), + .row( + ActionRow::new("Second action", Message::Noop) + .description("First panel column"), ) - .add( - action_row::ActionRow::new( - "Third action", - action_row::State::Ready(Message::Noop), - ) - .description("Second panel column"), + .row( + ActionRow::new("Third action", Message::Noop) + .description("Second panel column"), ), ) .row( - action_row::ActionRow::new( - "Independent action", - action_row::State::Ready(Message::Noop), - ) - .description("Beside the two expanders"), + ActionRow::new("Independent action", Message::Noop) + .description("Beside the two expanders"), ); - let expander_matrix = row_group::RowGroup::new() + let expander_matrix = RowGroup::new() .title("2 × 2 expander grid") .description("Opening a sibling closes the overlapping panel on the same grid line") .columns(2) @@ -429,26 +425,28 @@ impl Gallery { .expander(action_grid_expander("Expander D")); let status = column![ - status_bar::StatusBar::new("Win64", "soda-7.0.9", status_bar::BottleStatus::Running,) - .log(LOG), - status_bar::StatusBar::new("Win64", "soda-7.0.9", status_bar::BottleStatus::Stopped,) - .log(LOG), - status_bar::StatusBar::new("Win64", "soda-7.0.9", status_bar::BottleStatus::Starting,), - status_bar::StatusBar::new("Win64", "soda-7.0.9", status_bar::BottleStatus::Failed,), + LogPanel::new("Win64", "soda-7.0.9", "Running", Icon::Lightning).log(LOG), + LogPanel::new("Win64", "soda-7.0.9", "Stopped", Icon::Power).log(LOG), + LogPanel::new("Win64", "soda-7.0.9", "Starting", Icon::Lightning), + LogPanel::new("Win64", "soda-7.0.9", "Stopping", Icon::Power), + LogPanel::new("Win64", "soda-7.0.9", "Failed", Icon::Cross).danger(), + StatusBar::new("Generic status", Icon::Info).detail(Icon::Chip, "Generic detail"), ] .spacing(18); - let header = header_bar::HeaderBar::new(Message::Window(chrome::Action::Drag)).middle( - iced::widget::container( - search::Search::new( - "Search for software and games…", - &self.search, - Message::SearchChanged, + let header = chrome::header(Message::Window(chrome::Action::Drag), true, |header| { + header.middle( + iced::widget::container( + Search::new( + "Search for software and games…", + &self.search, + Message::SearchChanged, + ) + .state(self.search_state()), ) - .state(self.search_state()), + .width(370), ) - .width(370), - ); + }); let gallery = scrollable( container( @@ -461,6 +459,7 @@ impl Gallery { section("Search", search), section("Popovers", popovers), section("Rows", fields), + section("Standalone controls", standalone_controls), section("Row group", row_group), section("Overlap-aware expanders", multiple_expanders), section("Expander matrix", expander_matrix), @@ -483,14 +482,13 @@ impl Gallery { .width(Fill) .height(Fill); - let page: Element<'_, Message> = - chrome::WindowFrame::new(column![header, gallery], Message::Window).into(); + let page = chrome::window_frame(column![header, gallery], Message::Window); let dialog = self.dialog_open.then(|| { - dialog::Dialog::new( + Dialog::new( column![ - title::Title::new("Dialog").subtitle("Modal content can use any widget."), - button::Button::new("Close") - .kind(button::ButtonKind::Primary) + Title::new("Dialog").subtitle("Modal content can use any widget."), + Button::new("Close") + .kind(ButtonKind::Primary) .on_press(Message::DismissDialog), ] .spacing(18), @@ -498,35 +496,35 @@ impl Gallery { ) }); - dialog::WindowModal::new(page).dialog(dialog).into() + WindowModal::new(page).dialog(dialog).into() } - fn search_state(&self) -> search::SearchState<'_, Message> { + fn search_state(&self) -> SearchState<'_, Message> { let query = self.search.trim().to_lowercase(); if query.is_empty() { - return search::SearchState::Hidden; + return SearchState::Hidden; } let results: Vec<_> = SEARCH_CATALOG .iter() .filter(|(title, _, _)| title.to_lowercase().contains(&query)) .map(|(title, action, action_icon)| { - search::SearchResult::new(*title, *title, Message::Noop) + SearchResult::new(title, Message::Noop) .icon(Icon::Bottles) .action(action, *action_icon, Message::Noop) }) .collect(); if results.is_empty() { - search::SearchState::Empty + SearchState::Empty } else { - search::SearchState::Results(results) + SearchState::Results(results) } } } -fn drop_target_example<'a>() -> drop_target::DropTarget<'a, Message> { +fn action_tile_example<'a>(active: bool) -> ActionTile<'a, Message> { const ICON_CONTAINER_SIZE: f32 = 44.0; let icon = container(Icon::Plus.view().width(16).height(16)) @@ -536,34 +534,48 @@ fn drop_target_example<'a>() -> drop_target::DropTarget<'a, Message> { .align_y(Center) .style(|theme: &Theme| { container::Style::default() - .background(theme.extended_palette().background.weak.color) + .background(theme::colors(theme).surface) .border(iced::Border::default().rounded(ICON_CONTAINER_SIZE / 2.0)) }); let labels = column![ - text("New Program").size(17).medium(), - text("Install or add a program.").size(14), + text(if active { + "New Program" + } else { + "Unavailable target" + }) + .size(17) + .medium(), + text(if active { + "Install or add a program." + } else { + "Activation is disabled." + }) + .size(14), ] .spacing(6); let content = container(row![icon, labels].spacing(16).align_y(Center)).center_x(Fill); - drop_target::DropTarget::new(content, Message::Noop) - .width(Fill) - .padding([72.0, 24.0]) + if active { + ActionTile::new(content, Message::OpenDialog) + .width(Fill) + .padding([72.0, 24.0]) + } else { + ActionTile::disabled(content) + .width(Fill) + .padding([72.0, 24.0]) + } } -fn action_grid_expander(title: &'static str) -> expander_row::ExpanderRow<'static, Message> { +fn action_grid_expander(title: &'static str) -> ExpanderRow<'static, Message> { ["Action 1", "Action 2", "Action 3", "Action 4"] .into_iter() .fold( - expander_row::ExpanderRow::new(title) + ExpanderRow::new(title) .description("Contains four actions") .columns(2), |expander, title| { - expander.add( - action_row::ActionRow::new(title, action_row::State::Ready(Message::Noop)) - .description("Available action"), - ) + expander.row(ActionRow::new(title, Message::Noop).description("Available action")) }, ) } diff --git a/src/app.rs b/src/app.rs index 116fc07..f3e0506 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,19 +4,19 @@ use std::{ sync::Arc, }; -use bottles_core::{Bottles, Config as CoreConfig, error::Error as CoreError}; -use iced::{Element, Fill, Subscription, Task, Theme, theme::Mode as ThemeMode}; +use bottles_core::{ + Bottle, BottleManager, Bottles, Config as CoreConfig, Profiles, ProfilesConfig, + error::Error as CoreError, +}; +use iced::{Element, Fill, Subscription, Task, theme::Mode as ThemeMode, window}; use next_config::Config; use serde::{Deserialize, Serialize}; use crate::{ - classic, onboarding, theme, - ui::chrome, - widgets::{ - button::{Button, ButtonKind}, - dialog::{Dialog, WindowModal}, - header_bar::HeaderBar, - }, + chrome, classic, + domain::DomainSnapshots, + features, next, onboarding, theme, + widget::{Button, ButtonKind, Dialog, WindowModal}, }; const APP_CONFIG_FILE: &str = "config.toml"; @@ -53,7 +53,7 @@ impl fmt::Display for AppError { type AppResult = Result; -#[derive(Debug, Default, Clone, Serialize, Deserialize, Config)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, Config)] #[config(version = 1)] pub struct AppConfig { #[serde(default)] @@ -68,103 +68,133 @@ pub enum Experience { pub(crate) struct App { phase: Phase, - theme: Theme, + theme: iced::Theme, + next_run_generation: u64, } enum Phase { - Booting, - Onboarding { - core: Arc, - state: Box, - saving: Option, - notice: Option, - }, - Workspace { - core: Arc, - workspace: Workspace, - transition: WorkspaceTransition, - }, - ShuttingDown, + Booting { close_requested: bool }, + ClosingBoot(Boot), + Session(Session), Failed(AppError), } -enum Workspace { - Classic(Box), - NextUnavailable, +struct Session { + config: AppConfig, + core: Arc, + domain: DomainSnapshots, + screen: SessionScreen, + lifecycle: SessionLifecycle, + overlay: Option, } -impl Workspace { - fn experience(&self) -> Experience { - match self { - Self::Classic(state) => state.experience(), - Self::NextUnavailable => Experience::Next, - } - } +enum SessionScreen { + Onboarding(Box), + Running { + generation: u64, + features: Box, + presentation: Presentation, + }, +} + +enum GlobalOverlay { + ConfirmExperienceSwitch(Experience), + Notice(AppError), +} +enum SessionLifecycle { + Active, + Draining(DrainIntent), + Saving { + candidate: AppConfig, + close_requested: bool, + }, + ShuttingDown(ShutdownResume), +} + +impl Session { fn has_active_operations(&self) -> bool { - match self { - Self::Classic(state) => state.has_active_operations(), - Self::NextUnavailable => false, + match &self.screen { + SessionScreen::Onboarding(state) => state.has_active_operations(), + SessionScreen::Running { features, .. } => features.has_active_operations(), } } fn cancel_active_operations(&mut self) { - if let Self::Classic(state) = self { - state.cancel_active_operations(); + match &mut self.screen { + SessionScreen::Onboarding(state) => state.cancel_active_operations(), + SessionScreen::Running { features, .. } => features.cancel_active_operations(), } } } -enum WorkspaceTransition { - Ready { notice: Option }, - Confirming(Experience), - Draining(Experience), - Saving(Experience), +enum Presentation { + Classic(Box), + Next(next::State), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DrainIntent { + Switch(Experience), + Close, +} + +enum ShutdownResume { + Existing, + Reopen(AppConfig), } #[derive(Clone)] pub(crate) enum AppMessage { - Booted(AppResult), - Onboarding(onboarding::Message), - Workspace(WorkspaceMessage), - RequestExperience(Experience), - ExperienceSaved { - experience: Experience, - result: AppResult<()>, + Action(AppAction), + Event(AppEvent), +} + +#[derive(Clone)] +pub(crate) enum AppAction { + Onboarding(onboarding::Action), + Classic { + generation: u64, + message: Box, + }, + Feature { + generation: u64, + message: features::Message, + }, + Next { + generation: u64, + action: next::Action, }, ConfirmExperienceSwitch, CancelExperienceSwitch, DismissNotice, - CloseRequested, - ShutdownFinished(AppResult<()>), Window(chrome::Action), +} + +#[derive(Clone)] +pub(crate) enum AppEvent { + Booted(AppResult), + Onboarding(onboarding::Event), + Feature { + generation: u64, + message: features::Message, + }, + BottlesChanged(Vec), + ProfilesChanged(Arc), + ConfigSaved(AppResult<()>), + ShutdownFinished(AppResult<()>), SystemThemeChanged(ThemeMode), } -impl std::fmt::Debug for AppMessage { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Debug for AppMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { - Self::Booted(_) => "Booted", - Self::Onboarding(_) => "Onboarding", - Self::Workspace(_) => "Workspace", - Self::RequestExperience(_) => "RequestExperience", - Self::ExperienceSaved { .. } => "ExperienceSaved", - Self::ConfirmExperienceSwitch => "ConfirmExperienceSwitch", - Self::CancelExperienceSwitch => "CancelExperienceSwitch", - Self::DismissNotice => "DismissNotice", - Self::CloseRequested => "CloseRequested", - Self::ShutdownFinished(_) => "ShutdownFinished", - Self::Window(_) => "Window", - Self::SystemThemeChanged(_) => "SystemThemeChanged", + Self::Action(_) => "Action", + Self::Event(_) => "Event", }) } } -#[derive(Clone)] -pub(crate) enum WorkspaceMessage { - Classic(Box), -} - #[derive(Clone)] pub(crate) struct Boot { config: AppConfig, @@ -175,415 +205,735 @@ impl App { pub(crate) fn new() -> (Self, Task) { ( Self { - phase: Phase::Booting, + phase: Phase::Booting { + close_requested: false, + }, theme: theme::for_mode(ThemeMode::default()), + next_run_generation: 0, }, Task::batch([ - Task::perform(boot(), AppMessage::Booted), - iced::system::theme().map(AppMessage::SystemThemeChanged), + Task::perform(boot(), |result| AppMessage::Event(AppEvent::Booted(result))), + iced::system::theme() + .map(|mode| AppMessage::Event(AppEvent::SystemThemeChanged(mode))), ]), ) } - pub(crate) fn theme(&self) -> Theme { + pub(crate) fn theme(&self) -> iced::Theme { self.theme.clone() } pub(crate) fn subscription(&self) -> Subscription { - let phase = match &self.phase { - Phase::Workspace { - workspace: Workspace::Classic(state), - .. - } => state - .subscription() - .map(|message| AppMessage::Workspace(WorkspaceMessage::Classic(Box::new(message)))), - Phase::Booting - | Phase::Onboarding { .. } - | Phase::Workspace { - workspace: Workspace::NextUnavailable, - .. - } - | Phase::ShuttingDown - | Phase::Failed(_) => Subscription::none(), - }; + let mut subscriptions = vec![ + iced::system::theme_changes() + .map(|mode| AppMessage::Event(AppEvent::SystemThemeChanged(mode))), + chrome::subscription().map(|action| AppMessage::Action(AppAction::Window(action))), + ]; + + if let Phase::Session(session) = &self.phase { + subscriptions.extend(domain_subscriptions(session)); + } - Subscription::batch([ - phase, - iced::system::theme_changes().map(AppMessage::SystemThemeChanged), - iced::event::listen().filter_map(|event| { - matches!( - event, - iced::Event::Window(iced::window::Event::CloseRequested) - ) - .then_some(AppMessage::CloseRequested) - }), - ]) + Subscription::batch(subscriptions) } pub(crate) fn update(&mut self, message: AppMessage) -> Task { match message { - AppMessage::Booted(result) if matches!(self.phase, Phase::ShuttingDown) => { - return match result { - Ok(boot) => self.shutdown(boot.core), - Err(_) => iced::exit(), - }; - } - AppMessage::Booted(Ok(boot)) if matches!(self.phase, Phase::Booting) => { - return self.finish_boot(boot); - } - AppMessage::Booted(Err(error)) if matches!(self.phase, Phase::Booting) => { - self.phase = Phase::Failed(error); + AppMessage::Action(action) => self.update_action(action), + AppMessage::Event(event) => self.update_event(event), + } + } + + fn update_action(&mut self, action: AppAction) -> Task { + if matches!(action, AppAction::Window(chrome::Action::RequestClose)) { + return self.request_close(); + } + + let Phase::Session(session) = &self.phase else { + return Task::none(); + }; + if !matches!(session.lifecycle, SessionLifecycle::Active) { + return Task::none(); + } + + if let AppAction::Window(action) = action { + return match action { + chrome::Action::Drag => window::latest().and_then(window::drag), + chrome::Action::Resize(direction) => { + window::latest().and_then(move |id| window::drag_resize(id, direction)) + } + chrome::Action::FocusPrevious => iced::widget::operation::focus_previous(), + chrome::Action::FocusNext => iced::widget::operation::focus_next(), + chrome::Action::RequestClose => { + unreachable!("close requests are handled before the action gate") + } + }; + } + + match action { + AppAction::Onboarding(onboarding::Action::Finished(experience)) => { + self.begin_onboarding_switch(experience) } - AppMessage::Booted(_) => {} - AppMessage::Onboarding(onboarding::Message::Finished(experience)) => { - if !matches!(self.phase, Phase::Onboarding { .. }) { + AppAction::Onboarding(action) => { + let Phase::Session(session) = &mut self.phase else { return Task::none(); - } - return self.request_experience(experience); + }; + let SessionScreen::Onboarding(state) = &mut session.screen else { + return Task::none(); + }; + state.update_action(action).map(onboarding_event_message) } - AppMessage::Onboarding(message) => { - let Phase::Onboarding { state, .. } = &mut self.phase else { + AppAction::Classic { + generation, + message, + } => { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + let SessionScreen::Running { + generation: current_generation, + features, + presentation: Presentation::Classic(state), + .. + } = &mut session.screen + else { return Task::none(); }; - return state.update(message).map(AppMessage::Onboarding); + let Some(message) = current_run_action(generation, *current_generation, message) + else { + return Task::none(); + }; + let generation = *current_generation; + + state + .update(features, *message, &session.domain) + .map(move |message| feature_event_message(generation, message)) } - AppMessage::Workspace(WorkspaceMessage::Classic(message)) => { - match message.as_ref() { - classic::Message::RequestExperience(experience) => { - return self.request_experience(*experience); - } - _ => {} - } + AppAction::Feature { + generation, + message, + } => { + let Phase::Session(session) = &self.phase else { + return Task::none(); + }; + let SessionScreen::Running { + generation: current_generation, + .. + } = &session.screen + else { + return Task::none(); + }; + let Some(message) = current_run_action(generation, *current_generation, message) + else { + return Task::none(); + }; - let task = { - let Phase::Workspace { - workspace: Workspace::Classic(state), - .. - } = &mut self.phase - else { - return Task::none(); - }; - - state.update(*message).map(|message| { - AppMessage::Workspace(WorkspaceMessage::Classic(Box::new(message))) - }) + self.update_feature(message) + } + AppAction::Next { generation, action } => { + let Phase::Session(session) = &self.phase else { + return Task::none(); + }; + let SessionScreen::Running { + generation: current_generation, + presentation: Presentation::Next(_), + .. + } = &session.screen + else { + return Task::none(); + }; + let Some(action) = current_run_action(generation, *current_generation, action) + else { + return Task::none(); }; - return Task::batch([task, self.advance_experience_switch()]); + match action { + next::Action::UseClassic => self.request_experience(Experience::Classic), + } } - AppMessage::RequestExperience(experience) => { - return self.request_experience(experience); + AppAction::ConfirmExperienceSwitch => self.confirm_experience_switch(), + AppAction::CancelExperienceSwitch => { + if let Phase::Session(session) = &mut self.phase { + session.overlay = None; + } + Task::none() } - AppMessage::ExperienceSaved { experience, result } => { - return self.finish_experience_save(experience, result); + AppAction::DismissNotice => { + if let Phase::Session(session) = &mut self.phase { + session.overlay = None; + } + Task::none() } - AppMessage::ConfirmExperienceSwitch => return self.confirm_experience_switch(), - AppMessage::CancelExperienceSwitch => { - let Phase::Workspace { transition, .. } = &mut self.phase else { + AppAction::Window(_) => unreachable!(), + } + } + + fn update_event(&mut self, event: AppEvent) -> Task { + match event { + AppEvent::Booted(result) => self.finish_boot(result), + AppEvent::Onboarding(event) => { + let Phase::Session(session) = &mut self.phase else { return Task::none(); }; - if matches!(transition, WorkspaceTransition::Confirming(_)) { - *transition = WorkspaceTransition::Ready { notice: None }; + if !matches!( + session.lifecycle, + SessionLifecycle::Active | SessionLifecycle::Draining(_) + ) { + return Task::none(); } + let SessionScreen::Onboarding(state) = &mut session.screen else { + return Task::none(); + }; + let task = state.update_event(event).map(onboarding_event_message); + Task::batch([task, self.advance_draining()]) } - AppMessage::DismissNotice => match &mut self.phase { - Phase::Onboarding { notice, .. } - | Phase::Workspace { - transition: WorkspaceTransition::Ready { notice }, + AppEvent::Feature { + generation, + message, + } => { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + if !matches!( + session.lifecycle, + SessionLifecycle::Active | SessionLifecycle::Draining(_) + ) { + return Task::none(); + } + let SessionScreen::Running { + generation: current_generation, .. - } => *notice = None, - Phase::Workspace { .. } => {} - Phase::Booting | Phase::ShuttingDown | Phase::Failed(_) => {} - }, - AppMessage::CloseRequested => return self.request_close(), - AppMessage::ShutdownFinished(result) => { - if !matches!(self.phase, Phase::ShuttingDown) { + } = &mut session.screen + else { + return Task::none(); + }; + if *current_generation != generation { return Task::none(); } - let _ = result; - return iced::exit(); + let task = self.update_feature(message); + Task::batch([task, self.advance_draining()]) } - AppMessage::Window(action) => { - return action.task().unwrap_or_else(|| self.request_close()); + AppEvent::BottlesChanged(bottles) => self.update_bottles(bottles), + AppEvent::ProfilesChanged(profiles) => self.update_profiles(profiles), + AppEvent::ConfigSaved(result) => self.finish_config_save(result), + AppEvent::ShutdownFinished(result) => self.finish_shutdown(result), + AppEvent::SystemThemeChanged(mode) => { + self.theme = theme::for_mode(mode); + Task::none() } - AppMessage::SystemThemeChanged(mode) => self.theme = theme::for_mode(mode), } + } - Task::none() + fn update_feature(&mut self, message: features::Message) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + let SessionScreen::Running { + generation, + features, + presentation, + } = &mut session.screen + else { + return Task::none(); + }; + let generation = *generation; + let (task, output) = features.update(message, &session.domain); + + if let (Presentation::Classic(state), Some(output)) = (presentation, output) { + state.handle_feature_output(output); + } + + task.map(move |message| feature_event_message(generation, message)) + } + + fn update_bottles(&mut self, bottles: Vec) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + if !session.domain.replace_bottles(bottles) { + return Task::none(); + } + let effects_allowed = matches!(session.lifecycle, SessionLifecycle::Active); + let SessionScreen::Running { + generation, + features, + presentation, + } = &mut session.screen + else { + return Task::none(); + }; + let generation = *generation; + if let Presentation::Classic(state) = presentation { + state.bottles_changed(features, &session.domain); + } + if effects_allowed { + features + .reload_library() + .map(move |message| feature_event_message(generation, message)) + } else { + Task::none() + } + } + + fn update_profiles(&mut self, profiles: Arc) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + let Some(selected_changed) = session.domain.replace_profiles(profiles) else { + return Task::none(); + }; + let effects_allowed = matches!(session.lifecycle, SessionLifecycle::Active); + let SessionScreen::Running { + generation, + features, + .. + } = &mut session.screen + else { + return Task::none(); + }; + let generation = *generation; + if effects_allowed { + features + .profiles_changed(&session.domain, selected_changed) + .map(move |message| feature_event_message(generation, message)) + } else { + Task::none() + } } pub(crate) fn view(&self) -> Element<'_, AppMessage> { let body = match &self.phase { - Phase::Booting => status_view("Starting Bottles", "Loading your workspace.", false), - Phase::Workspace { - workspace, - transition, + Phase::Booting { + close_requested: false, + .. + } => status_view("Starting Bottles", "Loading your workspace."), + Phase::Booting { + close_requested: true, .. - } => match transition { - WorkspaceTransition::Ready { .. } | WorkspaceTransition::Confirming(_) => { - workspace_view(workspace) + } + | Phase::ClosingBoot(_) => status_view("Closing Bottles", "Finishing background work."), + Phase::Session(session) => match &session.lifecycle { + SessionLifecycle::Active => match &session.screen { + SessionScreen::Onboarding(state) => { + if let Some(GlobalOverlay::Notice(error)) = &session.overlay { + onboarding_notice_view("Setup could not be saved", error.to_string()) + } else { + state.view().map(onboarding_action_message) + } + } + SessionScreen::Running { .. } => presentation_view(session), + }, + SessionLifecycle::Draining(intent) => match intent { + DrainIntent::Switch(_) => status_view( + "Preparing to switch experiences", + "Finishing current operations safely.", + ), + DrainIntent::Close => { + status_view("Closing Bottles", "Finishing background work.") + } + }, + SessionLifecycle::Saving { + close_requested: true, + .. } - WorkspaceTransition::Draining(_) => status_view( - "Preparing to switch experiences", - "Finishing current operations safely.", - false, - ), - WorkspaceTransition::Saving(_) => { - status_view("Switching experiences", "Saving your choice.", false) + | SessionLifecycle::ShuttingDown(_) => { + status_view("Closing Bottles", "Finishing background work.") } + SessionLifecycle::Saving { + close_requested: false, + .. + } => match session.screen { + SessionScreen::Onboarding(_) => { + onboarding_status_view("Finishing setup", "Saving your choice.") + } + SessionScreen::Running { .. } => { + status_view("Switching experiences", "Saving your choice.") + } + }, }, - Phase::Onboarding { - state, - saving, - notice, - .. - } => { - if saving.is_some() { - onboarding_status_view("Finishing setup", "Saving your choice.") - } else if let Some(error) = notice { - onboarding_notice_view("Setup could not be saved", error.to_string()) - } else { - state.view().map(AppMessage::Onboarding) - } - } - Phase::ShuttingDown => { - status_view("Closing Bottles", "Finishing background work.", false) - } - Phase::Failed(error) => { - status_view("Bottles could not start", error.to_string(), false) - } + Phase::Failed(error) => status_view("Bottles could not start", error.to_string()), }; - let page: Element<'_, AppMessage> = - chrome::WindowFrame::new(body, AppMessage::Window).into(); + let page = + chrome::window_frame(body, |action| AppMessage::Action(AppAction::Window(action))); let dialog = match &self.phase { - Phase::Workspace { - transition: WorkspaceTransition::Confirming(target), - .. - } => Some(confirmation_dialog(*target)), - Phase::Workspace { - transition: - WorkspaceTransition::Ready { - notice: Some(error), + Phase::Session(Session { + screen: + SessionScreen::Running { + generation, + features, + presentation, }, + lifecycle: SessionLifecycle::Active, + overlay, .. - } => Some(notice_dialog( - "The experience was not changed", - error.to_string(), - )), - Phase::Workspace { - workspace: Workspace::Classic(state), - transition: WorkspaceTransition::Ready { notice: None }, - .. - } => state.dialog().map(|dialog| dialog.map(classic_message)), + }) => match overlay { + Some(GlobalOverlay::ConfirmExperienceSwitch(target)) => { + Some(confirmation_dialog(*target)) + } + Some(GlobalOverlay::Notice(error)) => Some(notice_dialog( + "The experience was not changed", + error.to_string(), + )), + None => match presentation { + Presentation::Classic(state) => { + let generation = *generation; + state.dialog(features).map(move |dialog| { + dialog.map(move |message| classic_action_message(generation, message)) + }) + } + Presentation::Next(_) => None, + }, + }, _ => None, }; WindowModal::new(page).dialog(dialog).into() } - fn finish_boot(&mut self, boot: Boot) -> Task { - let Boot { config, core } = boot; + fn finish_boot(&mut self, result: AppResult) -> Task { + let Phase::Booting { close_requested } = &self.phase else { + return Task::none(); + }; + let close_requested = *close_requested; - match config.experience { - None => { - let state = onboarding::State::new(core.addons().clone()); - self.phase = Phase::Onboarding { - core, - state: Box::new(state), - saving: None, - notice: None, - }; + match result { + Ok(boot) if close_requested => { + let core = boot.core.clone(); + self.phase = Phase::ClosingBoot(boot); + shutdown_task(core) + } + Ok(boot) => self.open_boot(boot, None), + Err(_) if close_requested => iced::exit(), + Err(error) => { + self.phase = Phase::Failed(error); Task::none() } - Some(experience) => self.open_workspace(core, experience), } } - fn request_experience(&mut self, experience: Experience) -> Task { - match &mut self.phase { - Phase::Onboarding { saving, notice, .. } => { - if saving.is_some() { - return Task::none(); - } - *saving = Some(experience); - *notice = None; - save_experience(experience) - } - Phase::Workspace { - workspace, - transition, - .. - } => { - if !matches!(transition, WorkspaceTransition::Ready { notice: None }) - || workspace.experience() == experience - { - return Task::none(); - } + fn begin_onboarding_switch(&mut self, experience: Experience) -> Task { + let Phase::Session(session) = &self.phase else { + return Task::none(); + }; + if !matches!(session.screen, SessionScreen::Onboarding(_)) { + return Task::none(); + } + self.begin_draining(DrainIntent::Switch(experience)) + } - *transition = WorkspaceTransition::Confirming(experience); - Task::none() - } - Phase::Booting | Phase::ShuttingDown | Phase::Failed(_) => Task::none(), + fn request_experience(&mut self, experience: Experience) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + if !matches!(session.screen, SessionScreen::Running { .. }) { + return Task::none(); } + if session.config.experience == Some(experience) || session.overlay.is_some() { + return Task::none(); + } + + session.overlay = Some(GlobalOverlay::ConfirmExperienceSwitch(experience)); + Task::none() } fn confirm_experience_switch(&mut self) -> Task { - let Phase::Workspace { - workspace, - transition, - .. - } = &mut self.phase - else { + let Phase::Session(session) = &mut self.phase else { return Task::none(); }; - let WorkspaceTransition::Confirming(target) = transition else { + if !matches!(session.screen, SessionScreen::Running { .. }) { return Task::none(); + } + let target = match session.overlay { + Some(GlobalOverlay::ConfirmExperienceSwitch(target)) => target, + _ => return Task::none(), }; - let target = *target; + session.overlay = None; - workspace.cancel_active_operations(); - *transition = WorkspaceTransition::Draining(target); - self.advance_experience_switch() + self.begin_draining(DrainIntent::Switch(target)) } - fn advance_experience_switch(&mut self) -> Task { - let target = match &self.phase { - Phase::Workspace { - workspace, - transition: WorkspaceTransition::Draining(target), - .. - } if !workspace.has_active_operations() => *target, - _ => return Task::none(), + fn begin_draining(&mut self, intent: DrainIntent) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); }; + if !matches!(session.lifecycle, SessionLifecycle::Active) { + return Task::none(); + } + session.cancel_active_operations(); + session.lifecycle = SessionLifecycle::Draining(intent); + self.advance_draining() + } - let Phase::Workspace { transition, .. } = &mut self.phase else { - unreachable!("the switch target came from a workspace") + fn advance_draining(&mut self) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + let SessionLifecycle::Draining(intent) = session.lifecycle else { + return Task::none(); }; - *transition = WorkspaceTransition::Saving(target); - save_experience(target) + if session.has_active_operations() { + return Task::none(); + } + + match intent { + DrainIntent::Switch(experience) => { + let mut candidate = session.config.clone(); + candidate.experience = Some(experience); + session.lifecycle = SessionLifecycle::Saving { + candidate: candidate.clone(), + close_requested: false, + }; + save_config_task(candidate) + } + DrainIntent::Close => { + let core = session.core.clone(); + session.lifecycle = SessionLifecycle::ShuttingDown(ShutdownResume::Existing); + shutdown_task(core) + } + } } - fn finish_experience_save( - &mut self, - experience: Experience, - result: AppResult<()>, - ) -> Task { - let expected = match &self.phase { - Phase::Onboarding { saving, .. } => *saving == Some(experience), - Phase::Workspace { - transition: WorkspaceTransition::Saving(target), - .. - } => *target == experience, - _ => false, + fn finish_config_save(&mut self, result: AppResult<()>) -> Task { + let Phase::Session(session) = &mut self.phase else { + return Task::none(); }; - if !expected { + if !matches!(session.lifecycle, SessionLifecycle::Saving { .. }) { return Task::none(); } + let SessionLifecycle::Saving { + candidate, + close_requested, + } = std::mem::replace(&mut session.lifecycle, SessionLifecycle::Active) + else { + unreachable!() + }; match result { + Ok(()) if close_requested => { + let core = session.core.clone(); + session.lifecycle = + SessionLifecycle::ShuttingDown(ShutdownResume::Reopen(candidate)); + shutdown_task(core) + } Ok(()) => { - let core = match &self.phase { - Phase::Onboarding { core, .. } | Phase::Workspace { core, .. } => core.clone(), - _ => unreachable!("the saved experience belonged to a retained workspace"), - }; - self.open_workspace(core, experience) + let core = session.core.clone(); + self.open_experience(core, candidate, None) + } + Err(_) if close_requested => { + let core = session.core.clone(); + session.lifecycle = SessionLifecycle::ShuttingDown(ShutdownResume::Existing); + shutdown_task(core) } Err(error) => { - match &mut self.phase { - Phase::Onboarding { saving, notice, .. } => { - *saving = None; - *notice = Some(error); - } - Phase::Workspace { - workspace, - transition, - .. - } => { - *transition = WorkspaceTransition::Ready { - notice: Some(error), - }; - return match workspace { - Workspace::Classic(state) => { - state.resume_after_failed_switch().map(|message| { - AppMessage::Workspace(WorkspaceMessage::Classic(Box::new( - message, - ))) - }) - } - Workspace::NextUnavailable => Task::none(), - }; - } - _ => unreachable!("the failed save belonged to a retained workspace"), - } - Task::none() + session.overlay = Some(GlobalOverlay::Notice(error)); + resume_session(session) } } } - fn open_workspace(&mut self, core: Arc, experience: Experience) -> Task { - let (workspace, task) = match experience { - Experience::Classic => { - let (state, task) = classic::State::new(core.as_ref()); - ( - Workspace::Classic(Box::new(state)), - task.map(|message| { - AppMessage::Workspace(WorkspaceMessage::Classic(Box::new(message))) - }), - ) + fn request_close(&mut self) -> Task { + match &mut self.phase { + Phase::Booting { close_requested } => { + *close_requested = true; + return Task::none(); } - Experience::Next => (Workspace::NextUnavailable, Task::none()), - }; + Phase::ClosingBoot(_) => return Task::none(), + Phase::Failed(_) => return iced::exit(), + Phase::Session(session) => match &mut session.lifecycle { + SessionLifecycle::Active => {} + SessionLifecycle::Draining(intent) => { + *intent = DrainIntent::Close; + return self.advance_draining(); + } + SessionLifecycle::Saving { + close_requested, .. + } => { + *close_requested = true; + return Task::none(); + } + SessionLifecycle::ShuttingDown(_) => return Task::none(), + }, + } - self.phase = Phase::Workspace { - core, - workspace, - transition: WorkspaceTransition::Ready { notice: None }, - }; - task + self.begin_draining(DrainIntent::Close) } - fn request_close(&mut self) -> Task { - let core = match &mut self.phase { - Phase::Onboarding { core, state, .. } => { - state.cancel_active_operations(); - core.clone() - } - Phase::Workspace { - core, workspace, .. - } => { - workspace.cancel_active_operations(); - core.clone() + fn finish_shutdown(&mut self, result: AppResult<()>) -> Task { + if let Phase::ClosingBoot(boot) = &self.phase { + let boot = boot.clone(); + return match result { + Ok(()) => iced::exit(), + Err(error) => self.open_boot(boot, Some(error)), + }; + } + + let Phase::Session(session) = &mut self.phase else { + return Task::none(); + }; + if !matches!(session.lifecycle, SessionLifecycle::ShuttingDown(_)) { + return Task::none(); + } + let SessionLifecycle::ShuttingDown(resume) = + std::mem::replace(&mut session.lifecycle, SessionLifecycle::Active) + else { + unreachable!() + }; + + match (result, resume) { + (Ok(()), _) => iced::exit(), + (Err(error), ShutdownResume::Existing) => { + session.overlay = Some(GlobalOverlay::Notice(error)); + resume_session(session) } - Phase::Booting => { - self.phase = Phase::ShuttingDown; - return Task::none(); + (Err(error), ShutdownResume::Reopen(config)) => { + let core = session.core.clone(); + self.open_experience(core, config, Some(error)) } - Phase::Failed(_) => { - self.phase = Phase::ShuttingDown; - return iced::exit(); + } + } + + fn open_boot(&mut self, boot: Boot, notice: Option) -> Task { + let Boot { config, core } = boot; + match config.experience { + None => { + let domain = DomainSnapshots::new(&core); + self.phase = Phase::Session(Session { + config, + domain, + screen: SessionScreen::Onboarding(Box::new(onboarding::State::new( + core.addons().clone(), + ))), + core, + lifecycle: SessionLifecycle::Active, + overlay: notice.map(GlobalOverlay::Notice), + }); + Task::none() } - Phase::ShuttingDown => return Task::none(), + Some(_) => self.open_experience(core, config, notice), + } + } + + fn open_experience( + &mut self, + core: Arc, + config: AppConfig, + notice: Option, + ) -> Task { + let Some(experience) = config.experience else { + unreachable!("a presentation requires a selected experience") + }; + let generation = self.next_run_generation; + self.next_run_generation = self.next_run_generation.wrapping_add(1); + let domain = match &self.phase { + Phase::Session(session) if Arc::ptr_eq(&session.core, &core) => session.domain.clone(), + _ => DomainSnapshots::new(&core), + }; + let features = features::State::new(core.as_ref(), &domain); + let presentation = match experience { + Experience::Classic => Presentation::Classic(Box::new(classic::State::new())), + Experience::Next => Presentation::Next(next::State::new()), }; - self.phase = Phase::ShuttingDown; - self.shutdown(core) + self.phase = Phase::Session(Session { + config, + core, + domain, + screen: SessionScreen::Running { + generation, + features: Box::new(features), + presentation, + }, + lifecycle: SessionLifecycle::Active, + overlay: notice.map(GlobalOverlay::Notice), + }); + Task::none() } +} - fn shutdown(&self, core: Arc) -> Task { - Task::perform( - async move { - core.shutdown() - .await - .map_err(|error| AppError::Core(Arc::new(error))) - }, - AppMessage::ShutdownFinished, - ) +fn resume_session(session: &mut Session) -> Task { + let SessionScreen::Running { + generation, + features, + presentation, + .. + } = &mut session.screen + else { + return Task::none(); + }; + let generation = *generation; + features + .profiles + .sync_selected(session.domain.profiles().selected()); + + match presentation { + Presentation::Classic(state) => state + .resume_after_failed_switch(features, &session.domain) + .map(move |message| feature_event_message(generation, message)), + Presentation::Next(_) => Task::none(), } } -fn save_experience(experience: Experience) -> Task { - Task::perform(save_config(experience), move |result| { - AppMessage::ExperienceSaved { experience, result } +fn shutdown_task(core: Arc) -> Task { + Task::perform( + async move { + core.shutdown() + .await + .map_err(|error| AppError::Core(Arc::new(error))) + }, + |result| AppMessage::Event(AppEvent::ShutdownFinished(result)), + ) +} + +fn domain_subscriptions(session: &Session) -> [Subscription; 2] { + [ + Subscription::run_with(session.core.bottles().clone(), BottleManager::watch) + .map(|bottles| AppMessage::Event(AppEvent::BottlesChanged(bottles))), + Subscription::run_with(session.core.profiles().clone(), Profiles::watch) + .map(|profiles| AppMessage::Event(AppEvent::ProfilesChanged(profiles))), + ] +} + +fn onboarding_event_message(event: onboarding::Event) -> AppMessage { + AppMessage::Event(AppEvent::Onboarding(event)) +} + +fn onboarding_action_message(action: onboarding::Action) -> AppMessage { + match action { + onboarding::Action::DragWindow => { + AppMessage::Action(AppAction::Window(chrome::Action::Drag)) + } + action => AppMessage::Action(AppAction::Onboarding(action)), + } +} + +fn classic_action_message(generation: u64, message: classic::Message) -> AppMessage { + match message { + classic::Message::DragWindow => AppMessage::Action(AppAction::Window(chrome::Action::Drag)), + classic::Message::Feature(message) => AppMessage::Action(AppAction::Feature { + generation, + message, + }), + message => AppMessage::Action(AppAction::Classic { + generation, + message: Box::new(message), + }), + } +} + +fn current_run_action(generation: u64, current_generation: u64, action: T) -> Option { + (generation == current_generation).then_some(action) +} + +fn feature_event_message(generation: u64, message: features::Message) -> AppMessage { + AppMessage::Event(AppEvent::Feature { + generation, + message, + }) +} + +fn save_config_task(config: AppConfig) -> Task { + Task::perform(save_config(config), |result| { + AppMessage::Event(AppEvent::ConfigSaved(result)) }) } @@ -595,12 +945,11 @@ fn config_path() -> AppResult { async fn load_config() -> AppResult { let path = config_path()?; - load_config_from(&path).await } async fn load_config_from(path: &Path) -> AppResult { - match next_config::load(&path).await { + match next_config::load(path).await { Ok(config) => Ok(config), Err(next_config::error::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => { Ok(AppConfig::default()) @@ -613,24 +962,19 @@ async fn load_config_from(path: &Path) -> AppResult { } } -async fn save_config(experience: Experience) -> AppResult<()> { +async fn save_config(config: AppConfig) -> AppResult<()> { let path = config_path()?; - save_config_to(&path, experience).await + save_config_to(&path, &config).await } -async fn save_config_to(path: &Path, experience: Experience) -> AppResult<()> { - next_config::save( - path, - &AppConfig { - experience: Some(experience), - }, - ) - .await - .map_err(|source| AppError::Config { - action: "save", - path: path.to_owned(), - source: Arc::new(source), - }) +async fn save_config_to(path: &Path, config: &AppConfig) -> AppResult<()> { + next_config::save(path, config) + .await + .map_err(|source| AppError::Config { + action: "save", + path: path.to_owned(), + source: Arc::new(source), + }) } async fn boot() -> AppResult { @@ -662,20 +1006,10 @@ fn core_config() -> CoreConfig { fn status_view<'a>( title: impl iced::widget::text::IntoFragment<'a>, description: impl iced::widget::text::IntoFragment<'a>, - offer_classic: bool, ) -> Element<'a, AppMessage> { use iced::widget::{column, text}; - let mut status = column![text(title).size(32), text(description)].spacing(12); - if offer_classic { - status = status.push( - Button::new("Use Classic") - .kind(ButtonKind::Primary) - .on_press(AppMessage::RequestExperience(Experience::Classic)), - ); - } - - root_body(status) + root_body(column![text(title).size(32), text(description)].spacing(12)) } fn onboarding_status_view<'a>( @@ -686,7 +1020,7 @@ fn onboarding_status_view<'a>( onboarding::shell( column![text(title).size(32), text(description)].spacing(12), - AppMessage::Window(chrome::Action::Drag), + AppMessage::Action(AppAction::Window(chrome::Action::Drag)), ) } @@ -702,24 +1036,31 @@ fn onboarding_notice_view<'a>( text(description), Button::new("Continue") .kind(ButtonKind::Primary) - .on_press(AppMessage::DismissNotice), + .on_press(AppMessage::Action(AppAction::DismissNotice)), ] .spacing(12), - AppMessage::Window(chrome::Action::Drag), + AppMessage::Action(AppAction::Window(chrome::Action::Drag)), ) } -fn classic_message(message: classic::Message) -> AppMessage { - AppMessage::Workspace(WorkspaceMessage::Classic(Box::new(message))) -} - -fn workspace_view(workspace: &Workspace) -> Element<'_, AppMessage> { - match workspace { - Workspace::Classic(state) => state.view().map(classic_message), - Workspace::NextUnavailable => status_view( - "Next experience is not available yet", - "Choose Classic to use Bottles today.", - true, +fn presentation_view(session: &Session) -> Element<'_, AppMessage> { + let SessionScreen::Running { + generation, + features, + presentation, + } = &session.screen + else { + unreachable!("only running sessions render a presentation") + }; + let generation = *generation; + match presentation { + Presentation::Classic(state) => state + .view(features, &session.domain) + .map(move |message| classic_action_message(generation, message)), + Presentation::Next(state) => root_body( + state + .view() + .map(move |action| AppMessage::Action(AppAction::Next { generation, action })), ), } } @@ -740,10 +1081,10 @@ fn confirmation_dialog(target: Experience) -> Dialog<'static, AppMessage> { let actions = row![ Button::new("Cancel") .kind(ButtonKind::Transparent) - .on_press(AppMessage::CancelExperienceSwitch), + .on_press(AppMessage::Action(AppAction::CancelExperienceSwitch)), Button::new(confirm_label) .kind(ButtonKind::Primary) - .on_press(AppMessage::ConfirmExperienceSwitch), + .on_press(AppMessage::Action(AppAction::ConfirmExperienceSwitch)), ] .spacing(8); @@ -754,7 +1095,7 @@ fn confirmation_dialog(target: Experience) -> Dialog<'static, AppMessage> { actions ] .spacing(12), - AppMessage::CancelExperienceSwitch, + AppMessage::Action(AppAction::CancelExperienceSwitch), ) } @@ -770,24 +1111,27 @@ fn notice_dialog<'a>( text(description), Button::new("Continue") .kind(ButtonKind::Primary) - .on_press(AppMessage::DismissNotice), + .on_press(AppMessage::Action(AppAction::DismissNotice)), ] .spacing(12), - AppMessage::DismissNotice, + AppMessage::Action(AppAction::DismissNotice), ) } fn root_body<'a>(content: impl Into>) -> Element<'a, AppMessage> { use iced::widget::{center, column}; - let content = column![ - HeaderBar::new(AppMessage::Window(chrome::Action::Drag)), + column![ + chrome::header( + AppMessage::Action(AppAction::Window(chrome::Action::Drag)), + true, + |header| header, + ), center(content).width(Fill).height(Fill), ] .width(Fill) - .height(Fill); - - content.into() + .height(Fill) + .into() } #[cfg(test)] @@ -808,22 +1152,45 @@ mod tests { ); } + #[test] + fn classic_feature_messages_bypass_presentation_update() { + let message = classic_action_message( + 7, + classic::Message::Feature(features::Message::Library( + features::library::Message::QueryChanged(String::new()), + )), + ); + + assert!(matches!( + message, + AppMessage::Action(AppAction::Feature { generation: 7, .. }) + )); + } + + #[test] + fn stale_run_actions_are_rejected() { + assert_eq!(current_run_action(7, 7, "current"), Some("current")); + assert_eq!(current_run_action(6, 7, "stale"), None); + } + #[test] fn a_missing_config_has_no_selected_experience() { let path = std::env::temp_dir().join(format!("next-ui-{}.toml", uuid::Uuid::new_v4())); let config = futures_lite::future::block_on(load_config_from(&path)).unwrap(); - assert_eq!(config.experience, None); + assert_eq!(config, AppConfig::default()); } #[test] fn a_saved_experience_round_trips() { let path = std::env::temp_dir().join(format!("next-ui-{}.toml", uuid::Uuid::new_v4())); + let expected = AppConfig { + experience: Some(Experience::Classic), + }; futures_lite::future::block_on(async { - save_config_to(&path, Experience::Classic).await.unwrap(); - let config = load_config_from(&path).await.unwrap(); - assert_eq!(config.experience, Some(Experience::Classic)); + save_config_to(&path, &expected).await.unwrap(); + assert_eq!(load_config_from(&path).await.unwrap(), expected); }); std::fs::remove_file(path).unwrap(); diff --git a/src/chrome.rs b/src/chrome.rs new file mode 100644 index 0000000..b17ad33 --- /dev/null +++ b/src/chrome.rs @@ -0,0 +1,278 @@ +//! Application-owned chrome for the undecorated app and gallery windows. + +use iced::{ + Background, Border, Element, Fill, Subscription, event, keyboard, widget::container, window, +}; + +use iced::{ + alignment::{Horizontal, Vertical}, + mouse, + widget::{Space, mouse_area, stack}, + window::Direction, +}; + +use next_ui::{ + Icon, theme, + widget::{Button, ButtonKind, HeaderBar}, +}; + +const RESIZE_EDGE: f32 = 6.0; +const RESIZE_CORNER: f32 = 12.0; +const PANEL_INSET: [f32; 2] = [6.0, 8.0]; +const WINDOW_CONTROL_INSET: [f32; 2] = [22.0, 20.0]; +const WINDOW_CONTROL_SIZE: f32 = 32.0; + +pub(crate) const WINDOW_CONTROL_AT_START: bool = cfg!(target_os = "macos"); + +/// A semantic window-frame interaction for the application to handle. +#[derive(Debug, Clone, Copy)] +pub(crate) enum Action { + /// Begin dragging the current window. + Drag, + /// Begin resizing the current window from an edge or corner. + Resize(window::Direction), + /// Ask the application to run its shutdown lifecycle. + RequestClose, + /// Move to the previous global focus target. + FocusPrevious, + /// Move to the next global focus target. + FocusNext, +} + +/// Listens for application-window requests and unhandled keyboard traversal. +pub(crate) fn subscription() -> Subscription { + iced::event::listen_with(|event, status, _| subscription_action(event, status)) +} + +fn subscription_action(event: iced::Event, status: event::Status) -> Option { + match event { + iced::Event::Window(window::Event::CloseRequested) => Some(Action::RequestClose), + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Tab), + modifiers, + repeat: false, + .. + }) if status == event::Status::Ignored => Some(if modifiers.shift() { + Action::FocusPrevious + } else { + Action::FocusNext + }), + _ => None, + } +} + +/// An undecorated application frame with resize edges and a close control. +pub(crate) fn window_frame<'a, Message: Clone + 'a>( + content: impl Into>, + on_action: impl Fn(Action) -> Message, +) -> Element<'a, Message> { + let content: Element<'a, Message> = container(content) + .width(Fill) + .height(Fill) + .padding(PANEL_INSET) + .style(|current_theme| { + let colors = theme::colors(current_theme); + + container::Style { + background: Some(Background::Color(colors.window)), + border: Border::default() + .rounded(6) + .color(colors.window_border) + .width(1), + ..container::Style::default() + } + }) + .clip(true) + .into(); + + let mut layers = stack![content].width(Fill).height(Fill).clip(true); + + for direction in [ + Direction::North, + Direction::South, + Direction::East, + Direction::West, + Direction::NorthEast, + Direction::NorthWest, + Direction::SouthEast, + Direction::SouthWest, + ] { + layers = layers.push(resize_edge(direction, on_action(Action::Resize(direction)))) + } + + let close = Button::icon_only("Close window", Icon::Cross) + .diameter(WINDOW_CONTROL_SIZE) + .icon_size(16.0) + .kind(ButtonKind::Transparent) + .on_press(on_action(Action::RequestClose)); + let close = container(close) + .width(Fill) + .height(Fill) + .align_x(if WINDOW_CONTROL_AT_START { + Horizontal::Left + } else { + Horizontal::Right + }) + .align_y(Vertical::Top) + .padding(WINDOW_CONTROL_INSET); + + layers.push(close).into() +} + +pub(crate) fn header<'a, Message: Clone + 'a>( + on_drag: Message, + owns_window_control: bool, + build: impl FnOnce(HeaderBar<'a, Message>) -> HeaderBar<'a, Message>, +) -> Element<'a, Message> { + let header = HeaderBar::new(on_drag); + + let header = if !owns_window_control { + build(header) + } else { + let spacer = Space::new() + .width(WINDOW_CONTROL_SIZE) + .height(WINDOW_CONTROL_SIZE); + + if WINDOW_CONTROL_AT_START { + build(header.start(spacer)) + } else { + build(header).end(spacer) + } + }; + + header.into() +} + +fn resize_edge<'a, Message: Clone + 'a>( + direction: Direction, + message: Message, +) -> Element<'a, Message> { + let (width, height, interaction, horizontal, vertical) = match direction { + Direction::North => ( + Fill, + RESIZE_EDGE.into(), + mouse::Interaction::ResizingVertically, + Horizontal::Left, + Vertical::Top, + ), + Direction::South => ( + Fill, + RESIZE_EDGE.into(), + mouse::Interaction::ResizingVertically, + Horizontal::Left, + Vertical::Bottom, + ), + Direction::East => ( + RESIZE_EDGE.into(), + Fill, + mouse::Interaction::ResizingHorizontally, + Horizontal::Right, + Vertical::Top, + ), + Direction::West => ( + RESIZE_EDGE.into(), + Fill, + mouse::Interaction::ResizingHorizontally, + Horizontal::Left, + Vertical::Top, + ), + Direction::NorthEast => ( + RESIZE_CORNER.into(), + RESIZE_CORNER.into(), + mouse::Interaction::ResizingDiagonallyUp, + Horizontal::Right, + Vertical::Top, + ), + Direction::NorthWest => ( + RESIZE_CORNER.into(), + RESIZE_CORNER.into(), + mouse::Interaction::ResizingDiagonallyDown, + Horizontal::Left, + Vertical::Top, + ), + Direction::SouthEast => ( + RESIZE_CORNER.into(), + RESIZE_CORNER.into(), + mouse::Interaction::ResizingDiagonallyDown, + Horizontal::Right, + Vertical::Bottom, + ), + Direction::SouthWest => ( + RESIZE_CORNER.into(), + RESIZE_CORNER.into(), + mouse::Interaction::ResizingDiagonallyUp, + Horizontal::Left, + Vertical::Bottom, + ), + }; + + container( + mouse_area(Space::new().width(width).height(height)) + .on_press(message) + .interaction(interaction), + ) + .width(Fill) + .height(Fill) + .align_x(horizontal) + .align_y(vertical) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tab(modifiers: keyboard::Modifiers, repeat: bool) -> iced::Event { + let key = keyboard::Key::Named(keyboard::key::Named::Tab); + + iced::Event::Keyboard(keyboard::Event::KeyPressed { + modified_key: key.clone(), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab), + location: keyboard::Location::Standard, + modifiers, + text: None, + key, + repeat, + }) + } + + #[test] + fn only_ignored_non_repeated_tabs_request_global_focus_traversal() { + assert!(matches!( + subscription_action( + tab(keyboard::Modifiers::NONE, false), + event::Status::Ignored + ), + Some(Action::FocusNext) + )); + assert!(matches!( + subscription_action( + tab(keyboard::Modifiers::SHIFT, false), + event::Status::Ignored + ), + Some(Action::FocusPrevious) + )); + assert!( + subscription_action( + tab(keyboard::Modifiers::NONE, false), + event::Status::Captured + ) + .is_none() + ); + assert!( + subscription_action(tab(keyboard::Modifiers::NONE, true), event::Status::Ignored) + .is_none() + ); + } + + #[test] + fn close_requests_are_reported_even_when_a_modal_captures_them() { + assert!(matches!( + subscription_action( + iced::Event::Window(window::Event::CloseRequested), + event::Status::Captured, + ), + Some(Action::RequestClose) + )); + } +} diff --git a/src/classic/accounts_view.rs b/src/classic/accounts_view.rs new file mode 100644 index 0000000..450264f --- /dev/null +++ b/src/classic/accounts_view.rs @@ -0,0 +1,152 @@ +use bottles_core::{Profile, StorefrontProvider}; +use iced::{ + Element, + widget::{column, container, row}, +}; + +use crate::{ + Icon, + features::profiles::{self, AccountMessage, LoginDialog, State}, + widget::{ + ActionRow, Button, ButtonKind, Dialog, InfoCard, InfoCardKind, InfoRow, ListRow, PickerRow, + Popover, PopoverItem, RowGroup, TextRow, Title, + }, +}; + +pub fn dialog(state: &State) -> Option> { + state.account_link().dialog().map(|dialog| { + Dialog::new(login_dialog(dialog), AccountMessage::DismissLogin) + .map(profiles::Message::Account) + }) +} + +pub fn links<'a>(state: &'a State, active: &'a Profile) -> Element<'a, profiles::Message> { + let mut accounts = RowGroup::new().title("Linked accounts"); + for account in active.accounts() { + accounts = accounts.row(action_button_row( + provider_icon(&account.provider), + format!( + "{} on {}", + account.identity.display_name, account.provider.name + ), + "Connected", + "Unlink", + profiles::Message::Account(AccountMessage::UnlinkAccount(account.provider.id.clone())), + )); + } + + let trigger = PickerRow::new("Link a storefront account") + .description("Choose the account provider to connect"); + let mut menu = Popover::row(trigger); + + for provider in state.account_providers() { + if active + .accounts() + .iter() + .any(|account| account.provider.id == provider.id) + { + continue; + } + + menu = menu.item( + PopoverItem::new(provider.name.clone()) + .icon(provider_icon(&provider)) + .action( + "Link", + profiles::Message::Account(AccountMessage::BeginLogin(provider)), + ), + ); + } + + menu = menu.item( + PopoverItem::new("Not listed, install a provider plugin").on_select( + profiles::Message::Account(AccountMessage::InstallProviderPlugin), + ), + ); + + let mut content = column![accounts, container(menu).width(iced::Fill)].spacing(18); + if let Some(error) = state.account_link().last_error() { + content = content.push( + InfoCard::new(InfoCardKind::Error, "Account update failed", error).width(iced::Fill), + ); + } + + content.into() +} + +pub fn action_button_row<'a, Message: Clone + 'a>( + icon: Icon, + title: impl iced::widget::text::IntoFragment<'a>, + description: &'a str, + button_label: &'a str, + on_press: Message, +) -> ListRow<'a, Message> { + ListRow::from(InfoRow::new(title).description(description).icon(icon)).trailing( + Button::new(button_label) + .kind(ButtonKind::Surface) + .on_press(on_press), + ) +} + +fn login_dialog(dialog: &LoginDialog) -> iced::widget::Column<'_, AccountMessage> { + let submit_label = if dialog.is_submitting() { + "Submitting…" + } else { + "Submit" + }; + + let mut content = column![ + container(Title::new("Sign in").subtitle(dialog.instructions())).center_x(iced::Fill), + RowGroup::new() + .row( + ActionRow::new("Sign-in link (click to copy)", AccountMessage::CopyLoginUrl,) + .description(dialog.url()) + .icon(Icon::Controller), + ) + .row(action_button_row( + Icon::Arrow, + "Open in your browser", + "Sign in there, then paste the requested value below.", + "Open", + AccountMessage::OpenLoginUrl, + )) + .row( + TextRow::new("Authorization code", dialog.code()) + .icon(Icon::Checkmark) + .on_input(AccountMessage::LoginCodeChanged) + .on_submit(AccountMessage::SubmitLogin), + ), + ] + .spacing(18); + + if let Some(error) = dialog.error() { + content = content.push( + InfoCard::new( + InfoCardKind::Error, + "Could not answer the sign-in prompt", + error, + ) + .width(iced::Fill), + ); + } + + content.push( + row![ + Button::new(submit_label) + .kind(ButtonKind::Primary) + .on_press_maybe((!dialog.is_submitting()).then_some(AccountMessage::SubmitLogin)), + Button::new("Cancel") + .kind(ButtonKind::Transparent) + .on_press(AccountMessage::DismissLogin), + ] + .spacing(12), + ) +} + +fn provider_icon(provider: &StorefrontProvider) -> Icon { + if provider.id.as_str() == "steam" { + Icon::Computer + } else { + Icon::Controller + } +} diff --git a/src/classic/bottles.rs b/src/classic/bottles.rs deleted file mode 100644 index 6d152b1..0000000 --- a/src/classic/bottles.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Bottles: the list of bottles, the new-bottle creation flow, and the -//! "Programs" detail tab. - -use std::sync::Arc; - -use bottles_core::{Addons, Bottle, BottleManager, BottleState, Slot, Storage}; -use iced::{ - Center, Element, Length, Task, Theme, - widget::{Grid, column, container, responsive, row, text}, -}; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use crate::{ - icons::Icon, - operation, - widgets::{ - action_row::{ActionRow, State as ActionRowState}, - artwork_card::{ArtworkCard, CardAction}, - drop_target::DropTarget, - info_card::{InfoCard, Kind as InfoCardKind}, - list_row::ListRow, - picker_row::PickerRow, - selector_row::SelectorRow, - spacing, - status_bar::{BottleStatus, StatusBar}, - text::TextExt as _, - text_row::TextRow, - }, -}; - -const BOTTLE_LIST_MAX_WIDTH: f32 = 720.0; -const BOTTLE_TRACK_MIN_WIDTH: f32 = 300.0; - -const PURPOSES: [&str; 4] = ["Gaming", "Software", "Gaming (ULWGL)", "Custom"]; -const ARCHITECTURES: [&str; 2] = ["Win64", "Win32"]; - -#[derive(Clone, PartialEq)] -pub struct RunnerOption { - id: Uuid, - label: String, -} - -impl std::fmt::Display for RunnerOption { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.label) - } -} - -pub fn bottle_events( - manager: &BottleManager, -) -> impl iced::futures::Stream> + Send + 'static + use<> { - manager.watch() -} - -pub fn bottle_state_events( - bottle: &Bottle, -) -> impl iced::futures::Stream> + Send + 'static + use<> { - bottle.watch() -} - -#[derive(Clone)] -pub enum Message { - CreateBottle, - BottleCreation(operation::Event), - BottleNameChanged(String), - RunnerSelected(RunnerOption), - LaunchProgram { bottle: Bottle, program_id: Uuid }, - ProgramLaunched(Result>), - Noop, -} - -pub enum Output { - Created, -} - -pub struct State { - manager: BottleManager, - bottle_name: String, - runners: Vec, - selected_runner: Option, - creation_generation: u64, - creation_cancellation: Option, - program_launches: usize, - last_error: Option, -} - -impl State { - pub fn new(manager: BottleManager, addons: &Addons) -> Self { - let runners = addons - .components() - .iter() - .filter(|entry| entry.slot() == Slot::Runner) - .map(|entry| RunnerOption { - id: entry.id(), - label: format!("{} {}", entry.name(), entry.version()), - }) - .collect::>(); - let selected_runner = runners.first().cloned(); - Self { - manager, - bottle_name: "Gaming paradise".into(), - runners, - selected_runner, - creation_generation: 0, - creation_cancellation: None, - program_launches: 0, - last_error: None, - } - } - - pub(super) fn manager(&self) -> &BottleManager { - &self.manager - } - - pub fn reset_creation(&mut self) { - self.last_error = None; - } - - pub fn cancel_creation(&self) { - if let Some(cancellation) = &self.creation_cancellation { - cancellation.cancel(); - } - } - - pub fn has_active_operation(&self) -> bool { - self.creation_cancellation.is_some() || self.program_launches > 0 - } - - pub(super) fn is_creating(&self) -> bool { - self.creation_cancellation.is_some() - } - - pub fn update(&mut self, message: Message) -> (Task, Option) { - let mut output = None; - match message { - Message::CreateBottle => { - if self.creation_cancellation.is_none() - && let Some(runner) = self.selected_runner.clone() - { - let name = self.bottle_name.clone(); - let manager = self.manager.clone(); - let operation = manager.create(name, Storage::Standard, runner.id); - self.creation_generation = self.creation_generation.wrapping_add(1); - let generation = self.creation_generation; - let (cancellation, task) = operation::run(operation, generation); - self.creation_cancellation = Some(cancellation); - - self.last_error = None; - - return (task.map(Message::BottleCreation), None); - } - } - Message::BottleCreation(operation::Event::Finished { key, outcome }) - if key == self.creation_generation => - { - self.creation_cancellation = None; - match outcome { - operation::Outcome::Succeeded(_) => { - self.last_error = None; - output = Some(Output::Created); - } - operation::Outcome::Cancelled => { - self.last_error = Some("Bottle creation was cancelled.".into()); - } - operation::Outcome::Failed(error) => { - self.last_error = Some(error.to_string()); - } - } - } - Message::BottleNameChanged(name) => self.bottle_name = name, - Message::RunnerSelected(runner) => self.selected_runner = Some(runner), - Message::LaunchProgram { bottle, program_id } => { - self.program_launches += 1; - return ( - Task::perform( - async move { bottle.launch_program(program_id).await.map_err(Arc::new) }, - Message::ProgramLaunched, - ), - None, - ); - } - Message::ProgramLaunched(Err(error)) => { - self.program_launches = self.program_launches.saturating_sub(1); - self.last_error = Some(error.to_string()); - } - Message::ProgramLaunched(Ok(_)) => { - self.program_launches = self.program_launches.saturating_sub(1); - self.last_error = None; - } - Message::BottleCreation(_) | Message::Noop => {} - } - - (Task::none(), output) - } - - pub fn rows_view<'a, Msg: 'static + Clone>( - &self, - bottle_states: &'a [Arc], - selected_id: Option, - on_select: impl Fn(Uuid) -> Msg + 'a, - ) -> Element<'a, Msg> { - responsive(move |size| { - let columns = usize::from(size.width >= BOTTLE_TRACK_MIN_WIDTH * 2.0 + spacing::SM) + 1; - let rows = bottle_states.iter().map(|state| { - let row: ListRow<'_, Msg> = - ActionRow::new(state.name(), ActionRowState::Ready(on_select(state.id()))) - .description(state.runner().name()) - .icon(Icon::Bottles) - .into(); - - row.selected(selected_id == Some(state.id())).into() - }); - let grid = Grid::with_children(rows) - .columns(columns) - .spacing(spacing::SM) - .height(Length::Shrink); - - container( - container(grid) - .width(Length::Fill) - .max_width(BOTTLE_LIST_MAX_WIDTH), - ) - .center_x(Length::Fill) - .into() - }) - .height(Length::Shrink) - .into() - } - - pub fn creation_view(&self) -> Element<'_, Message> { - let creating = self.is_creating(); - let name = TextRow::new("Bottle Name", &self.bottle_name).icon(Icon::Person); - let name = if creating { - name - } else { - name.on_input(Message::BottleNameChanged) - }; - let runner = SelectorRow::new("Runner", &self.runners, self.selected_runner.as_ref()) - .icon(Icon::Run); - let runner = if creating { - runner - } else { - runner.on_selected(Message::RunnerSelected) - }; - let content = column![ - name, - runner, - SelectorRow::new("Purpose", &PURPOSES, Some(&PURPOSES[0])), - SelectorRow::new("Architecture", &ARCHITECTURES, Some(&ARCHITECTURES[0])) - .icon(Icon::Chip), - PickerRow::new("Use Recipe").description("Choose the location"), - ] - .spacing(12); - - if let Some(error) = &self.last_error { - column![ - InfoCard::new(InfoCardKind::Error, "Could not create bottle", error) - .width(Length::Fill), - content, - ] - .spacing(12) - .into() - } else { - content.into() - } - } - - pub fn bottle_status<'a>(&self, state: &'a BottleState) -> Element<'a, Message> { - StatusBar::new("Win64", state.runner().name(), BottleStatus::Stopped).into() - } - - pub fn program_grid<'a>(&self, bottle: Bottle, state: &'a BottleState) -> Element<'a, Message> { - let programs = state.programs().collect::>(); - let items = std::iter::once(new_program_target().into()).chain( - programs - .iter() - .copied() - .map(|program| program_card(bottle.clone(), program)), - ); - - Grid::with_children(items) - .fluid(400.0) - .spacing(spacing::MD) - .height(Length::Shrink) - .into() - } -} - -fn new_program_target<'a>() -> DropTarget<'a, Message> { - const ICON_CONTAINER_SIZE: f32 = 44.0; - - let icon = container(Icon::Plus.view().width(16).height(16)) - .width(ICON_CONTAINER_SIZE) - .height(ICON_CONTAINER_SIZE) - .align_x(Center) - .align_y(Center) - .style(|theme: &Theme| { - container::Style::default() - .background(theme.extended_palette().background.weak.color) - .border(iced::Border::default().rounded(ICON_CONTAINER_SIZE / 2.0)) - }); - let labels = column![ - text("New Program").size(17).medium(), - text("Install or add a program.").size(14), - ] - .spacing(spacing::XS); - - let content = container(row![icon, labels].spacing(16).align_y(Center)).center_x(Length::Fill); - - DropTarget::new(content, Message::Noop) - .width(Length::Fill) - .padding([72.0, spacing::LG]) -} - -fn program_card(bottle: Bottle, program: &bottles_core::Program) -> Element<'_, Message> { - ArtworkCard::new(program.name(), "Installed program") - .menu(CardAction::new("More actions", Icon::EllipsisVertical)) - .primary( - CardAction::new("Play", Icon::Play).on_press(Message::LaunchProgram { - bottle, - program_id: program.id(), - }), - ) - .into() -} diff --git a/src/classic/bottles_view.rs b/src/classic/bottles_view.rs new file mode 100644 index 0000000..1266b18 --- /dev/null +++ b/src/classic/bottles_view.rs @@ -0,0 +1,164 @@ +//! Classic presentation for bottles, creation, and installed programs. + +use bottles_core::{Bottle, BottleState}; +use iced::{ + Center, Element, Length, Theme, + widget::{Grid, column, container, responsive, row, text}, +}; +use uuid::Uuid; + +use crate::{ + Icon, + domain::BottleSnapshot, + features::bottles::{Message, State}, + widget::{ + ActionRow, ActionTile, ArtworkCard, CardAction, InfoCard, InfoCardKind, LogPanel, + PickerRow, SelectorRow, TextExt as _, TextRow, spacing, + }, +}; + +const BOTTLE_LIST_MAX_WIDTH: f32 = 720.0; +const BOTTLE_TRACK_MIN_WIDTH: f32 = 300.0; +const PURPOSES: [&str; 4] = ["Gaming", "Software", "Gaming (ULWGL)", "Custom"]; +const ARCHITECTURES: [&str; 2] = ["Win64", "Win32"]; + +pub fn rows<'a, Msg: 'static + Clone>( + bottles: &'a [BottleSnapshot], + selected_id: Option, + on_select: impl Fn(Uuid) -> Msg + 'a, +) -> Element<'a, Msg> { + responsive(move |size| { + let columns = usize::from(size.width >= BOTTLE_TRACK_MIN_WIDTH * 2.0 + spacing::SM) + 1; + let rows = bottles.iter().map(|snapshot| { + let state = snapshot.state.as_ref(); + ActionRow::new(state.name(), on_select(state.id())) + .description(state.runner().name()) + .icon(Icon::Bottles) + .selected(selected_id == Some(state.id())) + .into() + }); + let grid = Grid::with_children(rows) + .columns(columns) + .spacing(spacing::SM) + .height(Length::Shrink); + + container( + container(grid) + .width(Length::Fill) + .max_width(BOTTLE_LIST_MAX_WIDTH), + ) + .center_x(Length::Fill) + .into() + }) + .height(Length::Shrink) + .into() +} + +pub fn creation(state: &State) -> Element<'_, Message> { + let creating = state.is_creating(); + let name = TextRow::new("Bottle Name", state.bottle_name()).icon(Icon::Person); + let name = if creating { + name + } else { + name.on_input(Message::BottleNameChanged) + }; + let runner = + SelectorRow::new("Runner", state.runners(), state.selected_runner()).icon(Icon::Run); + let runner = if creating { + runner + } else { + runner.on_selected(Message::RunnerSelected) + }; + let content = column![ + name, + runner, + SelectorRow::new("Purpose", &PURPOSES, Some(&PURPOSES[0])), + SelectorRow::new("Architecture", &ARCHITECTURES, Some(&ARCHITECTURES[0])).icon(Icon::Chip), + PickerRow::new("Use Recipe").description("Choose the location"), + ] + .spacing(12); + + if let Some(error) = state.creation_error() { + column![ + InfoCard::new(InfoCardKind::Error, "Could not create bottle", error) + .width(Length::Fill), + content, + ] + .spacing(12) + .into() + } else { + content.into() + } +} + +pub fn status(state: &BottleState) -> Element<'_, Message> { + LogPanel::new("Win64", state.runner().name(), "Stopped", Icon::Power).into() +} + +pub fn programs<'a>( + feature: &'a State, + bottle: Bottle, + bottle_state: &'a BottleState, +) -> Element<'a, Message> { + let programs = bottle_state.programs().collect::>(); + let items = std::iter::once(new_program_target().into()).chain( + programs + .iter() + .copied() + .map(|program| program_card(bottle.clone(), program)), + ); + + let grid = Grid::with_children(items) + .fluid(400.0) + .spacing(spacing::MD) + .height(Length::Shrink); + + if let Some(error) = feature.launch_error() { + column![ + InfoCard::new(InfoCardKind::Error, "Program launch failed", error).width(Length::Fill), + grid, + ] + .spacing(12) + .into() + } else { + grid.into() + } +} + +fn new_program_target<'a>() -> ActionTile<'a, Message> { + const ICON_CONTAINER_SIZE: f32 = 44.0; + + let icon = container(Icon::Plus.view().width(16).height(16)) + .width(ICON_CONTAINER_SIZE) + .height(ICON_CONTAINER_SIZE) + .align_x(Center) + .align_y(Center) + .style(|theme: &Theme| { + container::Style::default() + .background(crate::theme::colors(theme).surface) + .border(iced::Border::default().rounded(ICON_CONTAINER_SIZE / 2.0)) + }); + let labels = column![ + text("New Program").size(17).medium(), + text("Install or add a program.").size(14), + ] + .spacing(spacing::XS); + + let content = container(row![icon, labels].spacing(16).align_y(Center)).center_x(Length::Fill); + + ActionTile::disabled(content) + .width(Length::Fill) + .padding([72.0, spacing::LG]) +} + +fn program_card(bottle: Bottle, program: &bottles_core::Program) -> Element<'_, Message> { + ArtworkCard::new(program.name(), "Installed program") + .menu(CardAction::new("More actions", Icon::EllipsisVertical)) + .primary( + CardAction::new("Play", Icon::Play).on_press(Message::LaunchProgram { + bottle, + program_id: program.id(), + }), + ) + .into() +} diff --git a/src/classic/layout.rs b/src/classic/layout.rs index bfd9a48..71adc15 100644 --- a/src/classic/layout.rs +++ b/src/classic/layout.rs @@ -7,19 +7,32 @@ use iced::{ animation::{Animation, Easing}, gradient, time::Instant, + touch, widget::responsive, window, }; use crate::{ + chrome::{self, WINDOW_CONTROL_AT_START}, theme, - ui::chrome::WINDOW_CONTROL_AT_START, - widgets::{event_cursor, header_bar::HeaderBar, spacing}, + widget::{HeaderBar, spacing}, }; const BREAKPOINT: f32 = 900.0; const COMPACT_MAX_WIDTH: f32 = 420.0; +fn event_cursor(event: &Event, cursor: mouse::Cursor) -> mouse::Cursor { + match event { + Event::Touch( + touch::Event::FingerPressed { position, .. } + | touch::Event::FingerMoved { position, .. } + | touch::Event::FingerLifted { position, .. } + | touch::Event::FingerLost { position, .. }, + ) => mouse::Cursor::Available(*position), + _ => cursor, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct PaneContext { standalone: bool, @@ -31,12 +44,12 @@ impl PaneContext { self.standalone } - pub(super) fn header<'a, Message>(self, on_drag: Message) -> HeaderBar<'a, Message> { - if self.owns_window_control { - HeaderBar::new(on_drag) - } else { - HeaderBar::without_window_control(on_drag) - } + pub(super) fn header<'a, Message: Clone + 'a>( + self, + on_drag: Message, + build: impl FnOnce(HeaderBar<'a, Message>) -> HeaderBar<'a, Message>, + ) -> Element<'a, Message> { + chrome::header(on_drag, self.owns_window_control, build) } } @@ -400,7 +413,7 @@ impl Widget for AnimatedSplit<'_, Messa && progress > 0.0 && let Some(bounds) = child_layout.bounds().intersection(&clip) { - let window = theme::window_color(theme); + let window = theme::colors(theme).window; renderer.with_layer(bounds, |renderer| { renderer.fill_quad( renderer::Quad { diff --git a/src/classic/library_view.rs b/src/classic/library_view.rs new file mode 100644 index 0000000..3fe2660 --- /dev/null +++ b/src/classic/library_view.rs @@ -0,0 +1,91 @@ +//! Classic presentation for the shared library workflow. + +use bottles_core::{SearchEntry, SearchSource}; +use iced::{ + Element, Fill, Length, + widget::{Grid, column, container}, +}; + +use crate::{ + Icon, + features::library::{Message, State, Status}, + widget::{ArtworkCard, CardAction, InfoCard, InfoCardKind, Search, spacing}, +}; + +const NARROW_CONTENT_MAX_WIDTH: f32 = 500.0; + +pub fn view(state: &State) -> Element<'_, Message> { + let search = centered_narrow(Search::new( + "Search library", + state.query(), + Message::QueryChanged, + )); + + let notice = match state.status() { + Status::Idle => Some(( + "No active profile", + "Sign in to a profile to see its library.", + )), + Status::Loading => Some(( + "Loading library", + "Loading games from this profile's linked storefronts.", + )), + Status::Loaded => None, + }; + if let Some((title, body)) = notice { + return column![ + search, + centered_narrow(InfoCard::new(InfoCardKind::Hint, title, body).width(Fill)) + ] + .spacing(12) + .into(); + } + + let mut content = column![search].spacing(12); + if let Some(error) = state.last_error() { + content = content.push(centered_narrow( + InfoCard::new(InfoCardKind::Error, "Program launch failed", error).width(Fill), + )); + } + if state.entries().is_empty() { + return content + .push(centered_narrow( + InfoCard::new( + InfoCardKind::Hint, + "Nothing here yet", + "Registered programs and linked storefront games will show up here.", + ) + .width(Fill), + )) + .into(); + } + + let rows = Grid::with_children(state.entries().iter().map(entry_card)) + .fluid(400.0) + .spacing(spacing::MD) + .height(Length::Shrink); + + content.push(rows).into() +} + +fn centered_narrow<'a>(content: impl Into>) -> Element<'a, Message> { + container( + container(content) + .width(Fill) + .max_width(NARROW_CONTENT_MAX_WIDTH), + ) + .center_x(Fill) + .into() +} + +fn entry_card(entry: &SearchEntry) -> Element<'_, Message> { + ArtworkCard::new(entry.title(), entry.source_name()) + .menu(CardAction::new("More actions", Icon::EllipsisVertical)) + .primary( + CardAction::new("Play", Icon::Play).on_press_maybe(match entry.source() { + SearchSource::Installed(item) => Some(Message::Launch(item.clone())), + _ => None, + }), + ) + .into() +} diff --git a/src/classic/mod.rs b/src/classic/mod.rs index 94d8718..ddc9c33 100644 --- a/src/classic/mod.rs +++ b/src/classic/mod.rs @@ -3,36 +3,27 @@ //! A future Next experience is a sibling of this module and cannot access these //! private workflow modules. -use std::sync::Arc; - -mod accounts; -mod bottles; +mod accounts_view; +mod bottles_view; mod layout; -mod library; -mod profiles; -mod settings; +mod library_view; +mod profiles_view; +mod settings_view; #[cfg(feature = "fvs")] -mod snapshots; +mod snapshots_view; use crate::{ - Experience, - icons::Icon, + Icon, + domain::{BottleSnapshot, DomainSnapshots}, + features::{self, bottles, profiles}, theme, - ui::chrome, - widgets::{ - action_row::{ActionRow, State as ActionRowState}, - button::{Button, ButtonKind}, - dialog::Dialog, - row_group::RowGroup, - tabs::{Tab, Tabs}, - text_row::TextRow, - title::Title, + widget::{ + ActionRow, Button, ButtonKind, Dialog, InfoCard, InfoCardKind, RowGroup, Tab, Tabs, + TextRow, Title, }, }; -use bottles_core::{Bottle, BottleManager, BottleState, Bottles, Profiles, ProfilesConfig}; use iced::{ - Element, Fill, Subscription, Task, - keyboard::{self, key}, + Element, Fill, Task, widget::{column, container, keyed_column, scrollable}, }; use layout::{PaneContext, Side, navigation_split, side_panel}; @@ -62,91 +53,34 @@ pub enum Route { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Panel { +enum OverlayKind { NewBottle, Profiles, } -struct ReadModel { - bottles: Vec, - bottle_states: Vec>, - profiles: Arc, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Overlay { + kind: OverlayKind, + open: bool, } -impl ReadModel { - fn new(bottle_manager: &BottleManager, profiles: &Profiles) -> Self { - let bottles = bottle_manager.list(); - let bottle_states = bottles - .iter() - .filter_map(|bottle| bottle.state().ok()) - .collect(); - - Self { - bottles, - bottle_states, - profiles: profiles.snapshot(), - } - } - - fn set_bottles(&mut self, bottles: Vec) -> bool { - if self.bottles.len() == bottles.len() - && self - .bottles - .iter() - .zip(&bottles) - .all(|(current, next)| current.id() == next.id()) - { - return false; - } - self.bottle_states = bottles - .iter() - .filter_map(|bottle| bottle.state().ok()) - .collect(); - self.bottles = bottles; - true - } - - fn set_bottle_state(&mut self, state: Arc) -> bool { - if let Some(current) = self - .bottle_states - .iter_mut() - .find(|current| current.id() == state.id()) - { - if current.as_ref() == state.as_ref() { - return false; - } - *current = state; - } else { - self.bottle_states.push(state); - } - true +impl Overlay { + fn open(kind: OverlayKind) -> Self { + Self { kind, open: true } } - fn bottle(&self, id: Uuid) -> Option { - self.bottles - .iter() - .find(|bottle| bottle.id() == id) - .cloned() + fn is_open(self, kind: OverlayKind) -> bool { + self.open && self.kind == kind } - fn bottle_state(&self, id: Uuid) -> Option<&Arc> { - self.bottle_states.iter().find(|state| state.id() == id) + fn close(&mut self) { + self.open = false; } } pub struct State { route: Route, - panel: Panel, - panel_open: bool, - read_model: ReadModel, - bottles: bottles::State, - #[cfg(feature = "fvs")] - snapshots: snapshots::State, - profiles: profiles::State, - library: library::State, - accounts: accounts::State, - settings: settings::State, - draining: bool, + overlay: Option, } #[derive(Clone)] @@ -157,85 +91,19 @@ pub enum Message { Back, AddBottle, CancelBottle, + ToggleProfileSettings, OpenMenu, TogglePower, - #[allow(dead_code)] // Constructed when the disabled Next settings row is enabled. - RequestExperience(Experience), - Window(chrome::Action), - MoveFocus(bool), - Bottles(bottles::Message), - Settings(settings::Message), - #[cfg(feature = "fvs")] - Snapshots(snapshots::Message), - Library(library::Message), - Profiles(profiles::Message), - Accounts(accounts::Message), - BottleListChanged(Vec), - BottleStateChanged(Arc), - ProfilesChanged(Arc), -} - -impl Message { - fn allowed_while_draining(&self) -> bool { - match self { - Self::Bottles( - bottles::Message::BottleCreation(_) | bottles::Message::ProgramLaunched(_), - ) - | Self::Library( - library::Message::Entry { .. } - | library::Message::Loaded(_) - | library::Message::Launched(_), - ) - | Self::Profiles( - profiles::Message::ProfileUpdated(_) | profiles::Message::ProfileDeleted(_), - ) - | Self::Accounts( - accounts::Message::ProfileUpdated(_) | accounts::Message::LinkFinished(_), - ) - | Self::BottleListChanged(_) - | Self::BottleStateChanged(_) - | Self::ProfilesChanged(_) => true, - #[cfg(target_os = "linux")] - Self::Settings(settings::Message::WrapperUpdated { .. }) => true, - #[cfg(feature = "fvs")] - Self::Snapshots(snapshots::Message::Loaded { .. }) => true, - _ => false, - } - } + DragWindow, + Feature(features::Message), } impl State { - pub fn new(core: &Bottles) -> (Self, Task) { - let bottle_manager = core.bottles().clone(); - let profiles_manager = core.profiles().clone(); - let read_model = ReadModel::new(&bottle_manager, &profiles_manager); - let profiles = profiles::State::new(profiles_manager, read_model.profiles.selected()); - let mut library = library::State::new(core.library().clone()); - let library_boot = library.reload().map(Message::Library); - - let state = Self { + pub fn new() -> Self { + Self { route: Route::Bottles, - panel: Panel::NewBottle, - panel_open: false, - read_model, - bottles: bottles::State::new(bottle_manager, core.addons()), - #[cfg(feature = "fvs")] - snapshots: snapshots::State::new(), - profiles, - library, - accounts: accounts::State::default(), - settings: settings::State::new(), - draining: false, - }; - - (state, library_boot) - } - - fn reload_library(&mut self) -> Task { - if self.draining { - return Task::none(); + overlay: None, } - self.library.reload().map(Message::Library) } fn primary_tab(&self) -> PrimaryTab { @@ -245,54 +113,38 @@ impl State { } } - pub fn experience(&self) -> Experience { - Experience::Classic - } - - pub fn has_active_operations(&self) -> bool { - let active = self.bottles.has_active_operation() - || self.profiles.has_active_operation() - || self.accounts.has_active_operation() - || self.settings.has_active_operation() - || self.library.has_active_operation(); - #[cfg(feature = "fvs")] - let active = active || self.snapshots.has_active_operation(); - active - } - - pub fn cancel_active_operations(&mut self) { - self.draining = true; - self.profiles.dismiss_dialog(); - self.bottles.cancel_creation(); - self.accounts.cancel_active_operation(); - self.library.cancel_active_operations(); + pub fn resume_after_failed_switch( + &mut self, + features: &mut features::State, + _domain: &DomainSnapshots, + ) -> Task { + let library = features.reload_library(); #[cfg(feature = "fvs")] - self.snapshots.cancel_active_operations(); - } - - pub fn resume_after_failed_switch(&mut self) -> Task { - self.draining = false; - let library = self.reload_library(); - #[cfg(feature = "fvs")] - if let Some(bottle) = self.selected_bottle_handle() { - let snapshots = self.snapshots.load(bottle).map(Message::Snapshots); + if let Some(snapshot) = self.selected_bottle(_domain) { + let snapshots = features + .snapshots + .load(snapshot.bottle.clone()) + .map(features::Message::Snapshots); return Task::batch([library, snapshots]); } library } - pub fn update(&mut self, message: Message) -> Task { - if self.draining && !message.allowed_while_draining() { - return Task::none(); - } + pub fn update( + &mut self, + features: &mut features::State, + message: Message, + domain: &DomainSnapshots, + ) -> Task { match message { Message::PrimaryTabSelected(tab) => { + self.leave_bottle_route(features); self.route = match tab { PrimaryTab::Bottles => Route::Bottles, PrimaryTab::Library => Route::Library, }; if tab == PrimaryTab::Library { - return self.reload_library(); + return features.reload_library(); } } Message::DetailTabSelected(tab) => { @@ -301,245 +153,151 @@ impl State { } } Message::BottleSelected(id) => { + if domain.bottle(id).is_none() { + return Task::none(); + } self.route = Route::Bottle { id, tab: DetailTab::Programs, }; #[cfg(feature = "fvs")] - self.snapshots.clear(); - + features.snapshots.clear(); #[cfg(feature = "fvs")] - if let Some(bottle) = self.selected_bottle_handle() { - return self.snapshots.load(bottle).map(Message::Snapshots); + if let Some(snapshot) = self.selected_bottle(domain) { + return features + .snapshots + .load(snapshot.bottle.clone()) + .map(features::Message::Snapshots); } } Message::Back => { - self.profiles.dismiss_dialog(); - self.accounts.cancel_active_operation(); - if self.panel_open && self.panel == Panel::Profiles { - self.panel_open = false; - } else { + features.profiles.dismiss_dialogs(); + if !self.close_overlay(OverlayKind::Profiles) { + self.leave_bottle_route(features); self.route = Route::Bottles; } } Message::AddBottle => { - self.panel = Panel::NewBottle; - self.panel_open = true; - self.bottles.reset_creation(); + features.profiles.dismiss_dialogs(); + self.overlay = Some(Overlay::open(OverlayKind::NewBottle)); + features.bottles.reset_creation(); } Message::CancelBottle => { - self.bottles.cancel_creation(); - self.panel_open = false; + features.bottles.cancel_creation(); + self.close_overlay(OverlayKind::NewBottle); } - Message::Window(action) => { - return action.task().unwrap_or_else(Task::none); - } - Message::MoveFocus(previous) => { - return if previous { - iced::widget::operation::focus_previous() + Message::ToggleProfileSettings => { + if self.close_overlay(OverlayKind::Profiles) { + features.profiles.dismiss_dialogs(); } else { - iced::widget::operation::focus_next() - }; - } - Message::Bottles(message) => { - let (task, output) = self.bottles.update(message); - let task = task.map(Message::Bottles); - return match output { - Some(bottles::Output::Created) => { - if self.panel_open && self.panel == Panel::NewBottle { - self.panel_open = false; - } - task - } - None => task, - }; - } - #[cfg(feature = "fvs")] - Message::Snapshots(message) => { - return self.snapshots.update(message).map(Message::Snapshots); - } - Message::Settings(message) => { - let selected_id = match self.route { - Route::Bottle { id, .. } => Some(id), - _ => None, - }; - #[cfg(target_os = "linux")] - let bottle = selected_id.and_then(|id| self.read_model.bottle(id)); - let bottle_state = selected_id.and_then(|id| self.read_model.bottle_state(id)); - let ctx = settings::Context { - #[cfg(target_os = "linux")] - bottle, - bottle_state, - }; - return self.settings.update(message, &ctx).map(Message::Settings); - } - Message::Profiles(message) => { - if matches!(message, profiles::Message::OpenCreate) - && !self.profiles.has_active_operation() - { - self.accounts.cancel_active_operation(); + self.overlay = Some(Overlay::open(OverlayKind::Profiles)); } - let (task, output) = self.profiles.update(message); - let task = task.map(Message::Profiles); - return match output { - Some(profiles::Output::ToggleSettings) => { - if self.panel_open && self.panel == Panel::Profiles { - self.panel_open = false; - self.profiles.dismiss_dialog(); - self.accounts.cancel_active_operation(); - } else { - self.panel = Panel::Profiles; - self.panel_open = true; - } - task - } - None => task, - }; - } - Message::Accounts(message) => { - let ctx = accounts::Context { - active_profile: self.read_model.profiles.selected(), - profiles: self.profiles.manager(), - }; - return self.accounts.update(message, &ctx).map(Message::Accounts); - } - Message::Library(message) => { - let (task, output) = self.library.update(message); - return if matches!(output, Some(library::Output::Reload)) { - Task::batch([task.map(Message::Library), self.reload_library()]) - } else { - task.map(Message::Library) - }; } - Message::BottleListChanged(bottles) => { - return if self.read_model.set_bottles(bottles) { - self.reload_library() - } else { - Task::none() - }; - } - Message::BottleStateChanged(state) => { - return if self.read_model.set_bottle_state(state) { - self.reload_library() - } else { - Task::none() - }; - } - Message::ProfilesChanged(snapshot) => { - if self.read_model.profiles == snapshot { - return Task::none(); - } - let selected_changed = { - let current = self.read_model.profiles.selected(); - let next = snapshot.selected(); - current.id() != next.id() || current.name() != next.name() - }; - if selected_changed { - self.profiles.sync_selected(snapshot.selected()); - } - self.read_model.profiles = snapshot; - return self.reload_library(); - } - Message::OpenMenu | Message::TogglePower | Message::RequestExperience(_) => {} + Message::OpenMenu | Message::TogglePower | Message::DragWindow => {} + Message::Feature(_) => unreachable!("feature messages are handled by the app shell"), } Task::none() } - fn selected_bottle_handle(&self) -> Option { - let Route::Bottle { id, .. } = self.route else { - return None; + pub fn bottles_changed(&mut self, features: &mut features::State, domain: &DomainSnapshots) { + let selected_exists = match self.route { + Route::Bottle { id, .. } => domain.bottle(id).is_some(), + Route::Bottles | Route::Library => true, }; + if reconcile_route(&mut self.route, selected_exists) { + self.leave_bottle_route(features); + } + } + + pub fn handle_feature_output(&mut self, output: features::Output) { + match output { + features::Output::BottleCreated => { + self.close_overlay(OverlayKind::NewBottle); + } + } + } - self.read_model.bottle(id) + fn leave_bottle_route(&mut self, _features: &mut features::State) { + #[cfg(feature = "fvs")] + _features.snapshots.clear(); } - fn selected_bottle_state(&self) -> Option<&Arc> { + fn close_overlay(&mut self, kind: OverlayKind) -> bool { + let Some(overlay) = self + .overlay + .as_mut() + .filter(|overlay| overlay.is_open(kind)) + else { + return false; + }; + overlay.close(); + true + } + + fn selected_bottle<'a>(&self, domain: &'a DomainSnapshots) -> Option<&'a BottleSnapshot> { let Route::Bottle { id, .. } = self.route else { return None; }; - - self.read_model.bottle_state(id) + domain.bottle(id) } - pub fn subscription(&self) -> Subscription { - let keys = keyboard::listen().filter_map(|event| match event { - keyboard::Event::KeyPressed { - key: keyboard::Key::Named(key::Named::Tab), - modifiers, - repeat: false, - .. - } => Some(Message::MoveFocus(modifiers.shift())), - _ => None, + pub fn view<'a>( + &'a self, + features: &'a features::State, + domain: &'a DomainSnapshots, + ) -> Element<'a, Message> { + let overlay = self.overlay.unwrap_or(Overlay { + kind: OverlayKind::NewBottle, + open: false, }); - - let mut subscriptions = vec![keys]; - - subscriptions.push( - Subscription::run_with(self.bottles.manager().clone(), bottles::bottle_events) - .map(Message::BottleListChanged), - ); - - for bottle in &self.read_model.bottles { - subscriptions.push( - Subscription::run_with(bottle.clone(), bottles::bottle_state_events) - .map(Message::BottleStateChanged), - ); - } - - subscriptions.push( - Subscription::run_with(self.profiles.manager().clone(), profiles::profile_events) - .map(Message::ProfilesChanged), - ); - - Subscription::batch(subscriptions) - } - - pub fn view(&self) -> Element<'_, Message> { let split = side_panel( - match self.panel { - Panel::Profiles => Side::End, - Panel::NewBottle => Side::Start, + match overlay.kind { + OverlayKind::Profiles => Side::End, + OverlayKind::NewBottle => Side::Start, }, - self.panel_open, + overlay.open, |base_context| { navigation_split( base_context, matches!(self.route, Route::Bottle { .. }), - |context| self.primary_page(context), - |context| self.detail_page(context), + |context| self.primary_page(context, features, domain), + |context| self.detail_page(context, features, domain), ) }, - |context| { - if self.panel == Panel::Profiles { - self.profile_settings_page(context) - } else { - self.new_bottle_page(context) - } + move |context| match overlay.kind { + OverlayKind::Profiles => self.profile_settings_page(context, features, domain), + OverlayKind::NewBottle => self.new_bottle_page(context, features), }, ); container(split).width(Fill).height(Fill).into() } - pub(crate) fn dialog(&self) -> Option> { - if !self.panel_open || self.panel != Panel::Profiles { + pub(crate) fn dialog<'a>( + &'a self, + features: &'a features::State, + ) -> Option> { + if !self + .overlay + .is_some_and(|overlay| overlay.is_open(OverlayKind::Profiles)) + { return None; } - let profile = self - .profiles - .dialog() - .map(|dialog| dialog.map(Message::Profiles)); - let account = self - .accounts - .dialog() - .map(|dialog| dialog.map(Message::Accounts)); - - exclusive_dialog(profile, account) + profiles_view::dialog(&features.profiles) + .map(|dialog| dialog.map(profiles_message)) + .or_else(|| { + accounts_view::dialog(&features.profiles).map(|dialog| dialog.map(profiles_message)) + }) } - fn primary_page(&self, context: PaneContext) -> Element<'_, Message> { + fn primary_page<'a>( + &'a self, + context: PaneContext, + features: &'a features::State, + domain: &'a DomainSnapshots, + ) -> Element<'a, Message> { let tabs = Tabs::new( [ Tab::new(PrimaryTab::Bottles, "Bottles"), @@ -548,25 +306,22 @@ impl State { Some(self.primary_tab()), Message::PrimaryTabSelected, ); - let header = context - .header(Message::Window(chrome::Action::Drag)) - .start(header_button("Add bottle", Icon::Plus, Message::AddBottle)) - .middle(tabs) - .end( - self.profiles - .view_switcher(&self.read_model.profiles) - .map(Message::Profiles), - ); + let header = context.header(Message::DragWindow, |header| { + header + .start(header_button("Add bottle", Icon::Plus, Message::AddBottle)) + .middle(tabs) + .end(profiles_view::switcher(domain.profiles())) + }); let content: Element<'_, Message> = match self.primary_tab() { - PrimaryTab::Bottles => self.bottles.rows_view( - &self.read_model.bottle_states, + PrimaryTab::Bottles => bottles_view::rows( + domain.bottles(), match self.route { Route::Bottle { id, .. } => Some(id), _ => None, }, Message::BottleSelected, ), - PrimaryTab::Library => self.library.view().map(Message::Library), + PrimaryTab::Library => library_view::view(&features.library).map(library_message), }; column![header, scroll_panel(content)] @@ -575,77 +330,69 @@ impl State { .into() } - fn profile_settings_page(&self, context: PaneContext) -> Element<'_, Message> { - let header = context - .header(Message::Window(chrome::Action::Drag)) - .start(header_button("Cancel", Icon::Arrow, Message::Back)) - .middle( - container( - Title::new("Profile Settings") - .subtitle("Manage your profiles and linked accounts."), + fn profile_settings_page<'a>( + &'a self, + context: PaneContext, + features: &'a features::State, + domain: &'a DomainSnapshots, + ) -> Element<'a, Message> { + let header = context.header(Message::DragWindow, |header| { + header + .start(header_button("Cancel", Icon::Arrow, Message::Back)) + .middle( + container( + Title::new("Profile Settings") + .subtitle("Manage your profiles and linked accounts."), + ) + .padding(iced::padding::bottom(12)), ) - .padding(iced::padding::bottom(12)), - ) - .end(header_button( - "New profile", - Icon::Plus, - Message::Profiles(profiles::Message::OpenCreate), - )); + .end(header_button( + "New profile", + Icon::Plus, + profiles_message(profiles::Message::OpenCreate), + )) + }); let content: Element<'_, Message> = { - let active = self.read_model.profiles.selected(); - let accounts_ctx = accounts::Context { - active_profile: active, - profiles: self.profiles.manager(), - }; - let links = self - .accounts - .view_links(&accounts_ctx) - .map(Message::Accounts); + let active = domain.profiles().selected(); + let links = accounts_view::links(&features.profiles, active).map(profiles_message); column![ RowGroup::new() .title("Experience") .row( - ActionRow::new("Classic", ActionRowState::Disabled) + ActionRow::new("Classic", None) .description("Current experience") .icon(Icon::Checkmark), ) .row( - ActionRow::new("Next", ActionRowState::Disabled) + ActionRow::new("Next", None) .description("Not available yet") .icon(Icon::Wand), ), RowGroup::new() .title("Profile") .row( - TextRow::new("Profile name", self.profiles.name_draft()) + TextRow::new("Profile name", features.profiles.name_draft()) .icon(Icon::Person) - .on_input(|name| { - Message::Profiles(profiles::Message::NameChanged(name)) - }) - .on_submit(Message::Profiles(profiles::Message::RenameSubmit,)), + .on_input(|name| profiles_message(profiles::Message::NameChanged(name))) + .on_submit(profiles_message(profiles::Message::RenameSubmit)), ) - .row(accounts::action_button_row( + .row(accounts_view::action_button_row( Icon::Cross, "Delete profile", "Removes this profile and its linked accounts from this device", "Delete", - Message::Profiles(profiles::Message::DeleteProfile(active.id(),)), + profiles_message(profiles::Message::DeleteProfile(active.id())), )), links, ] .spacing(18) .into() }; - let content: Element<'_, Message> = if let Some(error) = self.profiles.last_error() { + let content: Element<'_, Message> = if let Some(error) = features.profiles.last_error() { column![ - crate::widgets::info_card::InfoCard::new( - crate::widgets::info_card::Kind::Error, - "Could not update profile", - error, - ) - .width(Fill), + InfoCard::new(InfoCardKind::Error, "Could not update profile", error,).width(Fill), content, ] .spacing(18) @@ -662,30 +409,35 @@ impl State { page } - fn new_bottle_page(&self, context: PaneContext) -> Element<'_, Message> { - let header = context - .header(Message::Window(chrome::Action::Drag)) - .start(header_button( - "Cancel bottle creation", - Icon::Arrow, - Message::CancelBottle, - )) - .middle( - container(Title::new("New Bottle").subtitle("Creating a new bottle.")) - .padding(iced::padding::bottom(12)), - ) - .end( - header_button( - "Create bottle", - Icon::Checkmark, - Message::Bottles(bottles::Message::CreateBottle), + fn new_bottle_page<'a>( + &'a self, + context: PaneContext, + features: &'a features::State, + ) -> Element<'a, Message> { + let header = context.header(Message::DragWindow, |header| { + header + .start(header_button( + "Cancel bottle creation", + Icon::Arrow, + Message::CancelBottle, + )) + .middle( + container(Title::new("New Bottle").subtitle("Creating a new bottle.")) + .padding(iced::padding::bottom(12)), ) - .on_press_maybe( - (!self.bottles.is_creating()) - .then_some(Message::Bottles(bottles::Message::CreateBottle)), - ), - ); - let content = self.bottles.creation_view().map(Message::Bottles); + .end( + header_button( + "Create bottle", + Icon::Checkmark, + bottles_message(bottles::Message::CreateBottle), + ) + .on_press_maybe( + (!features.bottles.is_creating()) + .then_some(bottles_message(bottles::Message::CreateBottle)), + ), + ) + }); + let content = bottles_view::creation(&features.bottles).map(bottles_message); column![header, scroll_panel(content)] .width(Fill) @@ -693,7 +445,12 @@ impl State { .into() } - fn detail_page(&self, context: PaneContext) -> Element<'_, Message> { + fn detail_page<'a>( + &'a self, + context: PaneContext, + features: &'a features::State, + domain: &'a DomainSnapshots, + ) -> Element<'a, Message> { #[cfg(feature = "fvs")] let detail_tabs = [ Tab::new(DetailTab::Programs, "Programs"), @@ -713,65 +470,69 @@ impl State { }), Message::DetailTabSelected, ); - let mut header = context.header(Message::Window(chrome::Action::Drag)); - - if context.is_standalone() { - header = header.start( - Button::icon_only("Back to bottles", Icon::Arrow) - .diameter(32.0) - .icon_size(16.0) - .kind(ButtonKind::Transparent) - .on_press(Message::Back), - ); - } + let header = context.header(Message::DragWindow, |mut header| { + if context.is_standalone() { + header = header.start( + Button::icon_only("Back to bottles", Icon::Arrow) + .diameter(32.0) + .icon_size(16.0) + .kind(ButtonKind::Transparent) + .on_press(Message::Back), + ); + } - let header = header - .start(header_button( - "More actions", - Icon::EllipsisVertical, - Message::OpenMenu, - )) - .start(header_button( - "Toggle power", - Icon::Power, - Message::TogglePower, - )) - .middle(tabs); - let settings_ctx = settings::Context { - #[cfg(target_os = "linux")] - bottle: self.selected_bottle_handle(), - bottle_state: self.selected_bottle_state(), - }; + header + .start(header_button( + "More actions", + Icon::EllipsisVertical, + Message::OpenMenu, + )) + .start(header_button( + "Toggle power", + Icon::Power, + Message::TogglePower, + )) + .middle(tabs) + }); let detail_tab = match self.route { Route::Bottle { tab, .. } => tab, _ => DetailTab::Programs, }; let content = match detail_tab { DetailTab::Programs => { - if let (Some(bottle), Some(state)) = - (self.selected_bottle_handle(), self.selected_bottle_state()) - { - self.bottles - .program_grid(bottle, state) - .map(Message::Bottles) + if let Some(snapshot) = self.selected_bottle(domain) { + bottles_view::programs( + &features.bottles, + snapshot.bottle.clone(), + snapshot.state.as_ref(), + ) + .map(bottles_message) + } else { + column![].into() + } + } + DetailTab::Settings => { + if let Some(snapshot) = self.selected_bottle(domain) { + let bottle_id = snapshot.state.id(); + settings_view::view(&features.settings, bottle_id, snapshot.state.as_ref()) + .map(settings_message) } else { column![].into() } } - DetailTab::Settings => self.settings.view(&settings_ctx).map(Message::Settings), #[cfg(feature = "fvs")] - DetailTab::Snapshots => self.snapshots.view().map(Message::Snapshots), + DetailTab::Snapshots => { + snapshots_view::view(&features.snapshots).map(snapshots_message) + } }; let mut body = column![scroll_content(content)].width(Fill).height(Fill); - if let Some(state) = self.selected_bottle_state() { + if let Some(snapshot) = self.selected_bottle(domain) { + let state = snapshot.state.as_ref(); body = body.push( - keyed_column([( - state.id(), - self.bottles.bottle_status(state).map(Message::Bottles), - )]) - .width(Fill), + keyed_column([(state.id(), bottles_view::status(state).map(bottles_message))]) + .width(Fill), ); } @@ -785,15 +546,13 @@ impl State { } } -fn exclusive_dialog<'a>( - profile: Option>, - account: Option>, -) -> Option> { - assert!( - profile.is_none() || account.is_none(), - "Classic workflows presented more than one dialog" - ); - profile.or(account) +fn reconcile_route(route: &mut Route, selected_exists: bool) -> bool { + if matches!(route, Route::Bottle { .. }) && !selected_exists { + *route = Route::Bottles; + true + } else { + false + } } fn scroll_panel<'a>(content: impl Into>) -> Element<'a, Message> { @@ -834,27 +593,23 @@ fn header_button(label: &str, icon: Icon, message: Message) -> Button<'_, Messag .on_press(message) } -#[cfg(test)] -mod tests { - use super::*; +fn bottles_message(message: bottles::Message) -> Message { + Message::Feature(features::Message::Bottles(message)) +} - #[test] - fn draining_accepts_task_messages_but_rejects_new_actions() { - assert!(Message::Library(library::Message::Loaded(1)).allowed_while_draining()); - assert!( - !Message::Library(library::Message::QueryChanged("new search".into())) - .allowed_while_draining() - ); - assert!(!Message::AddBottle.allowed_while_draining()); - } +fn profiles_message(message: profiles::Message) -> Message { + Message::Feature(features::Message::Profiles(message)) +} - #[test] - #[should_panic(expected = "more than one dialog")] - fn workflows_cannot_present_two_dialogs() { - use iced::widget::Space; +fn library_message(message: features::library::Message) -> Message { + Message::Feature(features::Message::Library(message)) +} - let dialog = || Dialog::new(Space::new(), Message::OpenMenu); +fn settings_message(message: features::bottle_settings::Message) -> Message { + Message::Feature(features::Message::Settings(message)) +} - let _ = exclusive_dialog(Some(dialog()), Some(dialog())); - } +#[cfg(feature = "fvs")] +fn snapshots_message(message: features::snapshots::Message) -> Message { + Message::Feature(features::Message::Snapshots(message)) } diff --git a/src/classic/profiles.rs b/src/classic/profiles.rs deleted file mode 100644 index cab82dd..0000000 --- a/src/classic/profiles.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Profile management backed directly by `next-core`. - -use std::sync::Arc; - -use bottles_core::{Profile, Profiles, ProfilesConfig, error::Error as CoreError}; -use iced::{ - Element, Task, - widget::{column, container, row}, -}; -use uuid::Uuid; - -use crate::{ - icons::Icon, - widgets::{ - button::{Button, ButtonKind}, - dialog::Dialog, - info_card::{InfoCard, Kind as InfoCardKind}, - popover::{Popover, PopoverItem}, - text_row::TextRow, - title::Title, - }, -}; - -pub fn profile_events( - profiles: &Profiles, -) -> impl iced::futures::Stream> + Send + 'static + use<> { - profiles.watch() -} - -#[derive(Clone)] -pub enum Message { - ToggleProfileSettings, - ActivateProfile(Uuid), - OpenCreate, - CreateNameChanged(String), - SubmitCreate, - DismissCreate, - ProfileUpdated(Result>), - NameChanged(String), - RenameSubmit, - DeleteProfile(Uuid), - ProfileDeleted(Result<(), Arc>), -} - -pub enum Output { - ToggleSettings, -} - -#[derive(Default)] -struct NewProfileDialog { - name: String, - error: Option, -} - -impl NewProfileDialog { - fn view(&self, pending: bool) -> iced::widget::Column<'_, Message> { - let mut content = column![ - container(Title::new("New profile").subtitle("Give this profile a name.")) - .center_x(iced::Fill), - TextRow::new("Profile name", &self.name) - .icon(Icon::Person) - .on_input(Message::CreateNameChanged) - .on_submit(Message::SubmitCreate), - ] - .spacing(18); - - if let Some(error) = &self.error { - content = content.push( - InfoCard::new(InfoCardKind::Error, "Could not create profile", error) - .width(iced::Fill), - ); - } - - content.push( - row![ - Button::new("Create") - .kind(ButtonKind::Primary) - .on_press(Message::SubmitCreate) - .loading(pending), - Button::new("Cancel") - .kind(ButtonKind::Transparent) - .on_press(Message::DismissCreate), - ] - .spacing(12), - ) - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum RequestKind { - Select, - Create, - Rename, - Delete, -} - -pub struct State { - profiles: Profiles, - selected_id: Uuid, - name_draft: String, - new_profile: Option, - last_error: Option, - request_kind: Option, -} - -impl State { - pub fn new(profiles: Profiles, selected: &Profile) -> Self { - Self { - profiles, - selected_id: selected.id(), - name_draft: selected.name().to_owned(), - new_profile: None, - last_error: None, - request_kind: None, - } - } - - /// Synchronizes local edit state with the Classic read model's selection. - pub fn sync_selected(&mut self, selected: &Profile) { - self.selected_id = selected.id(); - self.name_draft = selected.name().to_owned(); - } - - pub fn name_draft(&self) -> &str { - &self.name_draft - } - - pub(super) fn manager(&self) -> &Profiles { - &self.profiles - } - - pub fn last_error(&self) -> Option<&str> { - self.last_error.as_deref() - } - - pub fn has_active_operation(&self) -> bool { - self.request_kind.is_some() - } - - pub(super) fn dialog(&self) -> Option> { - self.new_profile.as_ref().map(|dialog| { - Dialog::new( - dialog.view(self.request_kind == Some(RequestKind::Create)), - Message::DismissCreate, - ) - }) - } - - pub(super) fn dismiss_dialog(&mut self) { - self.new_profile = None; - } - - pub fn update(&mut self, message: Message) -> (Task, Option) { - let mut output = None; - match message { - Message::ToggleProfileSettings => output = Some(Output::ToggleSettings), - Message::OpenCreate => { - if self.request_kind.is_none() && self.new_profile.is_none() { - self.new_profile = Some(NewProfileDialog::default()); - } - } - Message::CreateNameChanged(name) => { - if let Some(dialog) = &mut self.new_profile { - dialog.name = name; - } - } - Message::DismissCreate => self.dismiss_dialog(), - Message::ActivateProfile(id) => { - if self.request_kind.is_none() { - let profiles = self.profiles.clone(); - self.begin_request(RequestKind::Select); - return ( - Task::perform( - async move { profiles.select(id).await.map_err(Arc::new) }, - Message::ProfileUpdated, - ), - None, - ); - } - } - Message::SubmitCreate => { - if self.request_kind.is_some() { - return (Task::none(), None); - } - let Some(dialog) = &mut self.new_profile else { - return (Task::none(), None); - }; - - let profiles = self.profiles.clone(); - dialog.error = None; - let name = if dialog.name.trim().is_empty() { - "New profile".to_owned() - } else { - dialog.name.trim().to_owned() - }; - - self.begin_request(RequestKind::Create); - return ( - Task::perform( - async move { profiles.create(name).await.map_err(Arc::new) }, - Message::ProfileUpdated, - ), - None, - ); - } - Message::ProfileUpdated(result) => { - let Some(kind) = self.request_kind else { - return (Task::none(), None); - }; - if kind == RequestKind::Delete { - return (Task::none(), None); - } - self.request_kind = None; - - match result { - Ok(_) => { - self.last_error = None; - if kind == RequestKind::Create { - self.new_profile = None; - } - } - Err(error) if kind == RequestKind::Create => { - if let Some(dialog) = &mut self.new_profile { - dialog.error = Some(error.to_string()); - } - } - Err(error) => self.last_error = Some(error.to_string()), - } - } - Message::NameChanged(name) => self.name_draft = name, - Message::RenameSubmit => { - if self.request_kind.is_none() { - let profiles = self.profiles.clone(); - let selected_id = self.selected_id; - let name = self.name_draft.clone(); - self.begin_request(RequestKind::Rename); - return ( - Task::perform( - async move { profiles.rename(selected_id, name).await.map_err(Arc::new) }, - Message::ProfileUpdated, - ), - None, - ); - } - } - Message::DeleteProfile(id) => { - if self.request_kind.is_none() { - let profiles = self.profiles.clone(); - self.begin_request(RequestKind::Delete); - return ( - Task::perform( - async move { profiles.delete(id).await.map_err(Arc::new) }, - Message::ProfileDeleted, - ), - None, - ); - } - } - Message::ProfileDeleted(result) if self.request_kind == Some(RequestKind::Delete) => { - self.request_kind = None; - self.last_error = result.err().map(|error| error.to_string()); - } - Message::ProfileDeleted(_) => {} - } - - (Task::none(), output) - } - - fn begin_request(&mut self, kind: RequestKind) { - self.request_kind = Some(kind); - self.last_error = None; - } - - pub fn view_switcher<'a>(&self, snapshot: &'a ProfilesConfig) -> Element<'a, Message> { - let selected = snapshot.selected(); - let label = selected.name(); - let trigger = Button::icon_only(label, Icon::Person) - .diameter(32.0) - .icon_size(16.0) - .kind(ButtonKind::Transparent) - .on_press(()); - - let mut switcher = Popover::new(trigger); - - for profile in snapshot.profiles() { - switcher = switcher.item( - PopoverItem::new(profile.name()) - .icon(Icon::Person) - .selected(selected.id() == profile.id()) - .on_select(Message::ActivateProfile(profile.id())), - ); - } - - switcher = - switcher.item(PopoverItem::new("Profiles").on_select(Message::ToggleProfileSettings)); - - switcher.into() - } -} diff --git a/src/classic/profiles_view.rs b/src/classic/profiles_view.rs new file mode 100644 index 0000000..59f60dd --- /dev/null +++ b/src/classic/profiles_view.rs @@ -0,0 +1,71 @@ +use bottles_core::ProfilesConfig; +use iced::{ + Element, + widget::{column, container, row}, +}; + +use crate::{ + Icon, + features::profiles::{self, State}, + widget::{ + Button, ButtonKind, Dialog, InfoCard, InfoCardKind, Popover, PopoverItem, TextRow, Title, + }, +}; + +pub fn dialog(state: &State) -> Option> { + let profile = state.new_profile()?; + let mut content = column![ + container(Title::new("New profile").subtitle("Give this profile a name.")) + .center_x(iced::Fill), + TextRow::new("Profile name", profile.name()) + .icon(Icon::Person) + .on_input(profiles::Message::CreateNameChanged) + .on_submit(profiles::Message::SubmitCreate), + ] + .spacing(18); + + if let Some(error) = profile.error() { + content = content.push( + InfoCard::new(InfoCardKind::Error, "Could not create profile", error).width(iced::Fill), + ); + } + + content = content.push( + row![ + Button::new("Create") + .kind(ButtonKind::Primary) + .on_press(profiles::Message::SubmitCreate) + .loading(state.is_creating_profile()), + Button::new("Cancel") + .kind(ButtonKind::Transparent) + .on_press(profiles::Message::DismissCreate), + ] + .spacing(12), + ); + + Some(Dialog::new(content, profiles::Message::DismissCreate)) +} + +pub fn switcher(snapshot: &ProfilesConfig) -> Element<'_, super::Message> { + let selected = snapshot.selected(); + let trigger = Button::icon_only(selected.name(), Icon::Person) + .diameter(32.0) + .icon_size(16.0) + .kind(ButtonKind::Transparent); + let mut switcher = Popover::new(trigger); + + for profile in snapshot.profiles() { + switcher = switcher.item( + PopoverItem::new(profile.name()) + .icon(Icon::Person) + .selected(selected.id() == profile.id()) + .on_select(super::profiles_message(profiles::Message::ActivateProfile( + profile.id(), + ))), + ); + } + + switcher + .item(PopoverItem::new("Profiles").on_select(super::Message::ToggleProfileSettings)) + .into() +} diff --git a/src/classic/settings.rs b/src/classic/settings.rs deleted file mode 100644 index 16447c3..0000000 --- a/src/classic/settings.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Bottle "Settings" detail tab: platform-available wrapper toggles and -//! bottle/runner info. Bottle data comes from the selected core snapshot; -//! this state retains only local request/error state. - -use std::sync::Arc; - -use bottles_core::BottleState; -#[cfg(target_os = "linux")] -use bottles_core::{Bottle, MangoHudConfig, error::Error as CoreError}; -use iced::{ - Element, Length, - widget::{column, responsive}, -}; - -#[cfg(target_os = "linux")] -use crate::widgets::info_card::{InfoCard, Kind}; -use crate::{ - icons::Icon, - widgets::{ - action_row::{ActionRow, State as ActionRowState}, - cycle_row::CycleRow, - expander_row::ExpanderRow, - info_row::InfoRow, - list_row::{self, ListRow}, - row_group::RowGroup, - spacing, - switcher_row::SwitcherRow, - }, -}; - -const SETTINGS_TRACK_MIN_WIDTH: f32 = 300.0; - -pub struct Context<'a> { - #[cfg(target_os = "linux")] - pub bottle: Option, - pub bottle_state: Option<&'a Arc>, -} - -#[derive(Clone)] -pub enum Message { - #[cfg(target_os = "linux")] - ToggleGamescope(bool), - #[cfg(target_os = "linux")] - ToggleMangoHud(bool), - #[cfg(target_os = "linux")] - WrapperUpdated { - generation: u64, - result: Result<(), Arc>, - }, -} - -#[derive(Default)] -pub struct State { - #[cfg(target_os = "linux")] - generation: u64, - #[cfg(target_os = "linux")] - pending: bool, - #[cfg(target_os = "linux")] - last_error: Option, -} - -impl State { - pub fn new() -> Self { - Self::default() - } - - #[cfg(target_os = "linux")] - pub fn update(&mut self, message: Message, ctx: &Context<'_>) -> iced::Task { - match message { - Message::ToggleGamescope(enabled) => { - if !self.pending - && let (Some(bottle), Some(state)) = (ctx.bottle.clone(), ctx.bottle_state) - { - let mut config = state.wrappers().gamescope.clone(); - config.enabled = enabled; - let generation = self.next_generation(); - - return iced::Task::perform( - async move { - let mut edit = bottle.edit(); - edit.set_gamescope(config); - edit.commit().await.map_err(Arc::new) - }, - move |result| Message::WrapperUpdated { generation, result }, - ); - } - } - Message::ToggleMangoHud(enabled) => { - if !self.pending - && let Some(bottle) = ctx.bottle.clone() - { - let config = MangoHudConfig { enabled }; - let generation = self.next_generation(); - - return iced::Task::perform( - async move { - let mut edit = bottle.edit(); - edit.set_mangohud(config); - edit.commit().await.map_err(Arc::new) - }, - move |result| Message::WrapperUpdated { generation, result }, - ); - } - } - Message::WrapperUpdated { generation, result } if generation == self.generation => { - self.last_error = result.err().map(|error| error.to_string()); - self.pending = false; - } - Message::WrapperUpdated { .. } => {} - } - - iced::Task::none() - } - - #[cfg(not(target_os = "linux"))] - pub fn update(&mut self, message: Message, _ctx: &Context<'_>) -> iced::Task { - match message {} - } - - pub fn view<'a>(&'a self, ctx: &Context<'a>) -> Element<'a, Message> { - let Some(state) = ctx.bottle_state else { - return column![].into(); - }; - let content = column![ - responsive(move |size| { - let columns = - usize::from(size.width >= SETTINGS_TRACK_MIN_WIDTH * 2.0 + spacing::SM) + 1; - let bottle_name = ListRow::new(list_row::labels("Bottle Name", state.name())) - .trailing( - Icon::Pencil - .view() - .width(list_row::BODY_SIZE) - .height(list_row::BODY_SIZE), - ) - .enabled(false); - let unavailable = || { - InfoRow::new("Not available yet") - .description("This setting is not supported yet.") - }; - let bottle = RowGroup::new() - .title("Bottle") - .columns(columns) - .row(bottle_name) - .expander( - ExpanderRow::with_header( - InfoRow::new("Runner") - .description(format!( - "{} {}", - state.runner().name(), - state.runner().version() - )) - .icon(Icon::Run), - ) - .add(unavailable()) - .content_enabled(false), - ) - .expander( - ExpanderRow::new("Dependencies") - .description("Install fonts, codecs, libraries...") - .add(unavailable()) - .content_enabled(false), - ) - .expander( - ExpanderRow::new("Drives") - .description("Define your custom drives") - .add(unavailable()) - .content_enabled(false), - ); - - let graphics = RowGroup::new() - .title("Graphics") - .columns(columns) - .row( - SwitcherRow::new("DLSS", false).description("Deep Learning Super Sampling"), - ) - .row( - SwitcherRow::new("vkBasalt", false) - .description("Add post-processing effects"), - ) - .row( - SwitcherRow::new("Discrete GPU", false) - .description("Force use your dedicated GPU"), - ) - .expander( - ExpanderRow::with_header( - SwitcherRow::new("FSR", false) - .description("FidelityFX Super Resolution"), - ) - .columns(2) - .add( - ActionRow::new("Quality", ActionRowState::Disabled) - .description("Balanced"), - ) - .add(CycleRow::new("Sharpening", "5")), - ); - - #[cfg(target_os = "linux")] - let graphics = { - let wrappers = state.wrappers(); - graphics.row( - SwitcherRow::new("Gamescope", wrappers.gamescope.enabled) - .description("Use the SteamOS compositor") - .on_toggle(Message::ToggleGamescope), - ) - }; - - let graphics = graphics.expander( - ExpanderRow::with_header( - SwitcherRow::new("Display Settings", false) - .description("Resolution and other options"), - ) - .add(unavailable()) - .content_enabled(false), - ); - - #[cfg(target_os = "linux")] - let graphics = { - let wrappers = state.wrappers(); - graphics.row( - SwitcherRow::new("MangoHud", wrappers.mangohud.enabled) - .description("Show a performance overlay") - .on_toggle(Message::ToggleMangoHud), - ) - }; - - column![bottle, graphics].spacing(spacing::SM).into() - }) - .height(Length::Shrink) - ]; - #[cfg(target_os = "linux")] - let content = if let Some(error) = &self.last_error { - content.push( - InfoCard::new(Kind::Error, "Could not update bottle settings", error) - .width(iced::Fill), - ) - } else { - content - }; - - content.into() - } - - #[cfg(target_os = "linux")] - fn next_generation(&mut self) -> u64 { - self.generation = self.generation.wrapping_add(1); - self.pending = true; - self.last_error = None; - self.generation - } - - pub fn has_active_operation(&self) -> bool { - #[cfg(target_os = "linux")] - { - self.pending - } - #[cfg(not(target_os = "linux"))] - { - false - } - } -} diff --git a/src/classic/settings_view.rs b/src/classic/settings_view.rs new file mode 100644 index 0000000..f0c8b3b --- /dev/null +++ b/src/classic/settings_view.rs @@ -0,0 +1,127 @@ +use bottles_core::BottleState; +use iced::{ + Element, Length, + widget::{column, responsive}, +}; +use uuid::Uuid; + +#[cfg(target_os = "linux")] +use crate::widget::{InfoCard, InfoCardKind}; +use crate::{ + Icon, + features::bottle_settings::{Message, State}, + widget::{ActionRow, CycleRow, ExpanderRow, InfoRow, ListRow, RowGroup, SwitcherRow, spacing}, +}; + +const TRACK_MIN_WIDTH: f32 = 300.0; + +pub fn view<'a>( + state: &'a State, + bottle_id: Uuid, + bottle_state: &'a BottleState, +) -> Element<'a, Message> { + #[cfg(not(target_os = "linux"))] + let _ = bottle_id; + + let content = column![ + responsive(move |size| { + let columns = usize::from(size.width >= TRACK_MIN_WIDTH * 2.0 + spacing::SM) + 1; + let bottle_name: ListRow<'_, Message> = + ListRow::from(InfoRow::new("Bottle Name").description(bottle_state.name())) + .trailing(Icon::Pencil.view().width(16).height(16)); + let unavailable = || { + InfoRow::new("Not available yet").description("This setting is not supported yet.") + }; + let bottle = RowGroup::new() + .title("Bottle") + .columns(columns) + .row(bottle_name) + .expander( + ExpanderRow::with_header( + InfoRow::new("Runner") + .description(format!( + "{} {}", + bottle_state.runner().name(), + bottle_state.runner().version() + )) + .icon(Icon::Run), + ) + .row(unavailable()), + ) + .expander( + ExpanderRow::new("Dependencies") + .description("Install fonts, codecs, libraries...") + .row(unavailable()), + ) + .expander( + ExpanderRow::new("Drives") + .description("Define your custom drives") + .row(unavailable()), + ); + + let graphics = RowGroup::new() + .title("Graphics") + .columns(columns) + .row(SwitcherRow::new("DLSS", false).description("Deep Learning Super Sampling")) + .row(SwitcherRow::new("vkBasalt", false).description("Add post-processing effects")) + .row( + SwitcherRow::new("Discrete GPU", false) + .description("Force use your dedicated GPU"), + ) + .expander( + ExpanderRow::with_header( + SwitcherRow::new("FSR", false).description("FidelityFX Super Resolution"), + ) + .columns(2) + .row(ActionRow::new("Quality", None).description("Balanced")) + .row(CycleRow::new("Sharpening", "5")), + ); + + #[cfg(target_os = "linux")] + let graphics = { + let wrappers = bottle_state.wrappers(); + graphics.row( + SwitcherRow::new("Gamescope", wrappers.gamescope.enabled) + .description("Use the SteamOS compositor") + .on_toggle(move |enabled| Message::ToggleGamescope { bottle_id, enabled }), + ) + }; + + let graphics = graphics.expander( + ExpanderRow::with_header( + SwitcherRow::new("Display Settings", false) + .description("Resolution and other options"), + ) + .row(unavailable()), + ); + + #[cfg(target_os = "linux")] + let graphics = { + let wrappers = bottle_state.wrappers(); + graphics.row( + SwitcherRow::new("MangoHud", wrappers.mangohud.enabled) + .description("Show a performance overlay") + .on_toggle(move |enabled| Message::ToggleMangoHud { bottle_id, enabled }), + ) + }; + + column![bottle, graphics].spacing(spacing::SM).into() + }) + .height(Length::Shrink) + ]; + #[cfg(target_os = "linux")] + let content = if let Some(error) = state.last_error() { + content.push( + InfoCard::new( + InfoCardKind::Error, + "Could not update bottle settings", + error, + ) + .width(iced::Fill), + ) + } else { + content + }; + + content.into() +} diff --git a/src/classic/snapshots.rs b/src/classic/snapshots.rs deleted file mode 100644 index f89e1fb..0000000 --- a/src/classic/snapshots.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Bottle "Snapshots" detail tab. The Classic workspace loads it whenever a -//! bottle is selected and maps the task back into its own message enum. - -use std::{collections::HashMap, sync::Arc}; - -use bottles_core::{Bottle, SnapshotSummary, error::Error as CoreError}; -use iced::{ - Element, Fill, Length, Task, - widget::{column, responsive}, -}; -use tokio_util::sync::CancellationToken; - -use crate::{ - icons::Icon, - widgets::{ - action_row::{ActionRow, State as ActionRowState}, - info_card::{InfoCard, Kind}, - row_group::RowGroup, - }, -}; - -const CONTENT_GRID_BREAKPOINT: f32 = 720.0; - -#[derive(Clone)] -pub enum Message { - Loaded { - generation: u64, - result: Option, Arc>>, - }, - Noop, -} - -#[derive(Default)] -pub struct State { - snapshots: Vec, - snapshot_rows: Vec<(String, String)>, - generation: u64, - loads: HashMap, - last_error: Option>, -} - -impl State { - pub fn new() -> Self { - Self::default() - } - - pub fn clear(&mut self) { - self.cancel_active_operations(); - self.snapshots.clear(); - self.snapshot_rows.clear(); - self.last_error = None; - } - - pub fn load(&mut self, bottle: Bottle) -> Task { - self.clear(); - self.generation = self.generation.wrapping_add(1); - let generation = self.generation; - let cancellation = CancellationToken::new(); - let task_cancellation = cancellation.clone(); - self.loads.insert(generation, cancellation); - - Task::perform( - async move { - task_cancellation - .run_until_cancelled(bottle.snapshots()) - .await - .map(|result| result.map_err(Arc::new)) - }, - move |result| Message::Loaded { generation, result }, - ) - } - - pub fn update(&mut self, message: Message) -> Task { - let Message::Loaded { generation, result } = message else { - return Task::none(); - }; - self.loads.remove(&generation); - if generation == self.generation { - match result { - Some(Ok(snapshots)) => { - self.snapshot_rows = snapshots - .iter() - .map(|snapshot| { - let title = if snapshot.message.is_empty() { - snapshot.state_id.chars().take(12).collect() - } else { - snapshot.message.clone() - }; - let description = snapshot - .created_at - .as_ref() - .map(|timestamp| relative_time(timestamp.seconds)) - .unwrap_or_default(); - - (title, description) - }) - .collect(); - self.snapshots = snapshots; - self.last_error = None; - } - Some(Err(error)) => self.last_error = Some(error), - None => {} - } - } - - Task::none() - } - - pub fn has_active_operation(&self) -> bool { - !self.loads.is_empty() - } - - pub fn cancel_active_operations(&self) { - for cancellation in self.loads.values() { - cancellation.cancel(); - } - } - - pub fn view(&self) -> Element<'_, Message> { - let rows = responsive(move |size| { - let columns = usize::from(size.width >= CONTENT_GRID_BREAKPOINT) + 1; - - self.snapshot_rows - .iter() - .fold( - RowGroup::new().columns(columns), - |rows, (title, description)| { - rows.row( - ActionRow::new(title, ActionRowState::Ready(Message::Noop)) - .description(description) - .icon(Icon::Timer), - ) - }, - ) - .into() - }) - .height(Length::Shrink); - - let mut content = column![rows].spacing(12); - if let Some(error) = &self.last_error { - content = content.push( - InfoCard::new(Kind::Error, "Could not load snapshots", error.to_string()) - .width(Fill), - ); - } - - content.into() - } -} - -fn relative_time(seconds: i64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(seconds, |duration| duration.as_secs() as i64); - let diff = (now - seconds).max(0); - - match diff { - 0..=59 => "Just now".to_string(), - 60..=3599 => format!("{} minutes ago", diff / 60), - 3600..=86399 => format!("{} hours ago", diff / 3600), - _ => format!("{} days ago", diff / 86400), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cancellation_stays_active_until_the_terminal_message() { - let mut state = State::new(); - let cancellation = CancellationToken::new(); - state.loads.insert(1, cancellation.clone()); - - state.cancel_active_operations(); - - assert!(cancellation.is_cancelled()); - assert!(state.has_active_operation()); - - let _ = state.update(Message::Loaded { - generation: 1, - result: None, - }); - assert!(!state.has_active_operation()); - } -} diff --git a/src/classic/snapshots_view.rs b/src/classic/snapshots_view.rs new file mode 100644 index 0000000..b759c4d --- /dev/null +++ b/src/classic/snapshots_view.rs @@ -0,0 +1,69 @@ +use iced::{ + Element, Fill, Length, + widget::{column, responsive}, +}; + +use crate::{ + Icon, + features::snapshots::State, + widget::{InfoCard, InfoCardKind, InfoRow, RowGroup}, +}; + +const GRID_BREAKPOINT: f32 = 720.0; + +pub fn view(state: &State) -> Element<'_, Message> { + let rows = responsive(move |size| { + let columns = usize::from(size.width >= GRID_BREAKPOINT) + 1; + state + .snapshots() + .iter() + .fold(RowGroup::new().columns(columns), |rows, snapshot| { + let title = if snapshot.message.is_empty() { + snapshot.state_id.chars().take(12).collect() + } else { + snapshot.message.clone() + }; + let description = snapshot + .created_at + .as_ref() + .map(|timestamp| relative_time(timestamp.seconds)) + .unwrap_or_default(); + + rows.row( + InfoRow::new(title) + .description(description) + .icon(Icon::Timer), + ) + }) + .into() + }) + .height(Length::Shrink); + + let mut content = column![rows].spacing(12); + if let Some(error) = state.last_error() { + content = content.push( + InfoCard::new( + InfoCardKind::Error, + "Could not load snapshots", + error.to_string(), + ) + .width(Fill), + ); + } + + content.into() +} + +fn relative_time(seconds: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(seconds, |duration| duration.as_secs() as i64); + let diff = (now - seconds).max(0); + + match diff { + 0..=59 => "Just now".to_string(), + 60..=3599 => format!("{} minutes ago", diff / 60), + 3600..=86399 => format!("{} hours ago", diff / 3600), + _ => format!("{} days ago", diff / 86400), + } +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..7ae9286 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use bottles_core::{Bottle, BottleState, Bottles, ProfilesConfig}; +use uuid::Uuid; + +#[derive(Clone)] +pub(crate) struct BottleSnapshot { + pub(crate) bottle: Bottle, + pub(crate) state: Arc, +} + +#[derive(Clone)] +pub(crate) struct DomainSnapshots { + bottles: Vec, + profiles: Arc, +} + +impl DomainSnapshots { + pub(crate) fn new(core: &Bottles) -> Self { + Self { + bottles: bottle_snapshots(core.bottles().list()), + profiles: core.profiles().snapshot(), + } + } + + pub(crate) fn bottles(&self) -> &[BottleSnapshot] { + &self.bottles + } + + pub(crate) fn bottle(&self, id: Uuid) -> Option<&BottleSnapshot> { + self.bottles + .iter() + .find(|snapshot| snapshot.state.id() == id) + } + + pub(crate) fn profiles(&self) -> &ProfilesConfig { + &self.profiles + } + + pub(crate) fn replace_bottles(&mut self, bottles: Vec) -> bool { + let next = bottle_snapshots(bottles); + let changed = self.bottles.len() != next.len() + || self.bottles.iter().zip(&next).any(|(current, next)| { + current.bottle.id() != next.bottle.id() || !Arc::ptr_eq(¤t.state, &next.state) + }); + self.bottles = next; + changed + } + + pub(crate) fn replace_profiles(&mut self, profiles: Arc) -> Option { + if self.profiles == profiles { + return None; + } + let current = self.profiles.selected(); + let next = profiles.selected(); + let selected_changed = current.id() != next.id() || current.name() != next.name(); + self.profiles = profiles; + Some(selected_changed) + } +} + +fn bottle_snapshots(bottles: Vec) -> Vec { + let mut snapshots = bottles + .into_iter() + .filter_map(|bottle| { + let state = bottle.state().ok()?; + Some(BottleSnapshot { bottle, state }) + }) + .collect::>(); + snapshots.sort_unstable_by_key(|snapshot| snapshot.bottle.id()); + snapshots +} diff --git a/src/features/bottle_settings.rs b/src/features/bottle_settings.rs new file mode 100644 index 0000000..b7962c8 --- /dev/null +++ b/src/features/bottle_settings.rs @@ -0,0 +1,128 @@ +#[cfg(target_os = "linux")] +use std::sync::Arc; + +#[cfg(target_os = "linux")] +use bottles_core::{MangoHudConfig, error::Error as CoreError}; +use iced::Task; +#[cfg(target_os = "linux")] +use uuid::Uuid; + +use crate::domain::DomainSnapshots; + +#[derive(Clone)] +pub enum Message { + #[cfg(target_os = "linux")] + ToggleGamescope { bottle_id: Uuid, enabled: bool }, + #[cfg(target_os = "linux")] + ToggleMangoHud { bottle_id: Uuid, enabled: bool }, + #[cfg(target_os = "linux")] + WrapperUpdated(Result<(), Arc>), +} + +#[derive(Default)] +pub struct State { + #[cfg(target_os = "linux")] + pending: bool, + #[cfg(target_os = "linux")] + last_error: Option, +} + +impl State { + #[cfg(target_os = "linux")] + pub fn update(&mut self, message: Message, domain: &DomainSnapshots) -> Task { + match message { + Message::ToggleGamescope { bottle_id, enabled } => { + if !self.pending { + let Some(snapshot) = domain.bottle(bottle_id) else { + return Task::none(); + }; + let bottle = snapshot.bottle.clone(); + let mut config = snapshot.state.wrappers().gamescope.clone(); + config.enabled = enabled; + self.start_update(); + + return Task::perform( + async move { + let mut edit = bottle.edit(); + edit.set_gamescope(config); + edit.commit().await.map_err(Arc::new) + }, + Message::WrapperUpdated, + ); + } + } + Message::ToggleMangoHud { bottle_id, enabled } => { + if !self.pending { + let Some(snapshot) = domain.bottle(bottle_id) else { + return Task::none(); + }; + let bottle = snapshot.bottle.clone(); + self.start_update(); + return Task::perform( + async move { + let mut edit = bottle.edit(); + edit.set_mangohud(MangoHudConfig { enabled }); + edit.commit().await.map_err(Arc::new) + }, + Message::WrapperUpdated, + ); + } + } + Message::WrapperUpdated(result) => { + self.finish_update(result); + } + } + + Task::none() + } + + #[cfg(not(target_os = "linux"))] + pub fn update(&mut self, message: Message, _domain: &DomainSnapshots) -> Task { + match message {} + } + + pub fn has_active_operation(&self) -> bool { + #[cfg(target_os = "linux")] + { + self.pending + } + #[cfg(not(target_os = "linux"))] + { + false + } + } + + #[cfg(target_os = "linux")] + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + #[cfg(target_os = "linux")] + fn start_update(&mut self) { + self.pending = true; + self.last_error = None; + } + + #[cfg(target_os = "linux")] + fn finish_update(&mut self, result: Result<(), Arc>) { + self.last_error = result.err().map(|error| error.to_string()); + self.pending = false; + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::State; + + #[test] + fn completion_clears_pending_without_bottle_context() { + let mut state = State { + pending: true, + last_error: None, + }; + + state.finish_update(Ok(())); + + assert!(!state.pending); + } +} diff --git a/src/features/bottles.rs b/src/features/bottles.rs new file mode 100644 index 0000000..5bb4cda --- /dev/null +++ b/src/features/bottles.rs @@ -0,0 +1,183 @@ +use std::sync::Arc; + +use bottles_core::{Addons, Bottle, BottleManager, Slot, Storage}; +use iced::Task; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::operation; + +#[derive(Clone, PartialEq)] +pub struct RunnerOption { + id: Uuid, + label: String, +} + +impl std::fmt::Display for RunnerOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.label) + } +} + +#[derive(Clone)] +pub enum Message { + CreateBottle, + BottleCreation(operation::Event), + BottleNameChanged(String), + RunnerSelected(RunnerOption), + LaunchProgram { bottle: Bottle, program_id: Uuid }, + ProgramLaunched(Result>), +} + +pub enum Output { + Created, +} + +pub struct State { + creation: Creation, + launches: ProgramLaunches, +} + +struct Creation { + manager: BottleManager, + bottle_name: String, + runners: Vec, + selected_runner: Option, + creation_generation: u64, + creation_cancellation: Option, + last_error: Option, +} + +#[derive(Default)] +struct ProgramLaunches { + active: usize, + last_error: Option, +} + +impl State { + pub fn new(manager: BottleManager, addons: &Addons) -> Self { + let runners = addons + .components() + .iter() + .filter(|entry| entry.slot() == Slot::Runner) + .map(|entry| RunnerOption { + id: entry.id(), + label: format!("{} {}", entry.name(), entry.version()), + }) + .collect::>(); + let selected_runner = runners.first().cloned(); + Self { + creation: Creation { + manager, + bottle_name: "Gaming paradise".into(), + runners, + selected_runner, + creation_generation: 0, + creation_cancellation: None, + last_error: None, + }, + launches: ProgramLaunches::default(), + } + } + + pub fn bottle_name(&self) -> &str { + &self.creation.bottle_name + } + + pub fn runners(&self) -> &[RunnerOption] { + &self.creation.runners + } + + pub fn selected_runner(&self) -> Option<&RunnerOption> { + self.creation.selected_runner.as_ref() + } + + pub fn creation_error(&self) -> Option<&str> { + self.creation.last_error.as_deref() + } + + pub fn launch_error(&self) -> Option<&str> { + self.launches.last_error.as_deref() + } + + pub fn reset_creation(&mut self) { + self.creation.last_error = None; + } + + pub fn cancel_creation(&self) { + if let Some(cancellation) = &self.creation.creation_cancellation { + cancellation.cancel(); + } + } + + pub fn has_active_operation(&self) -> bool { + self.creation.creation_cancellation.is_some() || self.launches.active > 0 + } + + pub fn is_creating(&self) -> bool { + self.creation.creation_cancellation.is_some() + } + + pub fn update(&mut self, message: Message) -> (Task, Option) { + let mut output = None; + match message { + Message::CreateBottle => { + if self.creation.creation_cancellation.is_none() + && let Some(runner) = self.creation.selected_runner.clone() + { + let name = self.creation.bottle_name.clone(); + let manager = self.creation.manager.clone(); + let operation = manager.create(name, Storage::Standard, runner.id); + self.creation.creation_generation = + self.creation.creation_generation.wrapping_add(1); + let generation = self.creation.creation_generation; + let (cancellation, task) = operation::run(operation, generation); + self.creation.creation_cancellation = Some(cancellation); + self.creation.last_error = None; + + return (task.map(Message::BottleCreation), None); + } + } + Message::BottleCreation(operation::Event::Finished { key, outcome }) + if key == self.creation.creation_generation => + { + self.creation.creation_cancellation = None; + match outcome { + operation::Outcome::Succeeded(_) => { + self.creation.last_error = None; + output = Some(Output::Created); + } + operation::Outcome::Cancelled => { + self.creation.last_error = Some("Bottle creation was cancelled.".into()); + } + operation::Outcome::Failed(error) => { + self.creation.last_error = Some(error.to_string()); + } + } + } + Message::BottleNameChanged(name) => self.creation.bottle_name = name, + Message::RunnerSelected(runner) => self.creation.selected_runner = Some(runner), + Message::LaunchProgram { bottle, program_id } => { + self.launches.active += 1; + return ( + Task::perform( + async move { bottle.launch_program(program_id).await.map_err(Arc::new) }, + Message::ProgramLaunched, + ), + None, + ); + } + Message::ProgramLaunched(Err(error)) => { + self.launches.active = self.launches.active.saturating_sub(1); + self.launches.last_error = Some(error.to_string()); + } + Message::ProgramLaunched(Ok(_)) => { + self.launches.active = self.launches.active.saturating_sub(1); + self.launches.last_error = None; + } + Message::BottleCreation(_) => {} + } + + (Task::none(), output) + } +} diff --git a/src/classic/library.rs b/src/features/library.rs similarity index 50% rename from src/classic/library.rs rename to src/features/library.rs index f1eeaaa..1abb795 100644 --- a/src/classic/library.rs +++ b/src/features/library.rs @@ -1,29 +1,14 @@ -//! Library tab backed directly by `next-core`. - use std::{collections::HashMap, sync::Arc}; -use bottles_core::{Library, LibraryItem, SearchEntry, SearchSource, error::Error as CoreError}; +use bottles_core::{Library, LibraryItem, SearchEntry, error::Error as CoreError}; use iced::{ - Element, Fill, Length, + Task, futures::{StreamExt as _, stream}, - widget::{Grid, column, container}, }; use tokio_util::sync::CancellationToken; -use crate::{ - icons::Icon, - widgets::{ - artwork_card::{ArtworkCard, CardAction}, - info_card::{InfoCard, Kind}, - search::Search as SearchWidget, - spacing, - }, -}; - -const NARROW_CONTENT_MAX_WIDTH: f32 = 500.0; - #[derive(Clone, Copy, PartialEq, Eq)] -enum LibraryState { +pub enum Status { Idle, Loading, Loaded, @@ -31,7 +16,7 @@ enum LibraryState { struct Search { entries: Vec, - state: LibraryState, + status: Status, generation: u64, active: HashMap, } @@ -40,7 +25,7 @@ impl Default for Search { fn default() -> Self { Self { entries: Vec::new(), - state: LibraryState::Idle, + status: Status::Idle, generation: 0, active: HashMap::new(), } @@ -52,7 +37,7 @@ impl Search { self.cancel(); self.generation = self.generation.wrapping_add(1); self.entries.clear(); - self.state = LibraryState::Loading; + self.status = Status::Loading; let cancellation = CancellationToken::new(); self.active.insert(self.generation, cancellation.clone()); @@ -61,7 +46,7 @@ impl Search { fn push_entry(&mut self, generation: u64, entry: SearchEntry) { if generation == self.generation { - self.state = LibraryState::Loaded; + self.status = Status::Loaded; self.entries.push(entry); } } @@ -69,7 +54,7 @@ impl Search { fn finish(&mut self, generation: u64) { self.active.remove(&generation); if generation == self.generation { - self.state = LibraryState::Loaded; + self.status = Status::Loaded; } } @@ -93,10 +78,6 @@ pub enum Message { Launched(Result>), } -pub enum Output { - Reload, -} - pub struct State { library: Library, query: String, @@ -116,7 +97,23 @@ impl State { } } - pub fn reload(&mut self) -> iced::Task { + pub fn query(&self) -> &str { + &self.query + } + + pub fn status(&self) -> Status { + self.search.status + } + + pub fn entries(&self) -> &[SearchEntry] { + &self.search.entries + } + + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + pub fn reload(&mut self) -> Task { let (generation, cancellation) = self.search.begin(); let cancelled = cancellation.cancelled_owned(); let events = self @@ -126,28 +123,25 @@ impl State { .take_until(cancelled); let finished = stream::once(async move { Message::Loaded(generation) }); - iced::Task::run(events.chain(finished), std::convert::identity) + Task::run(events.chain(finished), std::convert::identity) } - pub fn update(&mut self, message: Message) -> (iced::Task, Option) { + pub fn update(&mut self, message: Message) -> Task { match message { Message::QueryChanged(query) => { self.query = query; - return (iced::Task::none(), Some(Output::Reload)); + return self.reload(); } Message::Entry { generation, entry } => self.search.push_entry(generation, entry), Message::Loaded(generation) => self.search.finish(generation), Message::Launch(item) => { if self.launching { - return (iced::Task::none(), None); + return Task::none(); } self.launching = true; - return ( - iced::Task::perform( - async move { item.launch().await.map_err(Arc::new) }, - Message::Launched, - ), - None, + return Task::perform( + async move { item.launch().await.map_err(Arc::new) }, + Message::Launched, ); } Message::Launched(Err(error)) => { @@ -160,7 +154,7 @@ impl State { } } - (iced::Task::none(), None) + Task::none() } pub fn has_active_operation(&self) -> bool { @@ -170,82 +164,6 @@ impl State { pub fn cancel_active_operations(&self) { self.search.cancel(); } - - pub fn view(&self) -> Element<'_, Message> { - let search = centered_narrow(SearchWidget::new( - "Search library", - &self.query, - Message::QueryChanged, - )); - - let notice = match self.search.state { - LibraryState::Idle => Some(( - "No active profile", - "Sign in to a profile to see its library.", - )), - LibraryState::Loading => Some(( - "Loading library", - "Loading games from this profile's linked storefronts.", - )), - LibraryState::Loaded => None, - }; - if let Some((title, body)) = notice { - return column![ - search, - centered_narrow(InfoCard::new(Kind::Hint, title, body).width(Fill)) - ] - .spacing(12) - .into(); - } - - let mut content = column![search].spacing(12); - if let Some(error) = &self.last_error { - content = content.push(centered_narrow( - InfoCard::new(Kind::Error, "Program launch failed", error).width(Fill), - )); - } - if self.search.entries.is_empty() { - return content - .push(centered_narrow( - InfoCard::new( - Kind::Hint, - "Nothing here yet", - "Registered programs and linked storefront games will show up here.", - ) - .width(Fill), - )) - .into(); - } - - let rows = Grid::with_children(self.search.entries.iter().map(entry_card)) - .fluid(400.0) - .spacing(spacing::MD) - .height(Length::Shrink); - - content.push(rows).into() - } -} - -fn centered_narrow<'a>(content: impl Into>) -> Element<'a, Message> { - container( - container(content) - .width(Fill) - .max_width(NARROW_CONTENT_MAX_WIDTH), - ) - .center_x(Fill) - .into() -} - -fn entry_card(entry: &SearchEntry) -> Element<'_, Message> { - ArtworkCard::new(entry.title(), entry.source_name()) - .menu(CardAction::new("More actions", Icon::EllipsisVertical)) - .primary( - CardAction::new("Play", Icon::Play).on_press_maybe(match entry.source() { - SearchSource::Installed(item) => Some(Message::Launch(item.clone())), - _ => None, - }), - ) - .into() } #[cfg(test)] @@ -260,10 +178,10 @@ mod tests { search.finish(stale); - assert!(search.state == LibraryState::Loading); + assert!(search.status == Status::Loading); search.finish(current); - assert!(search.state == LibraryState::Loaded); + assert!(search.status == Status::Loaded); } #[test] diff --git a/src/features/mod.rs b/src/features/mod.rs new file mode 100644 index 0000000..a277cd1 --- /dev/null +++ b/src/features/mod.rs @@ -0,0 +1,112 @@ +pub(crate) mod bottle_settings; +pub(crate) mod bottles; +pub(crate) mod library; +pub(crate) mod profiles; +#[cfg(feature = "fvs")] +pub(crate) mod snapshots; + +use bottles_core::Bottles; +use iced::Task; + +use crate::domain::DomainSnapshots; + +#[derive(Clone)] +pub(crate) enum Message { + Bottles(bottles::Message), + Settings(bottle_settings::Message), + #[cfg(feature = "fvs")] + Snapshots(snapshots::Message), + Library(library::Message), + Profiles(profiles::Message), +} + +pub(crate) enum Output { + BottleCreated, +} + +/// Disposable workflow state shared by application presentations. +pub(crate) struct State { + pub(crate) bottles: bottles::State, + pub(crate) profiles: profiles::State, + pub(crate) library: library::State, + pub(crate) settings: bottle_settings::State, + #[cfg(feature = "fvs")] + pub(crate) snapshots: snapshots::State, +} + +impl State { + pub(crate) fn new(core: &Bottles, domain: &DomainSnapshots) -> Self { + Self { + bottles: bottles::State::new(core.bottles().clone(), core.addons()), + profiles: profiles::State::new(core.profiles().clone(), domain.profiles().selected()), + library: library::State::new(core.library().clone()), + settings: bottle_settings::State::default(), + #[cfg(feature = "fvs")] + snapshots: snapshots::State::default(), + } + } + + pub(crate) fn has_active_operations(&self) -> bool { + let active = self.bottles.has_active_operation() + || self.profiles.has_active_operation() + || self.settings.has_active_operation() + || self.library.has_active_operation(); + #[cfg(feature = "fvs")] + let active = active || self.snapshots.has_active_operation(); + active + } + + pub(crate) fn cancel_active_operations(&mut self) { + self.profiles.dismiss_dialogs(); + self.bottles.cancel_creation(); + self.library.cancel_active_operations(); + #[cfg(feature = "fvs")] + self.snapshots.cancel_active_operations(); + } + + pub(crate) fn reload_library(&mut self) -> Task { + self.library.reload().map(Message::Library) + } + + pub(crate) fn update( + &mut self, + message: Message, + domain: &DomainSnapshots, + ) -> (Task, Option) { + match message { + Message::Bottles(message) => { + let (task, output) = self.bottles.update(message); + ( + task.map(Message::Bottles), + output.map(|bottles::Output::Created| Output::BottleCreated), + ) + } + Message::Settings(message) => ( + self.settings.update(message, domain).map(Message::Settings), + None, + ), + #[cfg(feature = "fvs")] + Message::Snapshots(message) => { + (self.snapshots.update(message).map(Message::Snapshots), None) + } + Message::Library(message) => (self.library.update(message).map(Message::Library), None), + Message::Profiles(message) => ( + self.profiles + .update(message, domain.profiles().selected()) + .map(Message::Profiles), + None, + ), + } + } + + pub(crate) fn profiles_changed( + &mut self, + domain: &DomainSnapshots, + selected_changed: bool, + ) -> Task { + if selected_changed { + self.profiles.sync_selected(domain.profiles().selected()); + } + self.reload_library() + } +} diff --git a/src/classic/accounts.rs b/src/features/profiles/account_link.rs similarity index 55% rename from src/classic/accounts.rs rename to src/features/profiles/account_link.rs index c13061f..8e9a4d5 100644 --- a/src/classic/accounts.rs +++ b/src/features/profiles/account_link.rs @@ -1,35 +1,16 @@ -//! Storefront account linking backed directly by `next-core` providers. - use std::sync::{Arc, Mutex}; use bottles_core::{ AccountLinkInteraction, PluginId, Profile, Profiles, StorefrontProvider, error::Error as CoreError, }; -use iced::futures::channel::{mpsc, oneshot}; +use iced::{ + Task, + futures::channel::{mpsc, oneshot}, +}; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::{ - icons::Icon, - widgets::{ - button::{Button, ButtonKind}, - dialog::Dialog, - info_row::InfoRow, - list_row::ListRow, - picker_row::PickerRow, - popover::{Popover, PopoverItem}, - row_group::RowGroup, - text_row::TextRow, - title::Title, - }, -}; - -pub struct Context<'a> { - pub active_profile: &'a Profile, - pub profiles: &'a Profiles, -} - #[derive(Clone)] pub struct LoginPrompt { url: String, @@ -88,7 +69,7 @@ impl AccountLinkInteraction for LoginInteraction { } } -struct LoginDialog { +pub struct LoginDialog { code_draft: String, prompt: LoginPrompt, submitting: bool, @@ -105,80 +86,35 @@ impl LoginDialog { } } - fn set_code(&mut self, code: String) { - self.code_draft = code; + pub fn code(&self) -> &str { + &self.code_draft } - fn url(&self) -> &str { + pub fn url(&self) -> &str { &self.prompt.url } - fn submit(&mut self) { - match self.prompt.submit(self.code_draft.trim()) { - Ok(()) => self.submitting = true, - Err(error) => self.error = Some(error.to_string()), - } + pub fn instructions(&self) -> &str { + &self.prompt.instructions } - fn view(&self) -> iced::widget::Column<'_, Message> { - use iced::widget::{column, container, row}; + pub fn is_submitting(&self) -> bool { + self.submitting + } - let submit_label = if self.submitting { - "Submitting…" - } else { - "Submit" - }; + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } - let mut content = column![ - container(Title::new("Sign in").subtitle(&self.prompt.instructions)) - .center_x(iced::Fill), - RowGroup::new() - .row( - ListRow::from( - InfoRow::new("Sign-in link (click to copy)") - .description(&self.prompt.url) - .icon(Icon::Controller), - ) - .on_press(Message::CopyLoginUrl), - ) - .row(action_button_row( - Icon::Arrow, - "Open in your browser", - "Sign in there, then paste the requested value below.", - "Open", - Message::OpenLoginUrl, - )) - .row( - TextRow::new("Authorization code", &self.code_draft) - .icon(Icon::Checkmark) - .on_input(Message::LoginCodeChanged) - .on_submit(Message::SubmitLogin), - ), - ] - .spacing(18); - if let Some(error) = &self.error { - content = content.push( - crate::widgets::info_card::InfoCard::new( - crate::widgets::info_card::Kind::Error, - "Could not answer the sign-in prompt", - error, - ) - .width(iced::Fill), - ); + fn set_code(&mut self, code: String) { + self.code_draft = code; + } + + fn submit(&mut self) { + match self.prompt.submit(self.code_draft.trim()) { + Ok(()) => self.submitting = true, + Err(error) => self.error = Some(error.to_string()), } - content = content.push( - row![ - Button::new(submit_label) - .kind(ButtonKind::Primary) - .on_press_maybe((!self.submitting).then_some(Message::SubmitLogin)), - Button::new("Cancel") - .kind(ButtonKind::Transparent) - .on_press(Message::DismissLogin), - ] - .spacing(12), - ); - - content } } @@ -201,9 +137,10 @@ pub enum Message { CopyLoginUrl, SubmitLogin, DismissLogin, + InstallProviderPlugin, + UrlOpened(Result<(), Arc>), LinkFinished(Result>), ProfileUpdated(Result>), - Noop, } #[derive(Default)] @@ -216,7 +153,14 @@ pub struct State { } impl State { - /// Requests cancellation without dropping the task that drives the session. + pub fn dialog(&self) -> Option<&LoginDialog> { + self.login_dialog.as_ref() + } + + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + pub fn cancel_active_operation(&mut self) { self.login_dialog = None; if let Some(cancellation) = &self.link_cancellation { @@ -228,14 +172,19 @@ impl State { self.link_cancellation.is_some() || self.mutation_pending } - pub fn update(&mut self, message: Message, ctx: &Context<'_>) -> iced::Task { + pub fn update( + &mut self, + message: Message, + active_profile: &Profile, + profiles: &Profiles, + ) -> Task { match message { Message::UnlinkAccount(provider_id) => { if !self.mutation_pending && self.link_cancellation.is_none() { - let profiles = ctx.profiles.clone(); - let profile_id = ctx.active_profile.id(); + let profiles = profiles.clone(); + let profile_id = active_profile.id(); self.mutation_pending = true; - return iced::Task::perform( + return Task::perform( async move { profiles .unlink_account(profile_id, provider_id) @@ -250,12 +199,8 @@ impl State { if self.link_cancellation.is_none() && !self.mutation_pending { self.link_generation = self.link_generation.wrapping_add(1); let generation = self.link_generation; - let (cancellation, task) = link_account( - ctx.profiles, - ctx.active_profile.id(), - provider.id, - generation, - ); + let (cancellation, task) = + link_account(profiles, active_profile.id(), provider.id, generation); self.link_cancellation = Some(cancellation); self.last_error = None; return task; @@ -267,20 +212,28 @@ impl State { Message::DismissLogin => self.cancel_active_operation(), Message::LoginCodeChanged(code) => { let Some(dialog) = &mut self.login_dialog else { - return iced::Task::none(); + return Task::none(); }; dialog.set_code(code); } Message::OpenLoginUrl => { - if let Some(dialog) = &self.login_dialog { - open_url(dialog.url()); - } + let Some(dialog) = &mut self.login_dialog else { + return Task::none(); + }; + let url = dialog.url().to_owned(); + dialog.error = None; + self.last_error = None; + + return Task::perform( + async move { open_url(&url).map_err(Arc::new) }, + Message::UrlOpened, + ); } Message::CopyLoginUrl => { return self .login_dialog .as_ref() - .map_or_else(iced::Task::none, |dialog| { + .map_or_else(Task::none, |dialog| { iced::clipboard::write(dialog.url().to_owned()) }); } @@ -289,12 +242,13 @@ impl State { dialog.submit(); } } + Message::InstallProviderPlugin => {} + Message::UrlOpened(result) => self.finish_url_open(result), Message::LinkFinished(result) => self.finish_link(result), Message::ProfileUpdated(result) => self.finish_mutation(result), - Message::Noop => {} } - iced::Task::none() + Task::none() } fn receive_prompt(&mut self, generation: u64, prompt: LoginPrompt) { @@ -321,113 +275,38 @@ impl State { self.last_error = presentation_error(result); } - pub(super) fn dialog(&self) -> Option> { - self.login_dialog - .as_ref() - .map(|dialog| Dialog::new(dialog.view(), Message::DismissLogin)) - } - - pub fn view_links<'a>(&'a self, ctx: &Context<'a>) -> iced::Element<'a, Message> { - use iced::widget::{column, container}; - - let active = ctx.active_profile; - - let mut accounts = RowGroup::new().title("Linked accounts"); - for account in active.accounts() { - accounts = accounts.row(account_row( - provider_icon(&account.provider), - format!( - "{} on {}", - account.identity.display_name, account.provider.name - ), - "Connected", - Message::UnlinkAccount(account.provider.id.clone()), - )); - } - - let link_trigger = PickerRow::new("Link a storefront account") - .description("Choose the account provider to connect") - .on_press(()); - let mut link_menu = Popover::new(link_trigger); - - for provider in ctx.profiles.account_providers() { - if active - .accounts() - .iter() - .any(|account| account.provider.id == provider.id) - { - continue; + fn finish_url_open(&mut self, result: Result<(), Arc>) { + if let Err(error) = result { + let error = format!("Could not open the sign-in link: {error}"); + if let Some(dialog) = &mut self.login_dialog { + dialog.error = Some(error); + } else { + self.last_error = Some(error); } - - link_menu = link_menu.item( - PopoverItem::new(provider.name.clone()) - .icon(provider_icon(&provider)) - .action("Link", Message::BeginLogin(provider)), - ); } - - link_menu = link_menu.item( - PopoverItem::new("Not listed, install a provider plugin").on_select(Message::Noop), - ); - - let mut content = column![accounts, container(link_menu).width(iced::Fill)].spacing(18); - if let Some(error) = &self.last_error { - content = content.push( - crate::widgets::info_card::InfoCard::new( - crate::widgets::info_card::Kind::Error, - "Account update failed", - error, - ) - .width(iced::Fill), - ); - } - content.into() } } -pub fn account_row<'a>( - icon: Icon, - title: impl iced::widget::text::IntoFragment<'a>, - description: &'a str, - on_unlink: Message, -) -> ListRow<'a, Message> { - action_button_row(icon, title, description, "Unlink", on_unlink) -} - -pub fn action_button_row<'a, M: Clone + 'a>( - icon: Icon, - title: impl iced::widget::text::IntoFragment<'a>, - description: &'a str, - button_label: &'a str, - on_press: M, -) -> ListRow<'a, M> { - ListRow::from(InfoRow::new(title).description(description).icon(icon)).trailing( - Button::new(button_label) - .kind(ButtonKind::Surface) - .on_press(on_press), - ) -} - fn link_account( profiles: &Profiles, profile_id: Uuid, provider_id: PluginId, generation: u64, -) -> (CancellationToken, iced::Task) { +) -> (CancellationToken, Task) { let (send_prompt, prompts) = mpsc::unbounded(); let interaction = Arc::new(LoginInteraction { prompts: send_prompt, }); let operation = profiles.link_account(profile_id, provider_id, interaction); let cancellation = operation.cancellation_token(); - let prompts = iced::Task::run(prompts, move |prompt| Message::LoginRequested { + let prompts = Task::run(prompts, move |prompt| Message::LoginRequested { generation, prompt, }); - let operation = iced::Task::perform(operation, |result| { + let operation = Task::perform(operation, |result| { Message::LinkFinished(result.map_err(Arc::new)) }); - (cancellation, iced::Task::batch([prompts, operation])) + (cancellation, Task::batch([prompts, operation])) } fn presentation_error(result: Result>) -> Option { @@ -436,23 +315,22 @@ fn presentation_error(result: Result>) -> Option }) } -fn open_url(url: &str) { +fn open_url(url: &str) -> std::io::Result<()> { #[cfg(target_os = "macos")] - let _ = std::process::Command::new("open").arg(url).spawn(); + let result = std::process::Command::new("open").arg(url).spawn(); #[cfg(target_os = "windows")] - let _ = std::process::Command::new("cmd") + let result = std::process::Command::new("cmd") .args(["/C", "start", "", url]) .spawn(); #[cfg(all(unix, not(target_os = "macos")))] - let _ = std::process::Command::new("xdg-open").arg(url).spawn(); -} - -fn provider_icon(provider: &StorefrontProvider) -> Icon { - if provider.id.as_str() == "steam" { - Icon::Computer - } else { - Icon::Controller - } + let result = std::process::Command::new("xdg-open").arg(url).spawn(); + #[cfg(not(any(unix, target_os = "windows")))] + let result = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "opening URLs is not supported on this platform", + )); + + result.map(drop) } #[cfg(test)] @@ -479,12 +357,14 @@ mod tests { #[test] fn cancellation_stays_active_until_the_terminal_event() { - let mut state = State::default(); let cancellation = CancellationToken::new(); - state.link_generation = 1; - state.link_cancellation = Some(cancellation.clone()); let (prompt, answer) = LoginPrompt::new("https://example.com".into(), "Sign in".into()); - state.login_dialog = Some(LoginDialog::new(prompt)); + let mut state = State { + link_generation: 1, + link_cancellation: Some(cancellation.clone()), + login_dialog: Some(LoginDialog::new(prompt)), + ..State::default() + }; state.cancel_active_operation(); @@ -502,9 +382,11 @@ mod tests { #[test] fn stale_prompts_do_not_attach_to_a_later_link() { futures_lite::future::block_on(async { - let mut state = State::default(); - state.link_generation = 2; - state.link_cancellation = Some(CancellationToken::new()); + let mut state = State { + link_generation: 2, + link_cancellation: Some(CancellationToken::new()), + ..State::default() + }; let (prompt, answer) = LoginPrompt::new("https://example.com".into(), "Sign in".into()); state.receive_prompt(1, prompt); diff --git a/src/features/profiles/mod.rs b/src/features/profiles/mod.rs new file mode 100644 index 0000000..ec6ff7e --- /dev/null +++ b/src/features/profiles/mod.rs @@ -0,0 +1,230 @@ +mod account_link; + +use std::sync::Arc; + +use bottles_core::{Profile, Profiles, StorefrontProvider, error::Error as CoreError}; +use iced::Task; +use uuid::Uuid; + +pub use account_link::{LoginDialog, Message as AccountMessage, State as AccountLinkState}; + +#[derive(Clone)] +pub enum Message { + ActivateProfile(Uuid), + OpenCreate, + CreateNameChanged(String), + SubmitCreate, + DismissCreate, + ProfileUpdated(Result>), + NameChanged(String), + RenameSubmit, + DeleteProfile(Uuid), + ProfileDeleted(Result<(), Arc>), + Account(AccountMessage), +} + +#[derive(Default)] +pub struct NewProfile { + name: String, + error: Option, +} + +impl NewProfile { + pub fn name(&self) -> &str { + &self.name + } + + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RequestKind { + Select, + Create, + Rename, + Delete, +} + +pub struct State { + profiles: Profiles, + selected_id: Uuid, + name_draft: String, + new_profile: Option, + last_error: Option, + request_kind: Option, + account_link: AccountLinkState, +} + +impl State { + pub fn new(profiles: Profiles, selected: &Profile) -> Self { + Self { + profiles, + selected_id: selected.id(), + name_draft: selected.name().to_owned(), + new_profile: None, + last_error: None, + request_kind: None, + account_link: AccountLinkState::default(), + } + } + + pub fn sync_selected(&mut self, selected: &Profile) { + if self.selected_id != selected.id() { + self.account_link.cancel_active_operation(); + } + self.selected_id = selected.id(); + self.name_draft = selected.name().to_owned(); + } + + pub fn account_providers(&self) -> Vec { + self.profiles.account_providers() + } + + pub fn name_draft(&self) -> &str { + &self.name_draft + } + + pub fn new_profile(&self) -> Option<&NewProfile> { + self.new_profile.as_ref() + } + + pub fn is_creating_profile(&self) -> bool { + self.request_kind == Some(RequestKind::Create) + } + + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + pub fn account_link(&self) -> &AccountLinkState { + &self.account_link + } + + pub fn has_active_operation(&self) -> bool { + self.request_kind.is_some() || self.account_link.has_active_operation() + } + + pub fn dismiss_dialogs(&mut self) { + self.new_profile = None; + self.account_link.cancel_active_operation(); + } + + pub fn update(&mut self, message: Message, active_profile: &Profile) -> Task { + match message { + Message::OpenCreate => { + if self.request_kind.is_none() && self.new_profile.is_none() { + self.account_link.cancel_active_operation(); + self.new_profile = Some(NewProfile::default()); + } + } + Message::CreateNameChanged(name) => { + if let Some(dialog) = &mut self.new_profile { + dialog.name = name; + } + } + Message::DismissCreate => self.new_profile = None, + Message::ActivateProfile(id) => { + if self.request_kind.is_none() { + let profiles = self.profiles.clone(); + self.begin_request(RequestKind::Select); + return Task::perform( + async move { profiles.select(id).await.map_err(Arc::new) }, + Message::ProfileUpdated, + ); + } + } + Message::SubmitCreate => { + if self.request_kind.is_some() { + return Task::none(); + } + let Some(dialog) = &mut self.new_profile else { + return Task::none(); + }; + + let profiles = self.profiles.clone(); + dialog.error = None; + let name = if dialog.name.trim().is_empty() { + "New profile".to_owned() + } else { + dialog.name.trim().to_owned() + }; + + self.begin_request(RequestKind::Create); + return Task::perform( + async move { profiles.create(name).await.map_err(Arc::new) }, + Message::ProfileUpdated, + ); + } + Message::ProfileUpdated(result) => { + let Some(kind) = self.request_kind else { + return Task::none(); + }; + if kind == RequestKind::Delete { + return Task::none(); + } + self.request_kind = None; + + match result { + Ok(_) => { + self.last_error = None; + if kind == RequestKind::Create { + self.new_profile = None; + } + } + Err(error) if kind == RequestKind::Create => { + if let Some(dialog) = &mut self.new_profile { + dialog.error = Some(error.to_string()); + } + } + Err(error) => self.last_error = Some(error.to_string()), + } + } + Message::NameChanged(name) => self.name_draft = name, + Message::RenameSubmit => { + if self.request_kind.is_none() { + let profiles = self.profiles.clone(); + let selected_id = self.selected_id; + let name = self.name_draft.clone(); + self.begin_request(RequestKind::Rename); + return Task::perform( + async move { profiles.rename(selected_id, name).await.map_err(Arc::new) }, + Message::ProfileUpdated, + ); + } + } + Message::DeleteProfile(id) => { + if self.request_kind.is_none() { + let profiles = self.profiles.clone(); + self.begin_request(RequestKind::Delete); + return Task::perform( + async move { profiles.delete(id).await.map_err(Arc::new) }, + Message::ProfileDeleted, + ); + } + } + Message::ProfileDeleted(result) if self.request_kind == Some(RequestKind::Delete) => { + self.request_kind = None; + self.last_error = result.err().map(|error| error.to_string()); + } + Message::ProfileDeleted(_) => {} + Message::Account(message) => { + if matches!(message, AccountMessage::BeginLogin(_)) { + self.new_profile = None; + } + return self + .account_link + .update(message, active_profile, &self.profiles) + .map(Message::Account); + } + } + + Task::none() + } + + fn begin_request(&mut self, kind: RequestKind) { + self.request_kind = Some(kind); + self.last_error = None; + } +} diff --git a/src/features/snapshots.rs b/src/features/snapshots.rs new file mode 100644 index 0000000..fb5e702 --- /dev/null +++ b/src/features/snapshots.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, sync::Arc}; + +use bottles_core::{Bottle, SnapshotSummary, error::Error as CoreError}; +use iced::Task; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub enum Message { + Loaded { + generation: u64, + result: Option, Arc>>, + }, +} + +#[derive(Default)] +pub struct State { + snapshots: Vec, + generation: u64, + loads: HashMap, + last_error: Option>, +} + +impl State { + pub fn clear(&mut self) { + self.cancel_active_operations(); + self.snapshots.clear(); + self.last_error = None; + } + + pub fn load(&mut self, bottle: Bottle) -> Task { + self.clear(); + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + self.loads.insert(generation, cancellation); + + Task::perform( + async move { + task_cancellation + .run_until_cancelled(bottle.snapshots()) + .await + .map(|result| result.map_err(Arc::new)) + }, + move |result| Message::Loaded { generation, result }, + ) + } + + pub fn update(&mut self, message: Message) -> Task { + let Message::Loaded { generation, result } = message; + self.loads.remove(&generation); + if generation == self.generation { + match result { + Some(Ok(snapshots)) => { + self.snapshots = snapshots; + self.last_error = None; + } + Some(Err(error)) => self.last_error = Some(error), + None => {} + } + } + + Task::none() + } + + pub fn snapshots(&self) -> &[SnapshotSummary] { + &self.snapshots + } + + pub fn last_error(&self) -> Option<&CoreError> { + self.last_error.as_deref() + } + + pub fn has_active_operation(&self) -> bool { + !self.loads.is_empty() + } + + pub fn cancel_active_operations(&self) { + for cancellation in self.loads.values() { + cancellation.cancel(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_stays_active_until_the_terminal_message() { + let mut state = State::default(); + let cancellation = CancellationToken::new(); + state.loads.insert(1, cancellation.clone()); + + state.cancel_active_operations(); + + assert!(cancellation.is_cancelled()); + assert!(state.has_active_operation()); + + let _ = state.update(Message::Loaded { + generation: 1, + result: None, + }); + assert!(!state.has_active_operation()); + } +} diff --git a/src/icons.rs b/src/icons.rs index 114b32c..1c46ba9 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -3,6 +3,7 @@ use rust_embed::RustEmbed; pub(crate) const SIZE: f32 = 24.0; +/// A typed reference to an icon embedded in the toolkit with `rust-embed`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Icon { Arrow, @@ -45,6 +46,7 @@ impl Icon { svg::Handle::from_memory(icon.data) } + /// Builds an SVG widget using the current semantic muted color. pub fn view<'a>(self) -> svg::Svg<'a> { svg(self.handle()) .width(SIZE) @@ -93,3 +95,68 @@ impl Icon { #[folder = "assets"] #[include = "icons/*.svg"] struct Assets; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + const ALL: [Icon; 29] = [ + Icon::Arrow, + Icon::Bottles, + Icon::Checkmark, + Icon::Chip, + Icon::Computer, + Icon::Controller, + Icon::Cross, + Icon::Custom, + Icon::Disk, + Icon::DoubleCheckmark, + Icon::DownCaret, + Icon::EllipsisVertical, + Icon::Error, + Icon::Folder, + Icon::Gear, + Icon::HollowGear, + Icon::Info, + Icon::Lightning, + Icon::Pencil, + Icon::Person, + Icon::Play, + Icon::Plus, + Icon::Power, + Icon::Run, + Icon::Search, + Icon::Stop, + Icon::Timer, + Icon::Wand, + Icon::Warning, + ]; + + #[test] + fn every_typed_icon_resolves_and_every_embedded_icon_is_typed() { + let declared: BTreeSet<_> = ALL + .into_iter() + .map(|icon| { + let path = format!("icons/{}.svg", icon.name()); + assert!( + Assets::get(&path).is_some(), + "missing embedded icon: {path}" + ); + path + }) + .collect(); + let embedded: BTreeSet<_> = Assets::iter() + .filter(|path| path.starts_with("icons/") && path.ends_with(".svg")) + .map(|path| path.into_owned()) + .collect(); + + assert_eq!( + declared.len(), + ALL.len(), + "two Icon variants share an asset" + ); + assert_eq!(declared, embedded); + } +} diff --git a/src/lib.rs b/src/lib.rs index a22131c..75b09a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,28 +1,48 @@ -mod app; -mod classic; -pub mod icons; -mod onboarding; -mod operation; -pub mod theme; -pub mod ui; -pub mod widgets; +//! The reusable Bottles Next UI toolkit. +//! +//! The `next-ui` package also contains the desktop application, but this library +//! facade exposes no application or domain types. Callers provide controlled +//! data and semantic messages; widgets retain only cohesive interaction state +//! such as focus, disclosure, popup placement, resizing, and animation. +//! +//! The supported facade is [`theme`], [`widget`], and [`Icon`]. -pub(crate) use app::Experience; +mod icons; +pub mod theme; +mod widgets; -pub fn run() -> iced::Result { - iced::application(app::App::new, app::App::update, app::App::view) - .title("Bottles Next") - .theme(app::App::theme) - .subscription(app::App::subscription) - .style(|_, theme| theme::application(theme)) - .window(iced::window::Settings { - size: iced::Size::new(1600.0, 1000.0), - position: iced::window::Position::Centered, - min_size: Some(iced::Size::new(720.0, 600.0)), - decorations: false, - transparent: true, - exit_on_close_request: false, - ..Default::default() - }) - .run() +pub use icons::Icon; +/// The complete public widget catalog. +/// +/// The caller owns values and semantic messages. Widgets own their interaction +/// state. +pub mod widget { + pub use crate::widgets::{ + action_row::ActionRow, + action_tile::ActionTile, + artwork_card::{ArtworkCard, CardAction}, + button::{Button, ButtonKind}, + card::Card, + cycle_row::CycleRow, + dialog::{Dialog, WindowModal}, + expander_row::ExpanderRow, + header_bar::HeaderBar, + info_card::{InfoCard, Kind as InfoCardKind}, + info_row::InfoRow, + list_row::ListRow, + picker_row::PickerRow, + popover::{Popover, PopoverItem}, + progress_ring::ProgressRing, + row_group::RowGroup, + search::{Search, SearchResult, SearchState}, + selector_row::SelectorRow, + spacing, + status_bar::{LogPanel, StatusBar}, + switcher::Switcher, + switcher_row::SwitcherRow, + tabs::{Tab, Tabs}, + text::TextExt, + text_row::TextRow, + title::Title, + }; } diff --git a/src/main.rs b/src/main.rs index 55774c9..c4d3577 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,29 @@ +mod app; +mod chrome; +mod classic; +mod domain; +mod features; +mod next; +mod onboarding; +mod operation; + +pub(crate) use app::Experience; +pub(crate) use next_ui::{Icon, theme, widget}; + fn main() -> iced::Result { - next_ui::run() + iced::application(app::App::new, app::App::update, app::App::view) + .title("Bottles Next") + .theme(app::App::theme) + .subscription(app::App::subscription) + .style(|_, theme| theme::application(theme)) + .window(iced::window::Settings { + size: iced::Size::new(1600.0, 1000.0), + position: iced::window::Position::Centered, + min_size: Some(iced::Size::new(720.0, 600.0)), + decorations: false, + transparent: true, + exit_on_close_request: false, + ..Default::default() + }) + .run() } diff --git a/src/next.rs b/src/next.rs new file mode 100644 index 0000000..0137e33 --- /dev/null +++ b/src/next.rs @@ -0,0 +1,33 @@ +//! Placeholder boundary for the not-yet-implemented Next experience. + +use iced::{ + Element, + widget::{column, text}, +}; + +use crate::widget::{Button, ButtonKind}; + +#[derive(Debug, Clone, Copy)] +pub enum Action { + UseClassic, +} + +pub struct State; + +impl State { + pub fn new() -> Self { + Self + } + + pub fn view(&self) -> Element<'_, Action> { + column![ + text("Next experience is not available yet").size(32), + text("Choose Classic to use Bottles today."), + Button::new("Use Classic") + .kind(ButtonKind::Primary) + .on_press(Action::UseClassic), + ] + .spacing(12) + .into() + } +} diff --git a/src/onboarding.rs b/src/onboarding.rs index 44044a8..ebb7418 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,41 +1,26 @@ //! First-run onboarding: mode picker, a short tutorial carousel, and a //! runtime-download checklist driven directly by the in-process addon manager. -use std::sync::Arc; +mod setup; use crate::{ - Experience, - icons::Icon, - operation::{self, Event as OperationEvent, Outcome}, - theme, - ui::chrome, - widgets::{ - action_row::{ActionRow, State as RowState}, - button::{Button, ButtonKind}, - header_bar::HeaderBar, - info_card::{InfoCard, Kind as InfoCardKind}, - info_row::InfoRow, - list_row::ListRow, - row_group::RowGroup, - text::TextExt as _, + Experience, Icon, chrome, theme, + widget::{ + ActionRow, Button, ButtonKind, InfoCard, InfoCardKind, InfoRow, RowGroup, TextExt as _, }, }; -use bottles_core::{Addons, CatalogEntry, Component, IndexEntry, Slot, error::Error as CoreError}; +use bottles_core::Addons; use iced::{ Element, Fill, Length, Task, Theme, alignment::{Horizontal, Vertical}, widget::{column, container, row, scrollable, svg, text}, }; -use tokio_util::sync::CancellationToken; const EXPERIENCE_SELECTOR_MAX_WIDTH: f32 = 900.0; const RESOURCE_LIST_MAX_WIDTH: f32 = 500.0; const EXPERIENCE_ROW_GAP: f32 = 8.0; const TUTORIAL_TEXT_MAX_WIDTH: f32 = 700.0; -const ONBOARDING_SLOTS: &[(Slot, &str)] = - &[(Slot::WineBridge, "WineBridge"), (Slot::Runner, "Runner")]; - struct TutorialStep { title: &'static str, body: &'static str, @@ -71,68 +56,25 @@ enum Step { Downloads, } -struct DownloadItem { - slot: Slot, - id: Option, - label: String, - size_label: String, - progress: f32, - state: DownloadState, -} - -enum DownloadState { - Pending, - Running(CancellationToken), - Succeeded, - Cancelled, - Unavailable, - Failed(Arc), -} - -enum SetupPhase { - Idle, - Preparing(CancellationToken), - Ready, - Downloading, - Cancelling, - Failed, - Complete, -} - -fn format_bytes(bytes: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - let bytes = bytes as f64; - - if bytes >= MB { - format!("{:.1} MB", bytes / MB) - } else { - format!("{:.0} KB", bytes / KB) - } -} - pub struct State { step: Step, experience: Experience, - addons: Addons, - downloads: Vec, - download_generation: u64, - setup_phase: SetupPhase, - catalog_error: Option>, + setup: setup::Coordinator, } #[derive(Clone)] -pub enum Message { +pub enum Action { SelectExperience(Experience), ApplyExperience, NextTutorialStep, - CatalogRefresh(OperationEvent), - StartDownloads, - Download(OperationEvent<(u64, Slot), Arc>>), - CancelDownloads, - Retry, + Setup(setup::Action), Finished(Experience), - Window(chrome::Action), + DragWindow, +} + +#[derive(Clone)] +pub enum Event { + Setup(setup::Event), } impl State { @@ -140,33 +82,30 @@ impl State { Self { step: Step::Welcome, experience: Experience::Classic, - addons, - downloads: Vec::new(), - download_generation: 0, - setup_phase: SetupPhase::Idle, - catalog_error: None, + setup: setup::Coordinator::new(addons), } } pub fn cancel_active_operations(&self) { - if let SetupPhase::Preparing(cancellation) = &self.setup_phase { - cancellation.cancel(); - } - request_download_cancellation(&self.downloads); + self.setup.cancel_active_operations(); + } + + pub fn has_active_operations(&self) -> bool { + self.setup.has_active_operations() } - pub fn update(&mut self, message: Message) -> Task { - match message { - Message::SelectExperience(experience) => { + pub fn update_action(&mut self, action: Action) -> Task { + match action { + Action::SelectExperience(experience) => { if experience_available(experience) { self.experience = experience; } } - Message::ApplyExperience => { + Action::ApplyExperience => { self.step = Step::Tutorial(0); - return self.start_preparation(); + return self.setup.prepare().map(Event::Setup); } - Message::NextTutorialStep => { + Action::NextTutorialStep => { if let Step::Tutorial(index) = self.step { if index + 1 < TUTORIAL_STEPS.len() { self.step = Step::Tutorial(index + 1); @@ -175,157 +114,30 @@ impl State { } } } - Message::CatalogRefresh(OperationEvent::Progress { .. }) => {} - Message::CatalogRefresh(OperationEvent::Finished { key, outcome }) => { - if key != self.download_generation { - return Task::none(); - } - - self.catalog_error = match outcome { - Outcome::Succeeded(()) => None, - Outcome::Cancelled => None, - Outcome::Failed(error) => Some(error), - }; - self.prepare_downloads(); - } - Message::StartDownloads => return self.start_downloads(), - Message::Download(OperationEvent::Progress { key, progress }) => { - if let Some(item) = - current_download_mut(&mut self.downloads, self.download_generation, key) - && matches!(&item.state, DownloadState::Running(_)) - { - update_download_progress(item, &progress); - } - } - Message::Download(OperationEvent::Finished { key, outcome }) => { - if let Some(item) = - current_download_mut(&mut self.downloads, self.download_generation, key) - { - item.state = match outcome { - Outcome::Succeeded(_) => { - item.progress = 1.0; - DownloadState::Succeeded - } - Outcome::Cancelled => DownloadState::Cancelled, - Outcome::Failed(error) => DownloadState::Failed(error), - }; - - self.finish_download_batch(); - } - } - Message::CancelDownloads => { - if matches!(self.setup_phase, SetupPhase::Downloading) { - request_download_cancellation(&self.downloads); - self.setup_phase = SetupPhase::Cancelling; - } - } - Message::Retry => { - if self - .downloads - .iter() - .any(|item| matches!(item.state, DownloadState::Unavailable)) - { - return self.start_preparation(); - } - return self.start_downloads(); - } - Message::Window(action) => return action.task().unwrap_or_else(Task::none), - Message::Finished(_) => {} + Action::Setup(action) => return self.setup.update_action(action).map(Event::Setup), + Action::DragWindow | Action::Finished(_) => {} } Task::none() } - fn start_preparation(&mut self) -> Task { - self.cancel_active_operations(); - self.download_generation = self.download_generation.wrapping_add(1); - let generation = self.download_generation; - self.downloads.clear(); - self.catalog_error = None; - - let (cancellation, task) = operation::run(self.addons.refresh(), generation); - self.setup_phase = SetupPhase::Preparing(cancellation); - - task.map(Message::CatalogRefresh) - } - - fn prepare_downloads(&mut self) { - let installed = self.addons.components(); - let catalog = self.addons.component_entries(); - - self.downloads = build_download_items(&installed, &catalog); - - if downloads_complete(&self.downloads) { - self.setup_phase = SetupPhase::Complete; - self.catalog_error = None; - } else if self - .downloads - .iter() - .all(|item| !matches!(item.state, DownloadState::Unavailable)) - { - self.setup_phase = SetupPhase::Ready; - self.catalog_error = None; - } else { - self.setup_phase = SetupPhase::Failed; + pub fn update_event(&mut self, event: Event) -> Task { + match event { + Event::Setup(event) => self.setup.update_event(event).map(Event::Setup), } } - fn start_downloads(&mut self) -> Task { - if !matches!(self.setup_phase, SetupPhase::Ready | SetupPhase::Failed) { - return Task::none(); - } - - self.download_generation = self.download_generation.wrapping_add(1); - let generation = self.download_generation; - let addons = self.addons.clone(); - let mut tasks = Vec::new(); - - for item in &mut self.downloads { - if matches!(item.state, DownloadState::Succeeded) { - continue; - } - - let Some(id) = item.id else { - continue; - }; - - item.progress = 0.0; - item.size_label = "Ready to download".into(); - let (cancellation, task) = - operation::run(addons.fetch_component(id), (generation, item.slot)); - tasks.push(task.map(Message::Download)); - item.state = DownloadState::Running(cancellation); - } - - if tasks.is_empty() { - self.setup_phase = if downloads_complete(&self.downloads) { - SetupPhase::Complete - } else { - SetupPhase::Failed - }; - Task::none() - } else { - self.catalog_error = None; - self.setup_phase = SetupPhase::Downloading; - Task::batch(tasks) - } - } - - fn finish_download_batch(&mut self) { - settle_download_batch(&mut self.setup_phase, &mut self.downloads); - } - - pub fn view(&self) -> Element<'_, Message> { + pub fn view(&self) -> Element<'_, Action> { let content = match &self.step { Step::Welcome => self.welcome_view(), Step::Tutorial(index) => tutorial_view(*index), Step::Downloads => self.downloads_view(), }; - shell(content, Message::Window(chrome::Action::Drag)) + shell(content, Action::DragWindow) } - fn welcome_view(&self) -> Element<'_, Message> { + fn welcome_view(&self) -> Element<'_, Action> { let header = onboarding_title( "Welcome", "Choose the experience, you can change this later.", @@ -356,7 +168,7 @@ impl State { .into() } - fn downloads_view(&self) -> Element<'_, Message> { + fn downloads_view(&self) -> Element<'_, Action> { let header = onboarding_title( "Almost Done", "Bottles need to download the following small resources to be ready.", @@ -366,39 +178,37 @@ impl State { let mut failures = column![].spacing(8); let mut has_failures = false; - if matches!(self.setup_phase, SetupPhase::Preparing(_)) { - group = group.row( - ActionRow::new("Resource catalog", RowState::Progress(0.0)) - .description("Preparing"), - ); + if matches!(self.setup.phase(), setup::Phase::Preparing(_)) { + group = + group.row(ActionRow::progress("Resource catalog", 0.0).description("Preparing")); } - for item in &self.downloads { + for item in self.setup.downloads() { let description = match &item.state { - DownloadState::Pending => &item.size_label, - DownloadState::Cancelled => "Cancelled", - DownloadState::Unavailable => "Unavailable", - DownloadState::Failed(_) => "Failed", - DownloadState::Running(_) | DownloadState::Succeeded => &item.size_label, + setup::DownloadState::Pending => &item.size_label, + setup::DownloadState::Cancelled => "Cancelled", + setup::DownloadState::Unavailable => "Unavailable", + setup::DownloadState::Failed(_) => "Failed", + setup::DownloadState::Running(_) | setup::DownloadState::Succeeded => { + &item.size_label + } }; group = match &item.state { - DownloadState::Running(_) => group.row( - ActionRow::new(&item.label, RowState::Progress(item.progress)) - .description(description), - ), - DownloadState::Succeeded => group.row( - ActionRow::new(&item.label, RowState::Progress(1.0)).description(description), - ), - DownloadState::Pending - | DownloadState::Cancelled - | DownloadState::Unavailable - | DownloadState::Failed(_) => { + setup::DownloadState::Running(_) => group + .row(ActionRow::progress(&item.label, item.progress).description(description)), + setup::DownloadState::Succeeded => { + group.row(ActionRow::progress(&item.label, 1.0).description(description)) + } + setup::DownloadState::Pending + | setup::DownloadState::Cancelled + | setup::DownloadState::Unavailable + | setup::DownloadState::Failed(_) => { group.row(InfoRow::new(&item.label).description(description)) } }; - if let DownloadState::Failed(error) = &item.state { + if let setup::DownloadState::Failed(error) = &item.state { has_failures = true; failures = failures.push( row![ @@ -408,7 +218,7 @@ impl State { .spacing(8) .align_y(Vertical::Center), ); - } else if matches!(&item.state, DownloadState::Unavailable) { + } else if matches!(&item.state, setup::DownloadState::Unavailable) { has_failures = true; failures = failures.push( row![ @@ -421,7 +231,7 @@ impl State { } } - if let Some(error) = &self.catalog_error { + if let Some(error) = self.setup.catalog_error() { has_failures = true; failures = failures.push( row![ @@ -433,20 +243,22 @@ impl State { ); } - let action: Element<'_, Message> = match &self.setup_phase { - SetupPhase::Idle | SetupPhase::Preparing(_) => onboarding_button("Preparing…").into(), - SetupPhase::Ready => onboarding_button_with_icon("Download") - .on_press(Message::StartDownloads) + let action: Element<'_, Action> = match self.setup.phase() { + setup::Phase::Idle | setup::Phase::Preparing(_) => { + onboarding_button("Preparing…").into() + } + setup::Phase::Ready => onboarding_button_with_icon("Download") + .on_press(Action::Setup(setup::Action::StartDownloads)) .into(), - SetupPhase::Downloading => onboarding_button("Cancel") - .on_press(Message::CancelDownloads) + setup::Phase::Downloading => onboarding_button("Cancel") + .on_press(Action::Setup(setup::Action::CancelDownloads)) .into(), - SetupPhase::Cancelling => onboarding_button("Cancelling…").into(), - SetupPhase::Failed => onboarding_button_with_icon("Retry") - .on_press(Message::Retry) + setup::Phase::Cancelling => onboarding_button("Cancelling…").into(), + setup::Phase::Failed => onboarding_button_with_icon("Retry") + .on_press(Action::Setup(setup::Action::Retry)) .into(), - SetupPhase::Complete => onboarding_button_with_icon("Get Started") - .on_press(Message::Finished(self.experience)) + setup::Phase::Complete => onboarding_button_with_icon("Get Started") + .on_press(Action::Finished(self.experience)) .into(), }; @@ -469,29 +281,24 @@ impl State { .into() } - fn apply_button(&self) -> Element<'_, Message> { + fn apply_button(&self) -> Element<'_, Action> { onboarding_button_with_icon("Apply Experience") - .on_press(Message::ApplyExperience) + .on_press(Action::ApplyExperience) .into() } - fn experience_button(&self, experience: Experience) -> Element<'_, Message> { + fn experience_button(&self, experience: Experience) -> Element<'_, Action> { let selected = experience == self.experience; - let state = if experience_available(experience) { - RowState::Ready(Message::SelectExperience(experience)) - } else { - RowState::Disabled - }; + let action = + experience_available(experience).then_some(Action::SelectExperience(experience)); - ListRow::from( - ActionRow::new(experience_option_label(experience), state) - .description(experience_caption(experience)), - ) - .selected(selected) - .into() + ActionRow::new(experience_option_label(experience), action) + .description(experience_caption(experience)) + .selected(selected) + .into() } - fn selected_experience_view(&self) -> Element<'_, Message> { + fn selected_experience_view(&self) -> Element<'_, Action> { let (first, second) = experience_detail(self.experience); InfoCard::new( @@ -509,7 +316,7 @@ pub(crate) fn shell<'a, Message: Clone + 'a>( content: impl Into>, on_drag: Message, ) -> Element<'a, Message> { - let header = HeaderBar::new(on_drag).transparent(true); + let header = chrome::header(on_drag, true, |header| header.transparent(true)); let panel = container( column![ header, @@ -532,120 +339,6 @@ fn experience_available(experience: Experience) -> bool { experience == Experience::Classic } -fn downloads_complete(downloads: &[DownloadItem]) -> bool { - downloads.len() == ONBOARDING_SLOTS.len() - && downloads - .iter() - .all(|item| matches!(&item.state, DownloadState::Succeeded)) -} - -fn build_download_items( - installed: &[Arc>], - catalog: &[CatalogEntry], -) -> Vec { - ONBOARDING_SLOTS - .iter() - .map(|(slot, slot_label)| { - if let Some(entry) = installed.iter().find(|entry| entry.slot() == *slot) { - return DownloadItem { - slot: *slot, - id: Some(entry.id()), - label: format!("{} {}", entry.name(), entry.version()), - size_label: "Installed".into(), - progress: 1.0, - state: DownloadState::Succeeded, - }; - } - - let Some(entry) = supported_component(catalog, *slot) else { - return DownloadItem { - slot: *slot, - id: None, - label: (*slot_label).to_string(), - size_label: "Unavailable".into(), - progress: 0.0, - state: DownloadState::Unavailable, - }; - }; - - DownloadItem { - slot: *slot, - id: Some(entry.id()), - label: format!("{} {}", entry.name(), entry.version()), - size_label: "Ready to download".into(), - progress: 0.0, - state: DownloadState::Pending, - } - }) - .collect() -} - -fn current_download_mut( - downloads: &mut [DownloadItem], - current_generation: u64, - (generation, slot): (u64, Slot), -) -> Option<&mut DownloadItem> { - if generation != current_generation { - return None; - } - downloads.iter_mut().find(|item| item.slot == slot) -} - -fn update_download_progress(item: &mut DownloadItem, progress: &bottles_core::Progress) { - if let Some(fraction) = progress.fraction() { - item.progress = fraction; - } - if let Some(total) = progress.transfer.and_then(|transfer| transfer.total) { - item.size_label = format_bytes(total); - } -} - -fn settle_download_batch(phase: &mut SetupPhase, downloads: &mut [DownloadItem]) { - if downloads - .iter() - .any(|item| matches!(item.state, DownloadState::Running(_))) - { - return; - } - - if matches!(phase, SetupPhase::Cancelling) { - for item in &mut *downloads { - if matches!(item.state, DownloadState::Cancelled) { - item.state = DownloadState::Pending; - item.size_label = "Ready to download".into(); - } - } - } - - *phase = if downloads_complete(downloads) { - SetupPhase::Complete - } else if downloads - .iter() - .any(|item| matches!(item.state, DownloadState::Failed(_))) - { - SetupPhase::Failed - } else { - SetupPhase::Ready - }; -} - -fn request_download_cancellation(downloads: &[DownloadItem]) { - for item in downloads { - if let DownloadState::Running(cancellation) = &item.state { - cancellation.cancel(); - } - } -} - -fn supported_component( - entries: &[CatalogEntry], - slot: Slot, -) -> Option<&CatalogEntry> { - entries - .iter() - .find(|entry| entry.slot() == slot && entry.is_supported()) -} - fn experience_label(experience: Experience) -> &'static str { match experience { Experience::Next => "Next Mode", @@ -680,7 +373,7 @@ fn experience_detail(experience: Experience) -> (&'static str, &'static str) { } } -fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Message> { +fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Action> { column![ text(title) .size(32) @@ -689,7 +382,7 @@ fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Messag ..iced::Font::DEFAULT }) .style(|theme: &Theme| text::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }), text(subtitle).size(16).medium().muted(), ] @@ -700,11 +393,11 @@ fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Messag fn error_icon<'a>() -> svg::Svg<'a> { Icon::Error.view().style(|theme: &Theme, _| svg::Style { - color: Some(theme.palette().danger), + color: Some(crate::theme::colors(theme).danger), }) } -fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { +fn tutorial_view<'a>(index: usize) -> Element<'a, Action> { let step = &TUTORIAL_STEPS[index]; let body = step.body.replace("{OS}", os_label()); let icon = Icon::Bottles @@ -712,7 +405,7 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { .width(160) .height(160) .style(|theme: &Theme, _| svg::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }); let text_block = column![ text(step.title) @@ -722,13 +415,13 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { ..iced::Font::DEFAULT }) .style(|theme: &Theme| text::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }), text(body).body().muted() ] .spacing(20) .max_width(TUTORIAL_TEXT_MAX_WIDTH); - let next = onboarding_button_with_icon("Next").on_press(Message::NextTutorialStep); + let next = onboarding_button_with_icon("Next").on_press(Action::NextTutorialStep); column![ row![icon, text_block].spacing(48).align_y(Vertical::Center), @@ -739,11 +432,11 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { .into() } -fn onboarding_button<'a>(label: &'a str) -> Button<'a, Message> { - Button::new(text(label).label()).kind(ButtonKind::Primary) +fn onboarding_button<'a>(label: &'a str) -> Button<'a, Action> { + Button::custom(text(label).label()).kind(ButtonKind::Primary) } -fn onboarding_button_with_icon<'a>(label: &'a str) -> Button<'a, Message> { +fn onboarding_button_with_icon<'a>(label: &'a str) -> Button<'a, Action> { onboarding_button(label) .trailing_icon(Icon::Arrow) .icon_rotation(std::f32::consts::PI) @@ -753,225 +446,10 @@ fn onboarding_button_with_icon<'a>(label: &'a str) -> Button<'a, Message> { #[cfg(test)] mod tests { use super::*; - use uuid::Uuid; - - fn download(slot: Slot, state: DownloadState) -> DownloadItem { - DownloadItem { - slot, - id: Some(Uuid::nil()), - label: String::new(), - size_label: String::new(), - progress: 0.0, - state, - } - } - - fn entry( - id: &str, - name: &str, - slot: Slot, - platform: Option<(&str, &str)>, - ) -> CatalogEntry { - let mut artifact = serde_json::json!({ - "url": "https://example.com/addon.tar.xz", - "file_name": "addon.tar.xz", - "checksum": { "algorithm": "sha256", "value": "00" }, - }); - if let Some((os, arch)) = platform { - artifact["platform"] = serde_json::json!({ "os": os, "arch": arch }); - } - - serde_json::from_value(serde_json::json!({ - "id": id, - "name": name, - "version": "1.0.0", - "artifacts": [artifact], - "slot": slot.as_str(), - })) - .unwrap() - } #[test] fn only_classic_is_available() { assert!(experience_available(Experience::Classic)); assert!(!experience_available(Experience::Next)); } - - #[test] - fn component_selection_skips_unsupported_entries() { - let other_os = if cfg!(target_os = "linux") { - "mac-os" - } else { - "linux" - }; - let unsupported = entry( - "77e90211-9091-47a9-bb00-0da6c2360981", - "WineBridge", - Slot::WineBridge, - Some((other_os, "x86_64")), - ); - let universal = entry( - "d87fd8b8-8230-4e64-a66f-8b0e1c70c694", - "WineBridge", - Slot::WineBridge, - None, - ); - let entries = [unsupported.clone(), universal.clone()]; - - assert_eq!( - supported_component(&entries, Slot::WineBridge).map(CatalogEntry::id), - Some(universal.id()) - ); - assert!(supported_component(&[unsupported], Slot::WineBridge).is_none()); - } - - #[test] - fn prepared_catalog_entries_are_pending_not_running() { - let catalog = [ - entry( - "d87fd8b8-8230-4e64-a66f-8b0e1c70c694", - "WineBridge", - Slot::WineBridge, - None, - ), - entry( - "77e90211-9091-47a9-bb00-0da6c2360981", - "Runner", - Slot::Runner, - None, - ), - ]; - - let downloads = build_download_items(&[], &catalog); - - assert_eq!(downloads.len(), ONBOARDING_SLOTS.len()); - for (item, (slot, _)) in downloads.iter().zip(ONBOARDING_SLOTS) { - assert_eq!(item.slot, *slot); - assert!(item.id.is_some()); - assert_eq!(item.progress, 0.0); - assert_eq!(item.size_label, "Ready to download"); - assert!(matches!(item.state, DownloadState::Pending)); - } - assert!( - downloads - .iter() - .all(|item| !matches!(item.state, DownloadState::Running(_))) - ); - } - - #[test] - fn setup_completes_only_when_every_required_download_succeeds() { - assert!(!downloads_complete(&[download( - Slot::WineBridge, - DownloadState::Running(CancellationToken::new()), - )])); - assert!(!downloads_complete(&[download( - Slot::WineBridge, - DownloadState::Cancelled, - )])); - assert!(!downloads_complete(&[download( - Slot::WineBridge, - DownloadState::Unavailable, - )])); - let succeeded = ONBOARDING_SLOTS - .iter() - .map(|(slot, _)| download(*slot, DownloadState::Succeeded)) - .collect::>(); - - assert!(downloads_complete(&succeeded)); - } - - #[test] - fn download_keys_match_generation_and_slot() { - let mut downloads = vec![ - download( - Slot::WineBridge, - DownloadState::Running(CancellationToken::new()), - ), - download( - Slot::Runner, - DownloadState::Running(CancellationToken::new()), - ), - ]; - - assert!( - current_download_mut(&mut downloads, 2, (1, Slot::Runner)).is_none(), - "a stale generation must not update any slot" - ); - assert_eq!( - current_download_mut(&mut downloads, 2, (2, Slot::Runner)).map(|item| item.slot), - Some(Slot::Runner) - ); - assert!(current_download_mut(&mut downloads, 2, (2, Slot::Dxvk)).is_none()); - } - - #[test] - fn cancellation_is_requested_without_discarding_the_running_token() { - let cancellation = CancellationToken::new(); - let downloads = [download( - Slot::WineBridge, - DownloadState::Running(cancellation.clone()), - )]; - - request_download_cancellation(&downloads); - - assert!(cancellation.is_cancelled()); - let DownloadState::Running(retained) = &downloads[0].state else { - panic!("cancellation must retain the running state until its terminal event"); - }; - assert!(retained.is_cancelled()); - } - - #[test] - fn cancelling_waits_for_every_terminal_event_then_restores_pending_rows() { - let mut phase = SetupPhase::Cancelling; - let mut downloads = vec![ - download( - Slot::WineBridge, - DownloadState::Running(CancellationToken::new()), - ), - download(Slot::Runner, DownloadState::Cancelled), - ]; - - settle_download_batch(&mut phase, &mut downloads); - assert!(matches!(phase, SetupPhase::Cancelling)); - assert!(matches!(downloads[1].state, DownloadState::Cancelled)); - - downloads[0].state = DownloadState::Succeeded; - settle_download_batch(&mut phase, &mut downloads); - - assert!(matches!(phase, SetupPhase::Ready)); - assert!(matches!(downloads[0].state, DownloadState::Succeeded)); - assert!(matches!(downloads[1].state, DownloadState::Pending)); - assert_eq!(downloads[1].size_label, "Ready to download"); - } - - #[test] - fn byte_sizes_use_binary_units() { - assert_eq!(format_bytes(0), "0 KB"); - assert_eq!(format_bytes(1024), "1 KB"); - assert_eq!(format_bytes(10 * 1024), "10 KB"); - assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); - assert_eq!(format_bytes(3 * 1024 * 1024 / 2), "1.5 MB"); - } - - #[test] - fn download_progress_uses_the_reported_http_total() { - let mut item = download(Slot::WineBridge, DownloadState::Pending); - item.size_label = "Ready to download".into(); - let progress = bottles_core::Progress { - stage: bottles_core::Stage::Downloading { - file: "winebridge.tar.xz".into(), - }, - transfer: Some(bottles_core::Transfer { - current: 2 * 1024 * 1024, - total: Some(4 * 1024 * 1024), - }), - }; - - update_download_progress(&mut item, &progress); - - assert_eq!(item.progress, 0.5); - assert_eq!(item.size_label, "4.0 MB"); - } } diff --git a/src/onboarding/setup.rs b/src/onboarding/setup.rs new file mode 100644 index 0000000..68f3baa --- /dev/null +++ b/src/onboarding/setup.rs @@ -0,0 +1,584 @@ +//! Runtime catalog preparation and component downloads for onboarding. + +use std::sync::Arc; + +use bottles_core::{Addons, CatalogEntry, Component, IndexEntry, Slot, error::Error as CoreError}; +use iced::Task; +use tokio_util::sync::CancellationToken; + +use crate::operation::{self, Event as OperationEvent, Outcome}; + +const REQUIRED_SLOTS: &[(Slot, &str)] = + &[(Slot::WineBridge, "WineBridge"), (Slot::Runner, "Runner")]; + +pub(super) struct DownloadItem { + slot: Slot, + id: Option, + pub(super) label: String, + pub(super) size_label: String, + pub(super) progress: f32, + pub(super) state: DownloadState, +} + +pub(super) enum DownloadState { + Pending, + Running(CancellationToken), + Succeeded, + Cancelled, + Unavailable, + Failed(Arc), +} + +pub(super) enum Phase { + Idle, + Preparing(CancellationToken), + Ready, + Downloading, + Cancelling, + Failed, + Complete, +} + +#[derive(Clone)] +pub(crate) enum Action { + StartDownloads, + CancelDownloads, + Retry, +} + +#[derive(Clone)] +pub(crate) enum Event { + CatalogRefresh(OperationEvent), + Download(OperationEvent<(u64, Slot), Arc>>), +} + +pub(super) struct Coordinator { + addons: Addons, + downloads: Vec, + generation: u64, + phase: Phase, + catalog_error: Option>, +} + +impl Coordinator { + pub(super) fn new(addons: Addons) -> Self { + Self { + addons, + downloads: Vec::new(), + generation: 0, + phase: Phase::Idle, + catalog_error: None, + } + } + + pub(super) fn phase(&self) -> &Phase { + &self.phase + } + + pub(super) fn downloads(&self) -> &[DownloadItem] { + &self.downloads + } + + pub(super) fn catalog_error(&self) -> Option<&Arc> { + self.catalog_error.as_ref() + } + + pub(super) fn has_active_operations(&self) -> bool { + matches!(self.phase, Phase::Preparing(_)) + || self + .downloads + .iter() + .any(|item| matches!(item.state, DownloadState::Running(_))) + } + + pub(super) fn cancel_active_operations(&self) { + if let Phase::Preparing(cancellation) = &self.phase { + cancellation.cancel(); + } + request_download_cancellation(&self.downloads); + } + + pub(super) fn prepare(&mut self) -> Task { + self.cancel_active_operations(); + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + self.downloads.clear(); + self.catalog_error = None; + + let (cancellation, task) = operation::run(self.addons.refresh(), generation); + self.phase = Phase::Preparing(cancellation); + + task.map(Event::CatalogRefresh) + } + + pub(super) fn update_action(&mut self, action: Action) -> Task { + match action { + Action::StartDownloads => self.start_downloads(), + Action::CancelDownloads => { + if matches!(self.phase, Phase::Downloading) { + request_download_cancellation(&self.downloads); + self.phase = Phase::Cancelling; + } + Task::none() + } + Action::Retry => { + if self + .downloads + .iter() + .any(|item| matches!(item.state, DownloadState::Unavailable)) + { + self.prepare() + } else { + self.start_downloads() + } + } + } + } + + pub(super) fn update_event(&mut self, event: Event) -> Task { + match event { + Event::CatalogRefresh(OperationEvent::Progress { .. }) => {} + Event::CatalogRefresh(OperationEvent::Finished { key, outcome }) => { + if key != self.generation { + return Task::none(); + } + + self.catalog_error = match outcome { + Outcome::Succeeded(()) | Outcome::Cancelled => None, + Outcome::Failed(error) => Some(error), + }; + self.prepare_downloads(); + } + Event::Download(OperationEvent::Progress { key, progress }) => { + if let Some(item) = current_download_mut(&mut self.downloads, self.generation, key) + && matches!(&item.state, DownloadState::Running(_)) + { + update_download_progress(item, &progress); + } + } + Event::Download(OperationEvent::Finished { key, outcome }) => { + if let Some(item) = current_download_mut(&mut self.downloads, self.generation, key) + { + item.state = match outcome { + Outcome::Succeeded(_) => { + item.progress = 1.0; + DownloadState::Succeeded + } + Outcome::Cancelled => DownloadState::Cancelled, + Outcome::Failed(error) => DownloadState::Failed(error), + }; + + settle_download_batch(&mut self.phase, &mut self.downloads); + } + } + } + + Task::none() + } + + fn prepare_downloads(&mut self) { + self.downloads = + build_download_items(&self.addons.components(), &self.addons.component_entries()); + + if downloads_complete(&self.downloads) { + self.phase = Phase::Complete; + self.catalog_error = None; + } else if self + .downloads + .iter() + .all(|item| !matches!(item.state, DownloadState::Unavailable)) + { + self.phase = Phase::Ready; + self.catalog_error = None; + } else { + self.phase = Phase::Failed; + } + } + + fn start_downloads(&mut self) -> Task { + if !matches!(self.phase, Phase::Ready | Phase::Failed) { + return Task::none(); + } + + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let addons = self.addons.clone(); + let mut tasks = Vec::new(); + + for item in &mut self.downloads { + if matches!(item.state, DownloadState::Succeeded) { + continue; + } + + let Some(id) = item.id else { + continue; + }; + + item.progress = 0.0; + item.size_label = "Ready to download".into(); + let (cancellation, task) = + operation::run(addons.fetch_component(id), (generation, item.slot)); + tasks.push(task.map(Event::Download)); + item.state = DownloadState::Running(cancellation); + } + + if tasks.is_empty() { + self.phase = if downloads_complete(&self.downloads) { + Phase::Complete + } else { + Phase::Failed + }; + Task::none() + } else { + self.catalog_error = None; + self.phase = Phase::Downloading; + Task::batch(tasks) + } + } +} + +fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + let bytes = bytes as f64; + + if bytes >= MB { + format!("{:.1} MB", bytes / MB) + } else { + format!("{:.0} KB", bytes / KB) + } +} + +fn downloads_complete(downloads: &[DownloadItem]) -> bool { + downloads.len() == REQUIRED_SLOTS.len() + && downloads + .iter() + .all(|item| matches!(&item.state, DownloadState::Succeeded)) +} + +fn build_download_items( + installed: &[Arc>], + catalog: &[CatalogEntry], +) -> Vec { + REQUIRED_SLOTS + .iter() + .map(|(slot, slot_label)| { + if let Some(entry) = installed.iter().find(|entry| entry.slot() == *slot) { + return DownloadItem { + slot: *slot, + id: Some(entry.id()), + label: format!("{} {}", entry.name(), entry.version()), + size_label: "Installed".into(), + progress: 1.0, + state: DownloadState::Succeeded, + }; + } + + let Some(entry) = supported_component(catalog, *slot) else { + return DownloadItem { + slot: *slot, + id: None, + label: (*slot_label).to_string(), + size_label: "Unavailable".into(), + progress: 0.0, + state: DownloadState::Unavailable, + }; + }; + + DownloadItem { + slot: *slot, + id: Some(entry.id()), + label: format!("{} {}", entry.name(), entry.version()), + size_label: "Ready to download".into(), + progress: 0.0, + state: DownloadState::Pending, + } + }) + .collect() +} + +fn current_download_mut( + downloads: &mut [DownloadItem], + current_generation: u64, + (generation, slot): (u64, Slot), +) -> Option<&mut DownloadItem> { + if generation != current_generation { + return None; + } + downloads.iter_mut().find(|item| item.slot == slot) +} + +fn update_download_progress(item: &mut DownloadItem, progress: &bottles_core::Progress) { + if let Some(fraction) = progress.fraction() { + item.progress = fraction; + } + if let Some(total) = progress.transfer.and_then(|transfer| transfer.total) { + item.size_label = format_bytes(total); + } +} + +fn settle_download_batch(phase: &mut Phase, downloads: &mut [DownloadItem]) { + if downloads + .iter() + .any(|item| matches!(item.state, DownloadState::Running(_))) + { + return; + } + + if matches!(phase, Phase::Cancelling) { + for item in &mut *downloads { + if matches!(item.state, DownloadState::Cancelled) { + item.state = DownloadState::Pending; + item.size_label = "Ready to download".into(); + } + } + } + + *phase = if downloads_complete(downloads) { + Phase::Complete + } else if downloads + .iter() + .any(|item| matches!(item.state, DownloadState::Failed(_))) + { + Phase::Failed + } else { + Phase::Ready + }; +} + +fn request_download_cancellation(downloads: &[DownloadItem]) { + for item in downloads { + if let DownloadState::Running(cancellation) = &item.state { + cancellation.cancel(); + } + } +} + +fn supported_component( + entries: &[CatalogEntry], + slot: Slot, +) -> Option<&CatalogEntry> { + entries + .iter() + .find(|entry| entry.slot() == slot && entry.is_supported()) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn download(slot: Slot, state: DownloadState) -> DownloadItem { + DownloadItem { + slot, + id: Some(Uuid::nil()), + label: String::new(), + size_label: String::new(), + progress: 0.0, + state, + } + } + + fn entry( + id: &str, + name: &str, + slot: Slot, + platform: Option<(&str, &str)>, + ) -> CatalogEntry { + let mut artifact = serde_json::json!({ + "url": "https://example.com/addon.tar.xz", + "file_name": "addon.tar.xz", + "checksum": { "algorithm": "sha256", "value": "00" }, + }); + if let Some((os, arch)) = platform { + artifact["platform"] = serde_json::json!({ "os": os, "arch": arch }); + } + + serde_json::from_value(serde_json::json!({ + "id": id, + "name": name, + "version": "1.0.0", + "artifacts": [artifact], + "slot": slot.as_str(), + })) + .unwrap() + } + + #[test] + fn component_selection_skips_unsupported_entries() { + let other_os = if cfg!(target_os = "linux") { + "mac-os" + } else { + "linux" + }; + let unsupported = entry( + "77e90211-9091-47a9-bb00-0da6c2360981", + "WineBridge", + Slot::WineBridge, + Some((other_os, "x86_64")), + ); + let universal = entry( + "d87fd8b8-8230-4e64-a66f-8b0e1c70c694", + "WineBridge", + Slot::WineBridge, + None, + ); + let entries = [unsupported.clone(), universal.clone()]; + + assert_eq!( + supported_component(&entries, Slot::WineBridge).map(CatalogEntry::id), + Some(universal.id()) + ); + assert!(supported_component(&[unsupported], Slot::WineBridge).is_none()); + } + + #[test] + fn prepared_catalog_entries_are_pending_not_running() { + let catalog = [ + entry( + "d87fd8b8-8230-4e64-a66f-8b0e1c70c694", + "WineBridge", + Slot::WineBridge, + None, + ), + entry( + "77e90211-9091-47a9-bb00-0da6c2360981", + "Runner", + Slot::Runner, + None, + ), + ]; + + let downloads = build_download_items(&[], &catalog); + + assert_eq!(downloads.len(), REQUIRED_SLOTS.len()); + for (item, (slot, _)) in downloads.iter().zip(REQUIRED_SLOTS) { + assert_eq!(item.slot, *slot); + assert!(item.id.is_some()); + assert_eq!(item.progress, 0.0); + assert_eq!(item.size_label, "Ready to download"); + assert!(matches!(item.state, DownloadState::Pending)); + } + assert!( + downloads + .iter() + .all(|item| !matches!(item.state, DownloadState::Running(_))) + ); + } + + #[test] + fn setup_completes_only_when_every_required_download_succeeds() { + assert!(!downloads_complete(&[download( + Slot::WineBridge, + DownloadState::Running(CancellationToken::new()), + )])); + assert!(!downloads_complete(&[download( + Slot::WineBridge, + DownloadState::Cancelled, + )])); + assert!(!downloads_complete(&[download( + Slot::WineBridge, + DownloadState::Unavailable, + )])); + let succeeded = REQUIRED_SLOTS + .iter() + .map(|(slot, _)| download(*slot, DownloadState::Succeeded)) + .collect::>(); + + assert!(downloads_complete(&succeeded)); + } + + #[test] + fn download_keys_match_generation_and_slot() { + let mut downloads = vec![ + download( + Slot::WineBridge, + DownloadState::Running(CancellationToken::new()), + ), + download( + Slot::Runner, + DownloadState::Running(CancellationToken::new()), + ), + ]; + + assert!( + current_download_mut(&mut downloads, 2, (1, Slot::Runner)).is_none(), + "a stale generation must not update any slot" + ); + assert_eq!( + current_download_mut(&mut downloads, 2, (2, Slot::Runner)).map(|item| item.slot), + Some(Slot::Runner) + ); + assert!(current_download_mut(&mut downloads, 2, (2, Slot::Dxvk)).is_none()); + } + + #[test] + fn cancellation_is_requested_without_discarding_the_running_token() { + let cancellation = CancellationToken::new(); + let downloads = [download( + Slot::WineBridge, + DownloadState::Running(cancellation.clone()), + )]; + + request_download_cancellation(&downloads); + + assert!(cancellation.is_cancelled()); + let DownloadState::Running(retained) = &downloads[0].state else { + panic!("cancellation must retain the running state until its terminal event"); + }; + assert!(retained.is_cancelled()); + } + + #[test] + fn cancelling_waits_for_every_terminal_event_then_restores_pending_rows() { + let mut phase = Phase::Cancelling; + let mut downloads = vec![ + download( + Slot::WineBridge, + DownloadState::Running(CancellationToken::new()), + ), + download(Slot::Runner, DownloadState::Cancelled), + ]; + + settle_download_batch(&mut phase, &mut downloads); + assert!(matches!(phase, Phase::Cancelling)); + assert!(matches!(downloads[1].state, DownloadState::Cancelled)); + + downloads[0].state = DownloadState::Succeeded; + settle_download_batch(&mut phase, &mut downloads); + + assert!(matches!(phase, Phase::Ready)); + assert!(matches!(downloads[0].state, DownloadState::Succeeded)); + assert!(matches!(downloads[1].state, DownloadState::Pending)); + assert_eq!(downloads[1].size_label, "Ready to download"); + } + + #[test] + fn byte_sizes_use_binary_units() { + assert_eq!(format_bytes(0), "0 KB"); + assert_eq!(format_bytes(1024), "1 KB"); + assert_eq!(format_bytes(10 * 1024), "10 KB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(format_bytes(3 * 1024 * 1024 / 2), "1.5 MB"); + } + + #[test] + fn download_progress_uses_the_reported_http_total() { + let mut item = download(Slot::WineBridge, DownloadState::Pending); + item.size_label = "Ready to download".into(); + let progress = bottles_core::Progress { + stage: bottles_core::Stage::Downloading { + file: "winebridge.tar.xz".into(), + }, + transfer: Some(bottles_core::Transfer { + current: 2 * 1024 * 1024, + total: Some(4 * 1024 * 1024), + }), + }; + + update_download_progress(&mut item, &progress); + + assert_eq!(item.progress, 0.5); + assert_eq!(item.size_label, "4.0 MB"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 5409ad4..ac10d14 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,7 @@ +//! Semantic colors for the toolkit. + use iced::{ - Background, Border, Color, Theme, + Background, Border, Color, Theme as IcedTheme, theme::{ Mode, Palette, Style as ApplicationStyle, palette::{ @@ -45,7 +47,31 @@ const TEXT_LIGHT: Color = Color::from_rgb8(36, 28, 31); const ACCENT_LIGHT: Color = Color::from_rgb8(168, 76, 104); const ACCENT_MUTED_LIGHT: Color = Color::from_rgb8(196, 150, 164); -pub fn dark() -> Theme { +/// Semantic colors used by toolkit widgets. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Colors { + pub background: Color, + pub deep_background: Color, + pub surface: Color, + pub border: Color, + pub hover: Color, + pub selection: Color, + pub window: Color, + pub window_border: Color, + pub surface_deep: Color, + pub text: Color, + pub muted: Color, + pub accent: Color, + pub accent_muted: Color, + pub scrim: Color, + pub danger: Color, + pub danger_surface: Color, + pub warning_surface: Color, + pub success_surface: Color, +} + +/// Builds the dark Iced appearance used by the toolkit. +pub fn dark() -> IcedTheme { custom( "Bottles Next", Palette { @@ -97,7 +123,8 @@ pub fn dark() -> Theme { ) } -pub fn light() -> Theme { +/// Builds the light Iced appearance used by the toolkit. +pub fn light() -> IcedTheme { custom( "Bottles Next Light", Palette { @@ -149,62 +176,87 @@ pub fn light() -> Theme { ) } -pub(crate) fn for_mode(mode: Mode) -> Theme { +/// Resolves semantic toolkit colors from an Iced appearance. +pub fn colors(theme: &IcedTheme) -> Colors { + let extended = theme.extended_palette(); + let text = theme.palette().text; + let scrim = if extended.is_dark { + extended.background.weakest.color + } else { + text + } + .scale_alpha(171.0 / 255.0); + + Colors { + background: extended.background.base.color, + deep_background: extended.background.weakest.color, + surface: extended.background.weak.color, + border: extended.background.neutral.color, + hover: extended.background.strong.color, + selection: extended.background.stronger.color, + window: extended.primary.weak.color, + window_border: extended.background.strongest.color, + surface_deep: extended.secondary.strong.color, + text, + muted: extended.secondary.weak.text, + accent: theme.palette().primary, + accent_muted: extended.primary.strong.color, + scrim, + danger: theme.palette().danger, + danger_surface: theme.palette().danger, + warning_surface: theme.palette().warning, + success_surface: theme.palette().success, + } +} + +#[doc(hidden)] +pub fn for_mode(mode: Mode) -> IcedTheme { match mode { Mode::Light => light(), Mode::Dark | Mode::None => dark(), } } -pub(crate) fn hint(theme: &Theme) -> Pair { - theme.extended_palette().primary.weak +pub(crate) fn hint(theme: &IcedTheme) -> Pair { + let colors = colors(theme); + pair(colors.window, colors.text) } -pub(crate) fn deep_surface(theme: &Theme) -> Pair { - Pair { - color: theme.extended_palette().secondary.strong.color, - text: theme.palette().text, - } +pub(crate) fn deep_surface(theme: &IcedTheme) -> Pair { + let colors = colors(theme); + pair(colors.surface_deep, colors.text) } -pub(crate) fn muted(theme: &Theme) -> Color { - theme.extended_palette().primary.base.color +pub(crate) fn muted(theme: &IcedTheme) -> Color { + colors(theme).muted } -pub(crate) fn window_color(theme: &Theme) -> Color { - theme.extended_palette().primary.weak.color +pub(crate) fn scrim(theme: &IcedTheme) -> Color { + colors(theme).scrim } -pub(crate) fn window(theme: &Theme) -> container::Style { - container::Style { - background: Some(Background::Color(window_color(theme))), - border: Border::default() - .rounded(6) - .color(theme.extended_palette().background.strongest.color) - .width(1), - ..container::Style::default() +/// Styles the transparent Iced application root. +pub fn application(theme: &IcedTheme) -> ApplicationStyle { + ApplicationStyle { + background_color: Color::TRANSPARENT, + text_color: colors(theme).text, } } -pub(crate) fn scrim(theme: &Theme) -> Color { - let color = if theme.extended_palette().is_dark { - theme.extended_palette().background.weakest.color - } else { - theme.palette().text - }; - - color.scale_alpha(171.0 / 255.0) +/// Styles a standard application panel. +pub fn panel(theme: &IcedTheme) -> container::Style { + let colors = colors(theme); + surface(pair(colors.background, colors.text)) } -pub fn application(theme: &Theme) -> ApplicationStyle { - ApplicationStyle { - background_color: Color::TRANSPARENT, - text_color: theme.palette().text, - } +pub(crate) fn card(theme: &IcedTheme) -> container::Style { + let colors = colors(theme); + surface(pair(colors.surface, colors.text)) } -pub fn panel(theme: &Theme) -> container::Style { - surface(theme.extended_palette().background.weaker) +pub(crate) fn overlay(theme: &IcedTheme) -> container::Style { + let colors = colors(theme); + surface(pair(colors.border, colors.text)) } pub(crate) fn surface(colors: Pair) -> container::Style { @@ -214,7 +266,8 @@ pub(crate) fn surface(colors: Pair) -> container::Style { .border(Border::default().rounded(6)) } -pub fn scrollbar(theme: &Theme, status: scrollable::Status) -> scrollable::Style { +/// Styles a scrollbar using semantic muted colors. +pub fn scrollbar(theme: &IcedTheme, status: scrollable::Status) -> scrollable::Style { let mut style = scrollable::default(theme, status); let rail = scrollable::Rail { background: None, @@ -234,8 +287,8 @@ pub(crate) const fn info() -> Pair { pair(INFO, WHITE) } -fn custom(name: &'static str, palette: Palette, extended: Extended) -> Theme { - Theme::custom_with_fn(name, palette, move |_| extended) +fn custom(name: &'static str, palette: Palette, extended: Extended) -> IcedTheme { + IcedTheme::custom_with_fn(name, palette, move |_| extended) } const fn pair(color: Color, text: Color) -> Pair { diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs deleted file mode 100644 index 91d6583..0000000 --- a/src/ui/chrome.rs +++ /dev/null @@ -1,185 +0,0 @@ -use iced::{Element, Fill, Task, widget::container, window}; - -use iced::{ - alignment::{Horizontal, Vertical}, - mouse, - widget::{Space, mouse_area, stack}, - window::Direction, -}; - -use crate::{ - icons::Icon, - theme, - widgets::button::{Button, ButtonKind}, -}; - -const RESIZE_EDGE: f32 = 6.0; -const RESIZE_CORNER: f32 = 12.0; -const PANEL_INSET: [f32; 2] = [6.0, 8.0]; -const WINDOW_CONTROL_INSET: [f32; 2] = [22.0, 20.0]; -pub(crate) const WINDOW_CONTROL_SIZE: f32 = 32.0; - -pub(crate) const WINDOW_CONTROL_AT_START: bool = cfg!(target_os = "macos"); - -#[derive(Debug, Clone, Copy)] -pub enum Action { - Drag, - Resize(window::Direction), - RequestClose, -} - -impl Action { - /// Returns the direct window operation for this action. - /// - /// A close request returns `None` so the application can shut down its - /// services before exiting. - pub fn task(self) -> Option> { - Some(match self { - Self::Drag => window::latest().and_then(window::drag), - Self::Resize(direction) => { - window::latest().and_then(move |id| window::drag_resize(id, direction)) - } - Self::RequestClose => return None, - }) - } -} - -pub struct WindowFrame<'a, Message> { - content: Element<'a, Message>, - on_action: Box Message + 'a>, -} - -impl<'a, Message> WindowFrame<'a, Message> { - pub fn new( - content: impl Into>, - on_action: impl Fn(Action) -> Message + 'a, - ) -> Self { - Self { - content: content.into(), - on_action: Box::new(on_action), - } - } -} - -impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { - fn from(frame: WindowFrame<'a, Message>) -> Self { - let content: Element<'a, Message> = container(frame.content) - .width(Fill) - .height(Fill) - .padding(PANEL_INSET) - .style(theme::window) - .clip(true) - .into(); - - let mut layers = stack![content].width(Fill).height(Fill).clip(true); - - for direction in [ - Direction::North, - Direction::South, - Direction::East, - Direction::West, - Direction::NorthEast, - Direction::NorthWest, - Direction::SouthEast, - Direction::SouthWest, - ] { - layers = layers.push(resize_edge( - direction, - (frame.on_action)(Action::Resize(direction)), - )) - } - - let close = Button::icon_only("Close window", Icon::Cross) - .diameter(WINDOW_CONTROL_SIZE) - .icon_size(16.0) - .kind(ButtonKind::Transparent) - .on_press((frame.on_action)(Action::RequestClose)); - let close = container(close) - .width(Fill) - .height(Fill) - .align_x(if WINDOW_CONTROL_AT_START { - Horizontal::Left - } else { - Horizontal::Right - }) - .align_y(Vertical::Top) - .padding(WINDOW_CONTROL_INSET); - - layers.push(close).into() - } -} - -fn resize_edge<'a, Message: Clone + 'a>( - direction: Direction, - message: Message, -) -> Element<'a, Message> { - let (width, height, interaction, horizontal, vertical) = match direction { - Direction::North => ( - Fill, - RESIZE_EDGE.into(), - mouse::Interaction::ResizingVertically, - Horizontal::Left, - Vertical::Top, - ), - Direction::South => ( - Fill, - RESIZE_EDGE.into(), - mouse::Interaction::ResizingVertically, - Horizontal::Left, - Vertical::Bottom, - ), - Direction::East => ( - RESIZE_EDGE.into(), - Fill, - mouse::Interaction::ResizingHorizontally, - Horizontal::Right, - Vertical::Top, - ), - Direction::West => ( - RESIZE_EDGE.into(), - Fill, - mouse::Interaction::ResizingHorizontally, - Horizontal::Left, - Vertical::Top, - ), - Direction::NorthEast => ( - RESIZE_CORNER.into(), - RESIZE_CORNER.into(), - mouse::Interaction::ResizingDiagonallyUp, - Horizontal::Right, - Vertical::Top, - ), - Direction::NorthWest => ( - RESIZE_CORNER.into(), - RESIZE_CORNER.into(), - mouse::Interaction::ResizingDiagonallyDown, - Horizontal::Left, - Vertical::Top, - ), - Direction::SouthEast => ( - RESIZE_CORNER.into(), - RESIZE_CORNER.into(), - mouse::Interaction::ResizingDiagonallyDown, - Horizontal::Right, - Vertical::Bottom, - ), - Direction::SouthWest => ( - RESIZE_CORNER.into(), - RESIZE_CORNER.into(), - mouse::Interaction::ResizingDiagonallyUp, - Horizontal::Left, - Vertical::Bottom, - ), - }; - - container( - mouse_area(Space::new().width(width).height(height)) - .on_press(message) - .interaction(interaction), - ) - .width(Fill) - .height(Fill) - .align_x(horizontal) - .align_y(vertical) - .into() -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs deleted file mode 100644 index e5a8a46..0000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Low-level application UI facilities shared across experience boundaries. - -pub mod chrome; diff --git a/src/widgets/action_row.rs b/src/widgets/action_row.rs index bf87574..a0bfe92 100644 --- a/src/widgets/action_row.rs +++ b/src/widgets/action_row.rs @@ -1,6 +1,9 @@ use iced::{ Alignment, Element, - widget::{column, row, text}, + widget::{ + column, row, text, + text::{Fragment, IntoFragment}, + }, }; use crate::icons::Icon; @@ -12,33 +15,40 @@ use super::{ text::TextExt as _, }; -#[derive(Debug, Clone)] -pub enum State { +enum State { Ready(Message), Disabled, - /// In-flight state (e.g. downloading a component, linking an account), `0.0..=1.0`. Progress(f32), } pub struct ActionRow<'a, Message> { - title: &'a str, - description: &'a str, + title: Fragment<'a>, + description: Fragment<'a>, icon: Option, state: State, + selected: bool, } impl<'a, Message> ActionRow<'a, Message> { - pub fn new(title: &'a str, state: State) -> Self { + pub fn new(title: impl IntoFragment<'a>, action: impl Into>) -> Self { Self { - title, - description: "", + title: title.into_fragment(), + description: "".into_fragment(), icon: None, - state, + state: action.into().map_or(State::Disabled, State::Ready), + selected: false, } } - pub fn description(mut self, description: &'a str) -> Self { - self.description = description; + pub fn progress(title: impl IntoFragment<'a>, progress: f32) -> Self { + Self { + state: State::Progress(progress), + ..Self::new(title, None) + } + } + + pub fn description(mut self, description: impl IntoFragment<'a>) -> Self { + self.description = description.into_fragment(); self } @@ -46,19 +56,24 @@ impl<'a, Message> ActionRow<'a, Message> { self.icon = Some(icon); self } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } } impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { fn from(action: ActionRow<'a, Message>) -> Self { - ListRow::from(action).into() + action.lower() } } -impl<'a, Message: Clone + 'a> From> for ListRow<'a, Message> { - fn from(action: ActionRow<'a, Message>) -> Self { +impl<'a, Message: Clone + 'a> ActionRow<'a, Message> { + fn lower(self) -> Element<'a, Message> { let mut description = row![].spacing(spacing::SM).align_y(Alignment::Center); - if let Some(icon) = action.icon { + if let Some(icon) = self.icon { description = description.push( icon.view() .width(list_row::BODY_SIZE) @@ -66,21 +81,24 @@ impl<'a, Message: Clone + 'a> From> for ListRow<'a, Messa ); } - description = description.push(text(action.description).size(list_row::BODY_SIZE).muted()); + description = description.push(text(self.description).size(list_row::BODY_SIZE).muted()); - let labels = column![text(action.title).label().medium(), description].spacing(spacing::XS); - let trailing: Element<'a, Message> = match &action.state { - State::Progress(progress) => ProgressRing::new(*progress).into(), - State::Ready(_) | State::Disabled => { - Icon::Arrow.view().rotation(std::f32::consts::PI).into() - } + let labels = column![text(self.title).label().medium(), description].spacing(spacing::XS); + let (trailing, action): (Element<'a, Message>, _) = match self.state { + State::Ready(message) => ( + Icon::Arrow.view().rotation(std::f32::consts::PI).into(), + Some(message), + ), + State::Disabled => ( + Icon::Arrow.view().rotation(std::f32::consts::PI).into(), + None, + ), + State::Progress(progress) => (ProgressRing::new(progress).into(), None), }; - let row = ListRow::new(labels).trailing(trailing); - - match action.state { - State::Ready(message) => row.on_press(message), - State::Disabled => row.enabled(false), - State::Progress(_) => row, - } + list_row::action( + ListRow::new(labels).trailing(trailing), + action, + self.selected, + ) } } diff --git a/src/widgets/drop_target.rs b/src/widgets/action_tile.rs similarity index 55% rename from src/widgets/drop_target.rs rename to src/widgets/action_tile.rs index 8e5a4a9..78350b9 100644 --- a/src/widgets/drop_target.rs +++ b/src/widgets/action_tile.rs @@ -1,28 +1,38 @@ use iced::{ - Element, Length, Padding, Point, Rectangle, Renderer, Theme, border, - widget::{canvas, container, stack}, + Background, Border, Element, Length, Padding, Point, Rectangle, Renderer, Theme, border, + widget::{button, canvas, container, stack}, }; -use super::{Control, style}; +use super::focusable_action::{ActionState, FocusableAction}; const BORDER_RADIUS: f32 = 6.0; const BORDER_WIDTH: f32 = 2.0; const DASH_PATTERN: &[f32] = &[6.0, 6.0]; -/// An interactive surface with a dashed outline. -pub struct DropTarget<'a, Message> { +/// A dashed activation surface. +pub struct ActionTile<'a, Message> { content: Element<'a, Message>, - on_activate: Message, + on_activate: Option, width: Length, height: Length, padding: Padding, } -impl<'a, Message> DropTarget<'a, Message> { - pub fn new(content: impl Into>, on_activate: Message) -> Self { +impl<'a, Message> ActionTile<'a, Message> { + pub fn new(content: impl Into>, message: Message) -> Self { Self { content: content.into(), - on_activate, + on_activate: Some(message), + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::ZERO, + } + } + + pub fn disabled(content: impl Into>) -> Self { + Self { + content: content.into(), + on_activate: None, width: Length::Shrink, height: Length::Shrink, padding: Padding::ZERO, @@ -45,9 +55,9 @@ impl<'a, Message> DropTarget<'a, Message> { } } -impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { - fn from(target: DropTarget<'a, Message>) -> Self { - let DropTarget { +impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { + fn from(target: ActionTile<'a, Message>) -> Self { + let ActionTile { content, on_activate, width, @@ -66,15 +76,32 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Mess .width(width) .height(height); - Control::new(content) - .on_press(on_activate) + FocusableAction::new(content) + .on_press_maybe(on_activate) .width(width) .height(height) - .style(style::action) + .style(style) .into() } } +fn style(theme: &Theme, state: ActionState) -> button::Style { + let colors = crate::theme::colors(theme); + + button::Style { + background: if state.pressed { + Some(Background::Color(colors.selection)) + } else if state.hovered || state.focused { + Some(Background::Color(colors.hover)) + } else { + None + }, + text_color: colors.text, + border: Border::default().rounded(BORDER_RADIUS), + ..button::Style::default() + } +} + struct Outline; impl canvas::Program for Outline { @@ -108,10 +135,26 @@ impl canvas::Program for Outline { }, ..canvas::Stroke::default() .with_width(BORDER_WIDTH) - .with_color(theme.extended_palette().background.neutral.color) + .with_color(crate::theme::colors(theme).border) }, ); vec![frame.into_geometry()] } } + +#[cfg(test)] +mod tests { + use iced::widget::Space; + + use super::*; + + #[test] + fn constructors_make_activation_explicit() { + let disabled: ActionTile<'_, ()> = ActionTile::disabled(Space::new()); + assert!(disabled.on_activate.is_none()); + + let active = ActionTile::new(Space::new(), ()); + assert_eq!(active.on_activate, Some(())); + } +} diff --git a/src/widgets/anchored_overlay.rs b/src/widgets/anchored_overlay.rs index e5e8af1..9a63fc3 100644 --- a/src/widgets/anchored_overlay.rs +++ b/src/widgets/anchored_overlay.rs @@ -2,41 +2,67 @@ use iced::{ Event, Point, Rectangle, Size, Theme, Vector, advanced::{ Clipboard, Layout, Shell, Widget, layout, mouse, overlay, renderer, - widget::{Operation, Tree}, + widget::{Id, Operation, Tree, operation}, }, keyboard::{self, key}, touch, }; -use super::{control::focus_first_descendant, spacing}; +use super::{ + focus::{descendant_is_focused, focus_descendant, unfocus_descendants}, + spacing, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Dismissal { +pub(super) enum DismissReason { OutsidePress, Escape, - ContentMessage, + ContentMessage { keyboard: bool }, + FocusLost, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DismissOutcome { + PassThrough, + Captured, + CapturedAndRestoreFocus, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) enum PopupFocusState { + #[default] + None, + Requested, + Target(usize), +} + +pub(super) struct PopupFocus<'a> { + pub(super) state: &'a mut PopupFocusState, + pub(super) restore_anchor: &'a mut bool, + pub(super) targets: &'a [Id], } /// Positions arbitrary content relative to another widget. -pub(super) struct AnchoredOverlay<'a, Message> { +pub(super) struct PopupCore<'a, Message> { anchor: Rectangle, viewport: Rectangle, content: &'a mut dyn Widget, tree: &'a mut Tree, viewport_inset: f32, - focus_content: Option<&'a mut bool>, - on_dismiss: Box bool + 'a>, + match_anchor_width: bool, + focus: PopupFocus<'a>, + on_dismiss: Box DismissOutcome + 'a>, } -impl<'a, Message> AnchoredOverlay<'a, Message> { +impl<'a, Message> PopupCore<'a, Message> { pub(super) fn new( anchor: Rectangle, viewport: Rectangle, content: &'a mut dyn Widget, tree: &'a mut Tree, viewport_inset: f32, - focus_content: Option<&'a mut bool>, - on_dismiss: impl FnMut(Dismissal) -> bool + 'a, + focus: PopupFocus<'a>, + on_dismiss: impl FnMut(DismissReason) -> DismissOutcome + 'a, ) -> Self { Self { anchor, @@ -44,19 +70,37 @@ impl<'a, Message> AnchoredOverlay<'a, Message> { content, tree, viewport_inset, - focus_content, + match_anchor_width: false, + focus, on_dismiss: Box::new(on_dismiss), } } - fn dismiss(&mut self, reason: Dismissal) -> bool { - let capture = (self.on_dismiss)(reason); + pub(super) fn match_anchor_width(mut self) -> Self { + self.match_anchor_width = true; + self + } + + fn dismiss(&mut self, reason: DismissReason) -> DismissOutcome { + let outcome = (self.on_dismiss)(reason); - if capture && let Some(focus_content) = &mut self.focus_content { - **focus_content = false; + *self.focus.state = PopupFocusState::None; + if outcome == DismissOutcome::CapturedAndRestoreFocus { + *self.focus.restore_anchor = true; } - capture + outcome + } + + fn focus(&mut self, index: usize, layout: Layout<'_>, renderer: &iced::Renderer) { + let Some(target) = self.focus.targets.get(index) else { + *self.focus.state = PopupFocusState::None; + return; + }; + + focus_descendant(self.content, self.tree, layout, renderer, target.clone()); + *self.focus.state = PopupFocusState::Target(index); + reveal(self.content, self.tree, layout, renderer, target); } } @@ -84,18 +128,18 @@ fn geometry(anchor: Rectangle, viewport: Rectangle, inset: f32) -> Geometry { } } -fn dismissal(event: &Event, cursor: mouse::Cursor, bounds: Rectangle) -> Option { +fn dismissal(event: &Event, cursor: mouse::Cursor, bounds: Rectangle) -> Option { match event { Event::Mouse(mouse::Event::ButtonPressed(_)) => (!cursor.is_over(bounds) && cursor.position().is_some()) - .then_some(Dismissal::OutsidePress), + .then_some(DismissReason::OutsidePress), Event::Touch(touch::Event::FingerPressed { position, .. }) => { - (!bounds.contains(*position)).then_some(Dismissal::OutsidePress) + (!bounds.contains(*position)).then_some(DismissReason::OutsidePress) } Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(key::Named::Escape), .. - }) => Some(Dismissal::Escape), + }) => Some(DismissReason::Escape), _ => None, } } @@ -125,18 +169,26 @@ impl Geometry { } impl iced::advanced::Overlay - for AnchoredOverlay<'_, Message> + for PopupCore<'_, Message> { fn layout(&mut self, renderer: &iced::Renderer, bounds: Size) -> layout::Node { let bounds = Rectangle::with_size(bounds); let viewport = self.viewport.intersection(&bounds).unwrap_or(bounds); let geometry = geometry(self.anchor, viewport, self.viewport_inset); let max_height = geometry.below.max(geometry.above).max(0.0); - let content = self.content.layout( - self.tree, - renderer, - &layout::Limits::new(Size::ZERO, Size::new(geometry.max_width, max_height)), + let width = self.anchor.width.min(geometry.max_width); + let min = Size::new(if self.match_anchor_width { width } else { 0.0 }, 0.0); + let max = Size::new( + if self.match_anchor_width { + width + } else { + geometry.max_width + }, + max_height, ); + let content = self + .content + .layout(self.tree, renderer, &layout::Limits::new(min, max)); let size = content.size(); content.move_to(geometry.position( @@ -157,21 +209,42 @@ impl iced::advanced::Overlay shell: &mut Shell<'_, Message>, ) { if let Some(reason) = dismissal(event, cursor, layout.bounds()) { - if self.dismiss(reason) { + let outcome = self.dismiss(reason); + unfocus_descendants(self.content, self.tree, layout, renderer); + + if outcome != DismissOutcome::PassThrough { shell.capture_event(); - shell.request_redraw(); } + shell.request_redraw(); return; } - if let Some(focus_content) = &mut self.focus_content - && **focus_content - { - focus_first_descendant(self.content, self.tree, layout, renderer); + if *self.focus.state == PopupFocusState::Requested { + self.focus(0, layout, renderer); + shell.request_redraw(); + } - **focus_content = false; + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key::Named::Tab), + modifiers, + repeat: false, + .. + }) = event + && let PopupFocusState::Target(current) = *self.focus.state + && !self.focus.targets.is_empty() + && descendant_is_focused(self.content, self.tree, layout, renderer) + { + let count = self.focus.targets.len(); + let next = if modifiers.shift() { + (current + count - 1) % count + } else { + (current + 1) % count + }; + self.focus(next, layout, renderer); + shell.capture_event(); shell.request_redraw(); + return; } let mut messages = Vec::new(); @@ -188,8 +261,23 @@ impl iced::advanced::Overlay &self.viewport, ); - if !panel_shell.is_empty() && self.dismiss(Dismissal::ContentMessage) { - panel_shell.capture_event(); + let lost_focus = matches!(*self.focus.state, PopupFocusState::Target(_)) + && !descendant_is_focused(self.content, self.tree, layout, renderer); + + if lost_focus && panel_shell.is_empty() && !panel_shell.is_event_captured() { + self.dismiss(DismissReason::FocusLost); + panel_shell.request_redraw(); + } + + if !panel_shell.is_empty() { + let outcome = self.dismiss(DismissReason::ContentMessage { + keyboard: matches!(event, Event::Keyboard(_)), + }); + unfocus_descendants(self.content, self.tree, layout, renderer); + + if outcome != DismissOutcome::PassThrough { + panel_shell.capture_event(); + } panel_shell.request_redraw(); } @@ -247,6 +335,103 @@ impl iced::advanced::Overlay } } +fn reveal( + content: &mut dyn Widget, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + marker: &Id, +) -> bool { + let mut find = FindTargetBounds { + target: marker.clone(), + bounds: None, + }; + content.operate(tree, layout, renderer, &mut find); + let Some(bounds) = find.bounds else { + return false; + }; + let mut reveal = RevealBounds { + bounds, + changed: false, + }; + content.operate(tree, layout, renderer, &mut reveal); + reveal.changed +} + +struct FindTargetBounds { + target: Id, + bounds: Option, +} + +impl Operation for FindTargetBounds { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if id == Some(&self.target) { + self.bounds = Some(bounds); + } + } +} + +struct RevealBounds { + bounds: Rectangle, + changed: bool, +} + +impl Operation for RevealBounds { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn scrollable( + &mut self, + _id: Option<&Id>, + bounds: Rectangle, + content_bounds: Rectangle, + translation: Vector, + state: &mut dyn operation::Scrollable, + ) { + if !content_bounds.contains(self.bounds.center()) { + return; + } + + let target = Rectangle { + x: self.bounds.x - translation.x, + y: self.bounds.y - translation.y, + ..self.bounds + }; + let x = if target.x < bounds.x { + target.x - bounds.x + } else if target.x + target.width > bounds.x + bounds.width { + target.x + target.width - (bounds.x + bounds.width) + } else { + 0.0 + }; + let y = if target.y < bounds.y { + target.y - bounds.y + } else if target.y + target.height > bounds.y + bounds.height { + target.y + target.height - (bounds.y + bounds.height) + } else { + 0.0 + }; + let delta = Vector::new(x, y); + + if delta != Vector::ZERO { + state.scroll_by( + operation::scrollable::AbsoluteOffset { + x: delta.x, + y: delta.y, + }, + bounds, + content_bounds, + ); + self.changed = true; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -315,7 +500,7 @@ mod tests { mouse::Cursor::Unavailable, Rectangle::new(Point::new(10.0, 10.0), Size::new(100.0, 100.0)), ), - Some(Dismissal::OutsidePress) + Some(DismissReason::OutsidePress) ); } } diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 63748f4..7218d15 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -1,13 +1,13 @@ use iced::{ Background, Border, Center, Element, Fill, Length, Theme, theme::palette::Pair, - widget::{Row, container, svg, text, tooltip}, + widget::{Id, Row, container, svg, text, tooltip}, }; use crate::icons::{self, Icon}; use super::{ - control::{Control, State}, + focusable_action::{ActionState, FocusableAction}, spacing, text::TextExt as _, }; @@ -39,12 +39,17 @@ pub struct Button<'a, Message> { shape: Shape, diameter: f32, kind: ButtonKind, + id: Option, on_press: Option, loading: bool, } impl<'a, Message> Button<'a, Message> { - pub fn new(content: impl Into>) -> Self { + pub fn new(label: impl text::IntoFragment<'a>) -> Self { + Self::custom(text(label)) + } + + pub fn custom(content: impl Into>) -> Self { Self { content: content.into(), tooltip: None, @@ -55,6 +60,7 @@ impl<'a, Message> Button<'a, Message> { shape: Shape::Rectangular, diameter: 52.0, kind: ButtonKind::Secondary, + id: None, on_press: None, loading: false, } @@ -65,7 +71,7 @@ impl<'a, Message> Button<'a, Message> { icon: Some(icon), shape: Shape::IconOnly, tooltip: Some(tooltip), - ..Self::new(text("")) + ..Self::custom(text("")) } } @@ -114,6 +120,11 @@ impl<'a, Message> Button<'a, Message> { self } + pub(crate) fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + pub fn on_press_maybe(mut self, message: Option) -> Self { self.on_press = message; self @@ -176,11 +187,14 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Message> let shape = button.shape; let kind = button.kind; - let mut control = Control::new(content) - .sensitive(!disabled) + let mut control = FocusableAction::new(content) .on_press_maybe(button.on_press.filter(|_| !button.loading)) .style(move |theme, status| appearance(theme, status, shape, kind)); + if let Some(id) = button.id { + control = control.id(id); + } + control = match shape { Shape::Rectangular | Shape::Pill => control.padding(if kind == ButtonKind::Primary { [spacing::MD, spacing::XG] @@ -227,10 +241,8 @@ fn icon_element<'a, Message: 'a>( .height(size) .rotation(rotation) .style(move |theme: &Theme, _| svg::Style { - color: Some(if disabled { - theme.extended_palette().secondary.weak.text - } else if kind == ButtonKind::Primary { - theme.extended_palette().primary.base.color + color: Some(if disabled || kind == ButtonKind::Primary { + crate::theme::colors(theme).muted } else { colors(theme, false, false, false, kind).text }), @@ -240,13 +252,14 @@ fn icon_element<'a, Message: 'a>( fn appearance( theme: &Theme, - state: State, + state: ActionState, shape: Shape, kind: ButtonKind, ) -> iced::widget::button::Style { + let semantic = crate::theme::colors(theme); let colors = colors( theme, - !state.sensitive, + !state.enabled, state.hovered || state.focused, state.pressed, kind, @@ -255,13 +268,9 @@ fn appearance( iced::widget::button::Style { background: if kind == ButtonKind::Transparent { if state.pressed { - Some(Background::Color( - theme.extended_palette().background.stronger.color, - )) + Some(Background::Color(semantic.selection)) } else if state.focused && !state.hovered { - Some(Background::Color( - theme.extended_palette().background.strong.color, - )) + Some(Background::Color(semantic.hover)) } else { None } @@ -278,19 +287,22 @@ fn appearance( } fn colors(theme: &Theme, disabled: bool, active: bool, pressed: bool, kind: ButtonKind) -> Pair { - let palette = theme.extended_palette(); + let colors = crate::theme::colors(theme); if disabled { return Pair { - color: palette.background.weaker.color, - text: palette.secondary.weak.text, + color: colors.background, + text: colors.muted, }; } match kind { ButtonKind::Primary => { if pressed { - palette.background.weakest + Pair { + color: colors.deep_background, + text: colors.text, + } } else if active { crate::theme::deep_surface(theme) } else { @@ -299,30 +311,39 @@ fn colors(theme: &Theme, disabled: bool, active: bool, pressed: bool, kind: Butt } ButtonKind::Secondary => { if pressed { - palette.secondary.weak + Pair { + color: colors.background, + text: colors.muted, + } } else if active { - palette.secondary.strong + Pair { + color: colors.surface_deep, + text: colors.muted, + } } else { - palette.secondary.base + Pair { + color: colors.deep_background, + text: colors.muted, + } } } ButtonKind::Surface => { let background = if pressed { - palette.background.stronger + colors.selection } else if active { - palette.background.strong + colors.hover } else { - palette.background.weak + colors.surface }; Pair { - color: background.color, - text: palette.secondary.base.text, + color: background, + text: colors.muted, } } ButtonKind::Transparent => Pair { - color: palette.background.base.color, - text: palette.secondary.weak.text, + color: colors.background, + text: colors.muted, }, } } diff --git a/src/widgets/card.rs b/src/widgets/card.rs index ea8a30b..f17455b 100644 --- a/src/widgets/card.rs +++ b/src/widgets/card.rs @@ -3,11 +3,7 @@ use iced::{ widget::{Space, column, container, image, stack, text}, }; -use super::{ - spacing, - surface::{Kind as SurfaceKind, Surface}, - text::TextExt as _, -}; +use super::{spacing, text::TextExt as _}; pub(crate) const BANNER_HEIGHT: f32 = 132.0; @@ -46,15 +42,13 @@ impl<'a, Message> Card<'a, Message> { impl<'a, Message: 'a> From> for Element<'a, Message> { fn from(card: Card<'a, Message>) -> Self { - Surface::new( - SurfaceKind::Card, - container(card.content) - .padding(card.padding) - .width(card.width) - .height(card.height) - .clip(true), - ) - .into() + container(card.content) + .padding(card.padding) + .width(card.width) + .height(card.height) + .clip(true) + .style(crate::theme::card) + .into() } } @@ -83,8 +77,7 @@ pub(crate) fn image_content<'a, Message: 'a>( .width(Fill) .height(BANNER_HEIGHT) .style(|theme: &Theme| { - container::Style::default() - .background(theme.extended_palette().background.neutral.color) + container::Style::default().background(crate::theme::colors(theme).border) }) .into(), }; diff --git a/src/widgets/control.rs b/src/widgets/control.rs deleted file mode 100644 index 13ff28f..0000000 --- a/src/widgets/control.rs +++ /dev/null @@ -1,671 +0,0 @@ -use iced::{ - Background, Color, Element, Event, Length, Padding, Rectangle, Shadow, Size, Theme, Vector, - advanced::{ - Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, overlay, renderer, - widget::{Operation, Tree, operation, tree}, - }, - keyboard::{self, key}, - touch, - widget::button, - window, -}; -use std::{cell::Cell, rc::Rc}; - -/// A one-shot activation event from a [`Control`] to its composite owner. -/// It does not own persistent widget state. -pub(crate) type ActivationSignal = Rc>; - -/// The independent states used to resolve a control's appearance. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct State { - pub(crate) sensitive: bool, - pub(crate) actionable: bool, - pub(crate) hovered: bool, - pub(crate) pressed: bool, - pub(crate) focused: bool, - pub(crate) focus_within: bool, - pub(crate) selected: bool, - pub(crate) expanded: bool, - pub(crate) keyboard_highlighted: bool, -} - -/// The complete appearance of a [`Control`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct Style { - pub(crate) background: Option, - pub(crate) text_color: Color, - pub(crate) border: iced::Border, - pub(crate) shadow: Shadow, - pub(crate) snap: bool, - /// A foreground fill drawn after the content, such as a disabled scrim. - pub(crate) foreground: Option, -} - -impl Default for Style { - fn default() -> Self { - Self { - background: None, - text_color: Color::BLACK, - border: iced::Border::default(), - shadow: Shadow::default(), - snap: true, - foreground: None, - } - } -} - -impl From for Style { - fn from(style: button::Style) -> Self { - Self { - background: style.background, - text_color: style.text_color, - border: style.border, - shadow: style.shadow, - snap: style.snap, - foreground: None, - } - } -} - -/// The result of applying an input event to an [`Interaction`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Outcome { - Ignored, - Captured, - Activated, -} - -/// Transient input and focus state shared by controls and composite widgets. -#[derive(Debug, Default)] -pub(crate) struct Interaction { - pressed: bool, - focused: bool, - hovered: bool, - descendant_focused: bool, -} - -impl Interaction { - pub(crate) fn update( - &mut self, - event: &Event, - bounds: Rectangle, - cursor: mouse::Cursor, - sensitive: bool, - actionable: bool, - child_captured: bool, - shell: &mut Shell<'_, Message>, - ) -> Outcome { - let previous = (self.pressed, self.focused, self.hovered); - let pointer = event_cursor(event, cursor); - let hovered = sensitive && pointer.is_over(bounds); - let mut outcome = Outcome::Ignored; - - if !sensitive { - self.pressed = false; - self.focused = false; - self.hovered = false; - self.descendant_focused = false; - } else { - if matches!(event, Event::Mouse(mouse::Event::CursorLeft)) { - self.hovered = false; - } else if pointer != mouse::Cursor::Unavailable { - self.hovered = hovered; - } - - if !actionable { - self.pressed = false; - self.focused = false; - } else if child_captured { - if matches!( - event, - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) - ) { - self.focused = false; - self.pressed = false; - } else if matches!( - event, - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch( - touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. } - ) - ) { - self.pressed = false; - } - } else { - outcome = match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - self.focused = false; - - if hovered { - self.pressed = true; - Outcome::Captured - } else { - Outcome::Ignored - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) - if self.pressed => - { - self.pressed = false; - - if hovered { - Outcome::Activated - } else { - Outcome::Captured - } - } - Event::Touch(touch::Event::FingerLost { .. }) => { - self.pressed = false; - Outcome::Ignored - } - Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(key::Named::Enter | key::Named::Space), - repeat: false, - .. - }) if self.focused => { - self.pressed = true; - Outcome::Captured - } - Event::Keyboard(keyboard::Event::KeyReleased { - key: keyboard::Key::Named(key::Named::Enter | key::Named::Space), - .. - }) if self.focused && self.pressed => { - self.pressed = false; - Outcome::Activated - } - _ => Outcome::Ignored, - }; - } - - if matches!( - event, - Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) - ) { - self.hovered = false; - } - } - - if outcome != Outcome::Ignored { - shell.capture_event(); - } - - if !matches!(event, Event::Window(window::Event::RedrawRequested(_))) - && previous != (self.pressed, self.focused, self.hovered) - { - shell.request_redraw(); - } - - outcome - } - - pub(crate) fn state( - &self, - sensitive: bool, - actionable: bool, - bounds: Rectangle, - cursor: mouse::Cursor, - ) -> State { - let focused = sensitive && actionable && self.focused; - let hovered = sensitive - && if cursor == mouse::Cursor::Unavailable { - self.hovered - } else { - cursor.is_over(bounds) - }; - - State { - sensitive, - actionable, - hovered, - pressed: sensitive && actionable && self.pressed, - focused, - focus_within: sensitive && (focused || self.descendant_focused), - selected: false, - expanded: false, - keyboard_highlighted: false, - } - } - - pub(crate) fn set_descendant_focused(&mut self, focused: bool) { - self.descendant_focused = focused; - } - - pub(crate) fn mouse_interaction( - &self, - sensitive: bool, - actionable: bool, - bounds: Rectangle, - cursor: mouse::Cursor, - ) -> mouse::Interaction { - if sensitive && actionable && cursor.is_over(bounds) { - mouse::Interaction::Pointer - } else { - mouse::Interaction::default() - } - } -} - -impl operation::Focusable for Interaction { - fn is_focused(&self) -> bool { - self.focused - } - - fn focus(&mut self) { - self.focused = true; - } - - fn unfocus(&mut self) { - self.focused = false; - self.pressed = false; - } -} - -/// A single-child control with shared input, focus, and styling behavior. -pub(crate) struct Control<'a, Message> { - content: Element<'a, Message>, - on_press: Option, - activation: Option, - sensitive: bool, - selected: bool, - focus_first_descendant: bool, - width: Length, - height: Length, - padding: Padding, - style: Box Style + 'a>, -} - -impl<'a, Message> Control<'a, Message> { - pub(crate) fn new(content: impl Into>) -> Self { - Self { - content: content.into(), - on_press: None, - activation: None, - sensitive: true, - selected: false, - focus_first_descendant: false, - width: Length::Shrink, - height: Length::Shrink, - padding: Padding::ZERO, - style: Box::new(|theme, _| Style { - text_color: theme.palette().text, - ..Style::default() - }), - } - } - - pub(crate) fn on_press(mut self, message: Message) -> Self { - self.on_press = Some(message); - self - } - - pub(crate) fn on_press_maybe(mut self, message: Option) -> Self { - self.on_press = message; - self - } - - pub(crate) fn activation_signal(mut self, signal: ActivationSignal) -> Self { - self.activation = Some(signal); - self - } - - pub(crate) fn sensitive(mut self, sensitive: bool) -> Self { - self.sensitive = sensitive; - self - } - - pub(crate) fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - pub(crate) fn focus_first_descendant(mut self) -> Self { - self.focus_first_descendant = true; - self - } - - pub(crate) fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - pub(crate) fn height(mut self, height: impl Into) -> Self { - self.height = height.into(); - self - } - - pub(crate) fn padding(mut self, padding: impl Into) -> Self { - self.padding = padding.into(); - self - } - - pub(crate) fn style(mut self, style: impl Fn(&Theme, State) -> S + 'a) -> Self - where - S: Into