diff --git a/Cargo.lock b/Cargo.lock index e1ce9cd..9ea24de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,6 +844,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1778,6 +1797,7 @@ dependencies = [ "i-slint-common", "icu_normalizer", "icu_properties", + "image", "indexmap", "itertools 0.15.0", "lyon_extra", @@ -1785,9 +1805,14 @@ dependencies = [ "num_enum", "proc-macro2", "quote", + "rayon", + "resvg", "rowan", + "rspolib", + "skrifa", "smol_str 0.3.6", "strum", + "swash", "typed-index-collections", "unicode-segmentation", "url", @@ -2668,6 +2693,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + [[package]] name = "ndk" version = "0.9.0" @@ -3529,6 +3560,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -3651,6 +3702,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "rspolib" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +dependencies = [ + "lazy_static", + "natord", + "snafu", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3947,6 +4011,19 @@ dependencies = [ "vtable", ] +[[package]] +name = "slint-build" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6cfc9bb0dd48d01508f9e21511d0215e78e8e3be032bacd6be61744abe03c83" +dependencies = [ + "derive_more", + "fontique", + "i-slint-compiler", + "spin_on", + "toml_edit", +] + [[package]] name = "slint-macros" version = "1.18.0" @@ -4018,6 +4095,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4389,6 +4487,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -5101,6 +5200,7 @@ name = "windows-gotify-client-rust" version = "0.1.0" dependencies = [ "slint", + "slint-build", "thiserror 2.0.20", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 3d9ed0a..8d5a699 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # --- Error handling --- thiserror = "2" +# Slint's `.slint` files are compiled by `build.rs`; see build.rs. +[build-dependencies] +slint-build = "1" + [profile.release] # See AGENTS.md §42. The `release-balanced` profile below is the practical # default after benchmarking — adjust if startup time regresses. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f44c474 --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +// Build script: compile all `ui/*.slint` files into Rust source via +// `slint-build`. The generated file path is exposed via the +// `SLINT_INCLUDE_GENERATED` env var and pulled into `lib.rs` through +// `slint::include_modules!()`. +// +// Per AGENTS.md §4 the UI lives under `ui/`. `ui/app.slint` is the entry +// point that recursively imports the other files (theme.slint, +// components.slint, ...), so compiling only the entry is sufficient. + +fn main() { + slint_build::compile("ui/app.slint").expect("slint compilation failed"); +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 96e8c08..862ad94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,57 +1,38 @@ //! Windows Gotify Client (Rust) — core library. //! -//! Phase 1 only exposes the bare-minimum surface needed to spin up the -//! Slint hello window: -//! - [`error`]: AppError / AppResult +//! Phase 2 C1 only: +//! - [`error`]: AppError / AppResult //! - [`logging`]: tracing-subscriber init -//! - [`run`]: entry that builds the Slint UI and runs its event loop +//! - [`run`]: builds the MainWindow and runs its event loop //! -//! Larger subsystems (gotify, storage, windows integration, ...) are added in -//! later phases per AGENTS.md §57. +//! Larger subsystems (gotify / storage / windows integration / mock data) +//! land in later commits per AGENTS.md §57. pub mod error; pub mod logging; use slint::ComponentHandle; -slint::slint! { - export component AppWindow inherits Window { - title: "Gotify"; - width: 480px; - height: 320px; - background: #1f2024; +slint::include_modules!(); - VerticalLayout { - padding: 24px; - spacing: 12px; - - Text { - text: "Windows Gotify Client (Rust)"; - color: #e6e6e6; - font-size: 18px; - } - - Text { - text: "Phase 1 — Bootstrap OK"; - color: #8a8a8a; - font-size: 12px; - } - } - } -} - -/// Build the Slint UI and run its event loop until the window is closed. +/// Build the MainWindow and run its event loop until the window is closed. /// /// In release builds the Windows subsystem is set to `windows` via the /// `#[cfg_attr]` on the binary entry point, so no console window is shown. -pub fn run() -> AppResult<()> { +pub fn run() -> error::AppResult<()> { tracing::info!( "starting windows-gotify-client-rust v{}", env!("CARGO_PKG_VERSION") ); - let ui = AppWindow::new().map_err(|e| AppError::Ui(e.to_string()))?; - ui.run().map_err(|e| AppError::Ui(e.to_string())) + let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?; + + // Phase 2 C1: dark by default. C3 wires this to the Appearance section + // of the Settings page and to `state.json` so the choice persists. + ui.set_theme(AppearanceMode::Dark); + + ui.run() + .map_err(|e| error::AppError::Ui(e.to_string())) } pub use error::{AppError, AppResult}; \ No newline at end of file diff --git a/ui/app.slint b/ui/app.slint new file mode 100644 index 0000000..46788df --- /dev/null +++ b/ui/app.slint @@ -0,0 +1,174 @@ +// Main window + sidebar (AGENTS.md §6, §7). +// +// Phase 2 C1 only lays out the chrome: 960×680 default, 760×520 minimum, +// 232px sidebar with the seven required sections. The message area / +// settings page / about dialog are still placeholders — content lands in +// C2 (messages) and C3 (settings + dialogs + sidebar Light/System). + +import { AppearanceMode } from "./theme.slint"; +import { NavItem, SectionLabel } from "./components.slint"; + +export enum SidebarSelection { + All, + Settings, + About, +} + +export component MainWindow inherits Window { + title: "Gotify"; + preferred-width: 960px; + preferred-height: 680px; + min-width: 760px; + min-height: 520px; + + // Theme switch — Rust-controllable via `ui.set_theme(...)`. Light/System + // is wired through this single property; the sidebar children will start + // following it once C3 widens NavItem to accept palette forwarding. + in-out property theme: AppearanceMode.Dark; + out property is-dark: theme != AppearanceMode.Light; + + // Palette — all derived from `is-dark` (AGENTS.md §52). + out property bg: is-dark ? #1f2024 : #f5f6f8; + out property surface: is-dark ? #28292d : #ffffff; + out property surface-elev: is-dark ? #2f3035 : #fafbfc; + out property border: is-dark ? #3a3b40 : #e4e6ea; + out property text-primary: is-dark ? #e6e6e6 : #1a1a1a; + out property text-second: is-dark ? #a0a0a0 : #5c5c5c; + out property text-muted: is-dark ? #6a6a6a : #9a9a9a; + out property accent: is-dark ? #4ea1ff : #0a66c2; + out property success: #4caf50; + out property warning: #ff9800; + out property danger: #f44336; + out property priority-low: #6a6a6a; + out property priority-normal: is-dark ? #4ea1ff : #0a66c2; + out property priority-high: #ff9800; + out property priority-max: #f44336; + + background: bg; + + in-out property selection: SidebarSelection.All; + + HorizontalLayout { + // §7 Sidebar width: 220-240px. + Rectangle { + width: 232px; + background: surface; + + VerticalLayout { + padding: 20px; + spacing: 6px; + + // App logo / title block. + Text { + text: "Gotify"; + color: text-primary; + font-size: 18px; + font-weight: 700; + } + Text { + text: "Windows Client · Phase 2"; + color: text-muted; + font-size: 10px; + } + + Rectangle { height: 16px; } + + SectionLabel { label: "MESSAGES"; text-color: text-muted; } + NavItem { + label: "All"; + selected: root.selection == SidebarSelection.All; + clicked => { root.selection = SidebarSelection.All; } + } + + Rectangle { height: 12px; } + + SectionLabel { label: "SERVERS"; text-color: text-muted; } + Text { + text: "(no servers configured)"; + color: text-muted; + font-size: 11px; + } + + Rectangle { height: 12px; } + + SectionLabel { label: "FILTERS"; text-color: text-muted; } + NavItem { + label: "High"; + selected: false; + has-accent: false; + clicked => { /* TODO Phase 2 C2 */ } + } + NavItem { + label: "Normal"; + selected: false; + has-accent: false; + clicked => { /* TODO Phase 2 C2 */ } + } + NavItem { + label: "Low"; + selected: false; + has-accent: false; + clicked => { /* TODO Phase 2 C2 */ } + } + + Rectangle { height: 12px; } + + SectionLabel { label: "APP"; text-color: text-muted; } + NavItem { + label: "Settings"; + selected: root.selection == SidebarSelection.Settings; + clicked => { root.selection = SidebarSelection.Settings; } + } + NavItem { + label: "About"; + selected: root.selection == SidebarSelection.About; + clicked => { root.selection = SidebarSelection.About; } + } + } + } + + // Right pane — placeholder content driven by `selection`. + Rectangle { + background: bg; + + if root.selection == SidebarSelection.All: VerticalLayout { + padding: 24px; + spacing: 8px; + + Text { + text: "Messages"; + color: text-primary; + font-size: 18px; + font-weight: 700; + } + Text { + text: "Message list and empty state land in Phase 2 C2."; + color: text-muted; + font-size: 12px; + } + } + + if root.selection == SidebarSelection.Settings: Rectangle { + background: bg; + Text { + x: 24px; + y: 24px; + text: "Settings page arrives in Phase 2 C3."; + color: text-muted; + font-size: 13px; + } + } + + if root.selection == SidebarSelection.About: Rectangle { + background: bg; + Text { + x: 24px; + y: 24px; + text: "About dialog arrives in Phase 2 C3."; + color: text-muted; + font-size: 13px; + } + } + } + } +} \ No newline at end of file diff --git a/ui/components.slint b/ui/components.slint new file mode 100644 index 0000000..a0eb227 --- /dev/null +++ b/ui/components.slint @@ -0,0 +1,66 @@ +// Reusable UI building blocks (AGENTS.md §7). +// +// In Phase 2 C1 every theme-aware color is a fixed Dark-theme default. Phase +// 2 C3 will widen these to take a Palette struct or forward from `parent` +// so Light/System can repaint the sidebar too. + +export component NavItem inherits Rectangle { + in property label; + in property selected: false; + in property has-accent: true; + + in property bg-idle: #00000000; + in property bg-selected: #2f3035; + in property text-idle: #a0a0a0; + in property text-selected: #e6e6e6; + in property accent-color: #4ea1ff; + + callback clicked(); + + width: 100%; + height: 36px; + border-radius: 8px; + background: selected ? bg-selected : bg-idle; + + Rectangle { + x: 4px; + y: 8px; + width: 3px; + height: parent.height - 16px; + border-radius: 2px; + background: accent-color; + opacity: root.has-accent && root.selected ? 1.0 : 0.0; + } + + Text { + x: 16px; + y: (parent.height - self.height) / 2; + text: root.label; + color: selected ? text-selected : text-idle; + font-size: 13px; + font-weight: selected ? 600 : 400; + } + + TouchArea { + clicked => { + root.clicked(); + } + } +} + +export component SectionLabel inherits Text { + in property label; + in property text-color: #6a6a6a; + + text: label; + color: text-color; + font-size: 11px; + font-weight: 700; +} + +export component Divider inherits Rectangle { + in property line-color: #3a3b40; + + height: 1px; + background: line-color; +} \ No newline at end of file diff --git a/ui/dialogs.slint b/ui/dialogs.slint new file mode 100644 index 0000000..b3f2061 --- /dev/null +++ b/ui/dialogs.slint @@ -0,0 +1,4 @@ +// AGENTS.md §54 dialogs (About + confirm). +// +// Placeholder for Phase 2 C1. C3 adds the modal About dialog (called from +// sidebar) and the confirm dialog used by destructive settings actions. \ No newline at end of file diff --git a/ui/empty-state.slint b/ui/empty-state.slint new file mode 100644 index 0000000..6b1d107 --- /dev/null +++ b/ui/empty-state.slint @@ -0,0 +1,5 @@ +// AGENTS.md §9 empty state. +// +// Placeholder for Phase 2 C1 — actual ✦ glyph + "You're all caught up" +// panel lands in C2 when message-list integration knows whether the +// backing model is empty. \ No newline at end of file diff --git a/ui/message-card.slint b/ui/message-card.slint new file mode 100644 index 0000000..6e85a45 --- /dev/null +++ b/ui/message-card.slint @@ -0,0 +1,4 @@ +// AGENTS.md §7 Message Card. +// +// Placeholder for Phase 2 C1 — real implementation lands in C2 alongside +// `src/models.rs` (MessageItem / Priority) and the mock data feed. \ No newline at end of file diff --git a/ui/message-list.slint b/ui/message-list.slint new file mode 100644 index 0000000..7df55af --- /dev/null +++ b/ui/message-list.slint @@ -0,0 +1,5 @@ +// AGENTS.md §7 / §37 list virtualization. +// +// Placeholder for Phase 2 C1 — real ListView wiring lands in C2 alongside +// `src/models.rs`. Slint's `ListView` already virtualizes; we only need to +// feed it the Rust-side `Model` adapter. \ No newline at end of file diff --git a/ui/server-card.slint b/ui/server-card.slint new file mode 100644 index 0000000..00f9b7e --- /dev/null +++ b/ui/server-card.slint @@ -0,0 +1,6 @@ +// AGENTS.md §21 multi-server card. +// +// Placeholder for Phase 2 C1. The Settings → Connection section uses these +// to list/edit each configured server (name / URL / token / enabled). +// Real data wiring lands in Phase 5 (Storage) when `AppConfig.servers` is +// persisted. \ No newline at end of file diff --git a/ui/settings-page.slint b/ui/settings-page.slint new file mode 100644 index 0000000..970bfae --- /dev/null +++ b/ui/settings-page.slint @@ -0,0 +1,6 @@ +// AGENTS.md §10 Settings page. +// +// Placeholder for Phase 2 C1. C3 will lay out the five required sections +// (Connection / Notifications / Startup / Appearance / Advanced), use the +// Appearance section to drive the live `Theme.appearance` switch, and wire +// up the actual save pipeline (Phase 5). \ No newline at end of file diff --git a/ui/theme.slint b/ui/theme.slint new file mode 100644 index 0000000..f3f7bdc --- /dev/null +++ b/ui/theme.slint @@ -0,0 +1,14 @@ +// AGENTS.md §52 color system + theme switcher. +// +// `Theme` and `Palette` live on `MainWindow` rather than as `export global` — +// Slint 1.18 dead-code-eliminates globals whose binding is compile-time +// reducible (which Theme always is when the default `appearance` is Dark), so +// the only way to keep `Theme` reachable from Rust is to reference it through +// a property on the root component. `MainWindow.theme` is the single source +// of truth; everything else is derived in app.slint. + +export enum AppearanceMode { + System, + Light, + Dark, +} \ No newline at end of file