feat(ui): Phase 2 C3 — Settings 页 + Dialogs + 窗口状态保存
- src/storage.rs: AppState (last_window_width/height) + load/save atomic write 到 %APPDATA%/WindowsGotify/state.json(§18) - ui/settings-page.slint: 5 分组(Connection / Notifications / Startup / Appearance / Advanced),Appearance 实时切主题(System/Light/Dark radio);Advanced 折叠 - ui/dialogs.slint: AboutDialog + ConfirmDialog(§54) - ui/server-card.slint: 单 server 行(name/url/enabled toggle/edit/remove) - ui/app.slint: settings 页切换、About 导航、theme 联动 - src/lib.rs: load_state → 启动时应用窗口尺寸;ui.window() .on_close_requested 在 OS 关闭前 save_state;wire about/close callbacks - src/error.rs: 加 AppError::Storage(String) 变体 - Cargo.toml: 加 serde + serde_json 避坑: - Slint 1.x 同文件内 export component 必须在引用前定义(sub-components 移到 SettingsPage 上面) - Slint 不支持 padding 两值简写 (padding: 0 16px) → 用 padding-* 四属性 - Slint 不支持 Rectangle 内建 border-color 覆盖 → 命名 card-border - Slint 不支持 alignment: right → 用 spacer + horizontal-stretch: 1 - 修了一处 .slint 文件被 PowerShell Set-Content GBK 损坏的问题(已 通过 write 工具 + UTF-8 no BOM 重写并清理 ui/ 所有 .slint) 验证:cargo build / build --release 都 exit 0,0 warning - debug 27.55 MB, subsystem = WINDOWS_CUI - release 8.10 MB, subsystem = WINDOWS_GUI
This commit is contained in:
Generated
+2
@@ -5199,6 +5199,8 @@ dependencies = [
|
|||||||
name = "windows-gotify-client-rust"
|
name = "windows-gotify-client-rust"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"slint",
|
"slint",
|
||||||
"slint-build",
|
"slint-build",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
# --- Error handling ---
|
# --- Error handling ---
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
# --- Serialization (Phase 2 C3 storage) ---
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
|
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
slint-build = "1"
|
slint-build = "1"
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ pub enum AppError {
|
|||||||
|
|
||||||
#[error("ui error: {0}")]
|
#[error("ui error: {0}")]
|
||||||
Ui(String),
|
Ui(String),
|
||||||
|
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
}
|
}
|
||||||
+62
-12
@@ -3,19 +3,22 @@
|
|||||||
//! Phase 2 surface:
|
//! Phase 2 surface:
|
||||||
//! - [`error`]: AppError / AppResult
|
//! - [`error`]: AppError / AppResult
|
||||||
//! - [`logging`]: tracing-subscriber init
|
//! - [`logging`]: tracing-subscriber init
|
||||||
//! - [`models`]: MessageItem / Priority (re-exported from slint-generated
|
//! - [`models`]: Mock message feed (Phase 3 replaces with live Gotify data)
|
||||||
//! types) + the mock data feed used until Phase 3 lands.
|
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3)
|
||||||
//! - [`run`]: builds the MainWindow and runs its event loop
|
//! - [`run`]: builds the MainWindow and runs its event loop
|
||||||
//!
|
|
||||||
//! Larger subsystems (gotify / storage / windows integration) land in later
|
|
||||||
//! phases per AGENTS.md §57.
|
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod storage;
|
||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use storage::AppState;
|
||||||
|
|
||||||
/// Build the MainWindow 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
|
/// In release builds the Windows subsystem is set to `windows` via the
|
||||||
@@ -28,12 +31,20 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
|
|
||||||
let ui = MainWindow::new().map_err(|e| error::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
|
// Phase 2 C3: load persisted state and apply it before `run()`.
|
||||||
// of the Settings page and to `state.json` so the choice persists.
|
let initial_state = storage::load_state();
|
||||||
ui.set_theme(AppearanceMode::Dark);
|
tracing::info!(
|
||||||
|
width = initial_state.last_window_width,
|
||||||
|
height = initial_state.last_window_height,
|
||||||
|
"loaded state.json"
|
||||||
|
);
|
||||||
|
ui.set_theme(AppearanceMode::Dark); // C4 reads theme from state.json.
|
||||||
|
ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
|
||||||
|
initial_state.last_window_width.max(0) as f32,
|
||||||
|
initial_state.last_window_height.max(0) as f32,
|
||||||
|
)));
|
||||||
|
|
||||||
// Phase 2 C2: hook up the two top-level callbacks. C6/Phase 6 swap these
|
// Phase 2 C2 callbacks.
|
||||||
// for real Windows toast firing.
|
|
||||||
ui.on_test_notification_clicked(|| {
|
ui.on_test_notification_clicked(|| {
|
||||||
tracing::info!("empty-state: Test Notification clicked (stub)");
|
tracing::info!("empty-state: Test Notification clicked (stub)");
|
||||||
});
|
});
|
||||||
@@ -41,10 +52,49 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
tracing::debug!(idx, "message card clicked");
|
tracing::debug!(idx, "message card clicked");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 2 C2: feed mock data into the message list. Phase 3 swaps this
|
// Phase 2 C3 callbacks.
|
||||||
// for an HTTP catch-up + WebSocket live stream.
|
let state_for_save: Rc<RefCell<AppState>> = Rc::new(RefCell::new(initial_state));
|
||||||
|
ui.on_about_clicked({
|
||||||
|
let ui_handle = ui.as_weak();
|
||||||
|
move || {
|
||||||
|
tracing::info!("about-clicked");
|
||||||
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
|
ui.set_selection(SidebarSelection::About);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2 C2 message feed (mock).
|
||||||
ui.set_messages(models::mock_messages_model());
|
ui.set_messages(models::mock_messages_model());
|
||||||
|
|
||||||
|
// Hook a real close-requested callback on the Slint window so we save
|
||||||
|
// state right before the window is destroyed. Slint 1.18's `Window` has
|
||||||
|
// a `close-requested` callback that fires on OS-close.
|
||||||
|
ui.window().on_close_requested({
|
||||||
|
let state_for_save = state_for_save.clone();
|
||||||
|
let ui_handle = ui.as_weak();
|
||||||
|
move || {
|
||||||
|
// Capture final size from the live window before saving.
|
||||||
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
|
let size = ui.window().size();
|
||||||
|
let mut state = state_for_save.borrow_mut();
|
||||||
|
state.last_window_width = size.width as i32;
|
||||||
|
state.last_window_height = size.height as i32;
|
||||||
|
}
|
||||||
|
if let Err(e) = storage::save_state(&state_for_save.borrow()) {
|
||||||
|
tracing::warn!(error = %e, "on close: failed to save state.json");
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
width = state_for_save.borrow().last_window_width,
|
||||||
|
height = state_for_save.borrow().last_window_height,
|
||||||
|
"saved state.json on close"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Allow the window to hide (default CloseRequestResponse behaviour).
|
||||||
|
slint::CloseRequestResponse::HideWindow
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ui.run()
|
ui.run()
|
||||||
.map_err(|e| error::AppError::Ui(e.to_string()))
|
.map_err(|e| error::AppError::Ui(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
//! Local persistent state (AGENTS.md §18).
|
||||||
|
//!
|
||||||
|
//! Phase 2 C3 only persists **window geometry** (size). The full state.json
|
||||||
|
//! schema — `last_message_id` / `selected_server` / etc. — lands in Phase 5
|
||||||
|
//! (Storage) when `AppConfig` joins in. For now we keep this minimal: read
|
||||||
|
//! on startup, write on close, atomic via tmp + rename.
|
||||||
|
//!
|
||||||
|
//! Location: `%APPDATA%/WindowsGotify/state.json`. The directory is created
|
||||||
|
//! on first save; missing-file on first run is treated as "no prior state".
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
const STATE_DIR: &str = "WindowsGotify";
|
||||||
|
const STATE_FILE: &str = "state.json";
|
||||||
|
const STATE_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Persistable subset of application state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// Bumped when the on-disk schema changes so future migrations can run.
|
||||||
|
#[serde(default = "current_version")]
|
||||||
|
pub version: u32,
|
||||||
|
pub last_window_width: i32,
|
||||||
|
pub last_window_height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_version() -> u32 {
|
||||||
|
STATE_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: STATE_VERSION,
|
||||||
|
// Match MainWindow's `preferred-width/height` from ui/app.slint.
|
||||||
|
last_window_width: 960,
|
||||||
|
last_window_height: 680,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%APPDATA%/WindowsGotify/state.json`. Falls back to a temp-relative path
|
||||||
|
/// when `APPDATA` is unset (rare on real Windows installs but harmless for
|
||||||
|
/// dev shells).
|
||||||
|
pub fn state_path() -> Option<PathBuf> {
|
||||||
|
if let Some(roaming) = std::env::var_os("APPDATA") {
|
||||||
|
return Some(Path::new(&roaming).join(STATE_DIR).join(STATE_FILE));
|
||||||
|
}
|
||||||
|
// Fallback: current working dir + subfolder — only matters in dev shells.
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.join(STATE_DIR).join(STATE_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load state from disk. Missing file or invalid JSON yields `AppState::default()`.
|
||||||
|
pub fn load_state() -> AppState {
|
||||||
|
let Some(path) = state_path() else {
|
||||||
|
return AppState::default();
|
||||||
|
};
|
||||||
|
let Ok(bytes) = fs::read(&path) else {
|
||||||
|
return AppState::default();
|
||||||
|
};
|
||||||
|
match serde_json::from_slice::<AppState>(&bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(?path, error = %e, "state.json is malformed; using defaults");
|
||||||
|
AppState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic write: write `state.json.tmp`, fsync, rename over `state.json`.
|
||||||
|
/// Returns Ok(()) on success, even when `%APPDATA%` is unset (no-op in
|
||||||
|
/// that case so dev shells don't crash).
|
||||||
|
pub fn save_state(state: &AppState) -> AppResult<()> {
|
||||||
|
let Some(path) = state_path() else {
|
||||||
|
tracing::debug!("APPDATA unset; skipping state.json save");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
{
|
||||||
|
let mut f = fs::File::create(&tmp).map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
let body = serde_json::to_vec_pretty(state)
|
||||||
|
.map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
f.write_all(&body)
|
||||||
|
.map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
f.sync_all()
|
||||||
|
.map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &path).map_err(|e| AppError::Storage(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+39
-26
@@ -1,15 +1,17 @@
|
|||||||
// Main window + sidebar (AGENTS.md §6, §7).
|
// Main window + sidebar (AGENTS.md §6, §7).
|
||||||
//
|
//
|
||||||
// Phase 2 C1 only lays out the chrome: 960×680 default, 760×520 minimum,
|
// Phase 2 C3 wires the Settings page, About dialog, and window-state
|
||||||
// 232px sidebar with the seven required sections. The message area /
|
// persistence. The Appearance section in `settings-page.slint` is the only
|
||||||
// settings page / about dialog are still placeholders — content lands in
|
// settings UI that drives real state today — every other control is a
|
||||||
// C2 (messages) and C3 (settings + dialogs + sidebar Light/System).
|
// shell that Phase 5 hooks to `AppConfig`.
|
||||||
|
|
||||||
import { AppearanceMode } from "./theme.slint";
|
import { AppearanceMode } from "./theme.slint";
|
||||||
import { NavItem, SectionLabel } from "./components.slint";
|
import { NavItem, SectionLabel } from "./components.slint";
|
||||||
import { MessageItem } from "./message-card.slint";
|
import { MessageItem } from "./message-card.slint";
|
||||||
import { MessageList } from "./message-list.slint";
|
import { MessageList } from "./message-list.slint";
|
||||||
import { EmptyState } from "./empty-state.slint";
|
import { EmptyState } from "./empty-state.slint";
|
||||||
|
import { SettingsPage } from "./settings-page.slint";
|
||||||
|
import { AboutDialog } from "./dialogs.slint";
|
||||||
|
|
||||||
export enum SidebarSelection {
|
export enum SidebarSelection {
|
||||||
All,
|
All,
|
||||||
@@ -24,9 +26,7 @@ export component MainWindow inherits Window {
|
|||||||
min-width: 760px;
|
min-width: 760px;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
|
|
||||||
// Theme switch — Rust-controllable via `ui.set_theme(...)`. Light/System
|
// Theme switch — Rust-controllable via `ui.set_theme(...)`.
|
||||||
// is wired through this single property; the sidebar children will start
|
|
||||||
// following it once C3 widens NavItem to accept palette forwarding.
|
|
||||||
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
|
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
|
||||||
out property <bool> is-dark: theme != AppearanceMode.Light;
|
out property <bool> is-dark: theme != AppearanceMode.Light;
|
||||||
|
|
||||||
@@ -51,18 +51,21 @@ export component MainWindow inherits Window {
|
|||||||
|
|
||||||
in-out property <SidebarSelection> selection: SidebarSelection.All;
|
in-out property <SidebarSelection> selection: SidebarSelection.All;
|
||||||
|
|
||||||
// Phase 2 C2: mock-driven message list + selection state. Set from Rust
|
// ── Phase 2 C2: mock messages + selection ────────────────────
|
||||||
// via `ui.set_messages(model)` in `run()`. The list / empty-state switch
|
|
||||||
// is purely driven by `messages.length` (Slint's `for` over an empty
|
|
||||||
// model is a no-op).
|
|
||||||
in-out property <[MessageItem]> messages;
|
in-out property <[MessageItem]> messages;
|
||||||
in-out property <int> selected-message-index: -1;
|
in-out property <int> selected-message-index: -1;
|
||||||
|
|
||||||
// Hooks that Phase 6 (Windows Integration) wires to a real Windows
|
|
||||||
// toast. C2 logs them so the click path is observable end-to-end.
|
|
||||||
callback test-notification-clicked();
|
callback test-notification-clicked();
|
||||||
callback message-card-clicked(int);
|
callback message-card-clicked(int);
|
||||||
|
|
||||||
|
// ── Phase 2 C3: Settings / About / window state ──────────────
|
||||||
|
// Initial appearance radio (0=System/1=Light/2=Dark). Maps 1:1 to
|
||||||
|
// AppearanceMode: System=0, Light=1, Dark=2.
|
||||||
|
in-out property <int> appearance-index: 2;
|
||||||
|
|
||||||
|
// Show About dialog (one-shot — Rust closes it after handling).
|
||||||
|
callback about-clicked();
|
||||||
|
callback close-requested-save-state();
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
// §7 Sidebar width: 220-240px.
|
// §7 Sidebar width: 220-240px.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -73,7 +76,6 @@ export component MainWindow inherits Window {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
|
|
||||||
// App logo / title block.
|
|
||||||
Text {
|
Text {
|
||||||
text: "Gotify";
|
text: "Gotify";
|
||||||
color: text-primary;
|
color: text-primary;
|
||||||
@@ -137,12 +139,15 @@ export component MainWindow inherits Window {
|
|||||||
NavItem {
|
NavItem {
|
||||||
label: "About";
|
label: "About";
|
||||||
selected: root.selection == SidebarSelection.About;
|
selected: root.selection == SidebarSelection.About;
|
||||||
clicked => { root.selection = SidebarSelection.About; }
|
clicked => {
|
||||||
|
root.selection = SidebarSelection.About;
|
||||||
|
root.about-clicked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right pane — placeholder content driven by `selection`.
|
// Right pane — driven by `selection`.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: bg;
|
background: bg;
|
||||||
|
|
||||||
@@ -174,27 +179,35 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.selection == SidebarSelection.Settings: Rectangle {
|
if root.selection == SidebarSelection.Settings: SettingsPage {
|
||||||
background: bg;
|
appearance-index: root.appearance-index;
|
||||||
Text {
|
appearance-changed(idx) => {
|
||||||
x: 24px;
|
root.appearance-index = idx;
|
||||||
y: 24px;
|
root.theme = idx == 0 ? AppearanceMode.System :
|
||||||
text: "Settings page arrives in Phase 2 C3.";
|
idx == 1 ? AppearanceMode.Light :
|
||||||
color: text-muted;
|
AppearanceMode.Dark;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// About is rendered via a Dialog overlay — not in the right pane.
|
||||||
|
// A separate About row keeps the layout stable; the dialog is
|
||||||
|
// shown via `about-clicked` callback to Rust.
|
||||||
if root.selection == SidebarSelection.About: Rectangle {
|
if root.selection == SidebarSelection.About: Rectangle {
|
||||||
background: bg;
|
background: bg;
|
||||||
Text {
|
Text {
|
||||||
x: 24px;
|
x: 24px;
|
||||||
y: 24px;
|
y: 24px;
|
||||||
text: "About dialog arrives in Phase 2 C3.";
|
text: "About — see the dialog.";
|
||||||
color: text-muted;
|
color: text-muted;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modal About dialog — opened when the user clicks About in the sidebar.
|
||||||
|
AboutDialog {
|
||||||
|
// visible-by-default is false. Toggled via Rust by listening to
|
||||||
|
// `about-clicked`. C3 wires the bare open/close plumbing.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+155
-4
@@ -1,4 +1,155 @@
|
|||||||
// AGENTS.md §54 dialogs (About + confirm).
|
// AGENTS.md §54 modals. C3 only ships the About dialog (called from the
|
||||||
//
|
// sidebar) and a generic confirm dialog template; full delete-confirm flow
|
||||||
// Placeholder for Phase 2 C1. C3 adds the modal About dialog (called from
|
// lands in Phase 5 once `AppConfig` persistence is wired.
|
||||||
// sidebar) and the confirm dialog used by destructive settings actions.
|
|
||||||
|
export component AboutDialog inherits Dialog {
|
||||||
|
in property <string> version-string: "v0.1.0";
|
||||||
|
in property <string> build-date: "2026-09-19";
|
||||||
|
|
||||||
|
// Theme-aware color tokens (Dark defaults; C4 will widen).
|
||||||
|
in property <color> bg-color: #28292d;
|
||||||
|
in property <color> text-primary-color: #e6e6e6;
|
||||||
|
in property <color> text-secondary-color: #a0a0a0;
|
||||||
|
in property <color> accent-color: #4ea1ff;
|
||||||
|
in property <color> button-bg: #2f3035;
|
||||||
|
in property <color> button-border: #3a3b40;
|
||||||
|
|
||||||
|
title: "About Gotify Client";
|
||||||
|
background: bg-color;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 24px;
|
||||||
|
spacing: 12px;
|
||||||
|
width: 360px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Windows Gotify Client (Rust)";
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: version-string;
|
||||||
|
color: accent-color;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Built " + build-date;
|
||||||
|
color: text-secondary-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
Rectangle { height: 8px; }
|
||||||
|
Text {
|
||||||
|
text: "Lightweight, modern, Windows-native Gotify\ndesktop client. Phase 2 of 8.";
|
||||||
|
color: text-secondary-color;
|
||||||
|
font-size: 12px;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
|
// OK button.
|
||||||
|
Rectangle {
|
||||||
|
width: 100px;
|
||||||
|
height: 32px;
|
||||||
|
background: button-bg;
|
||||||
|
border-color: button-border;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
x: parent.width - self.width;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: "Close";
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component ConfirmDialog inherits Dialog {
|
||||||
|
in property <string> title-text: "Confirm";
|
||||||
|
in property <string> message: "Are you sure?";
|
||||||
|
in property <string> confirm-label: "Confirm";
|
||||||
|
in property <string> cancel-label: "Cancel";
|
||||||
|
|
||||||
|
callback confirmed();
|
||||||
|
callback cancelled();
|
||||||
|
|
||||||
|
in property <color> bg-color: #28292d;
|
||||||
|
in property <color> text-primary-color: #e6e6e6;
|
||||||
|
in property <color> text-secondary-color: #a0a0a0;
|
||||||
|
in property <color> danger-color: #f44336;
|
||||||
|
in property <color> button-bg: #2f3035;
|
||||||
|
in property <color> button-border: #3a3b40;
|
||||||
|
|
||||||
|
title: title-text;
|
||||||
|
background: bg-color;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 24px;
|
||||||
|
spacing: 16px;
|
||||||
|
width: 360px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: message;
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 13px;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 90px;
|
||||||
|
height: 32px;
|
||||||
|
background: button-bg;
|
||||||
|
border-color: button-border;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: cancel-label;
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.cancelled();
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 90px;
|
||||||
|
height: 32px;
|
||||||
|
background: danger-color;
|
||||||
|
border-radius: 6px;
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: confirm-label;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.confirmed();
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
-4
@@ -1,6 +1,129 @@
|
|||||||
// AGENTS.md §21 multi-server card.
|
// AGENTS.md §21 multi-server card.
|
||||||
//
|
//
|
||||||
// Placeholder for Phase 2 C1. The Settings → Connection section uses these
|
// Phase 2 C3 is a visual + structural shell: one placeholder row so the
|
||||||
// to list/edit each configured server (name / URL / token / enabled).
|
// Settings page has the right rhythm. The Settings `Connection` section
|
||||||
// Real data wiring lands in Phase 5 (Storage) when `AppConfig.servers` is
|
// hosts a `Repeater` over these once `AppConfig.servers` lands in Phase 5.
|
||||||
// persisted.
|
|
||||||
|
export component ServerCard inherits Rectangle {
|
||||||
|
in property <string> server-name: "(unnamed server)";
|
||||||
|
in property <string> server-url: "";
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <bool> is-current: false;
|
||||||
|
|
||||||
|
callback toggled();
|
||||||
|
callback edit-clicked();
|
||||||
|
callback remove-clicked();
|
||||||
|
|
||||||
|
in property <color> bg-color: #28292d;
|
||||||
|
in property <color> card-border: #3a3b40;
|
||||||
|
in property <color> text-primary-color: #e6e6e6;
|
||||||
|
in property <color> text-secondary-color: #a0a0a0;
|
||||||
|
in property <color> accent-color: #4ea1ff;
|
||||||
|
in property <color> success-color: #4caf50;
|
||||||
|
in property <color> toggle-on: #4ea1ff;
|
||||||
|
in property <color> toggle-off: #6a6a6a;
|
||||||
|
in property <color> button-bg: #2f3035;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: bg-color;
|
||||||
|
border-color: card-border;
|
||||||
|
border-width: 1px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
spacing: 12px;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
// On/off pill toggle (visual only in C3 — Phase 5 makes it mutate state).
|
||||||
|
Rectangle {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: enabled ? toggle-on : toggle-off;
|
||||||
|
y: parent.height / 2 - self.height / 2;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
x: enabled ? parent.width - self.width - 2px : 2px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.toggled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
spacing: 2px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
Text {
|
||||||
|
text: server-name;
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
if is-current: Text {
|
||||||
|
text: "• current";
|
||||||
|
color: accent-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: server-url;
|
||||||
|
color: text-secondary-color;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons.
|
||||||
|
Rectangle {
|
||||||
|
width: 56px;
|
||||||
|
height: 28px;
|
||||||
|
background: button-bg;
|
||||||
|
border-radius: 6px;
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: "Edit";
|
||||||
|
color: text-primary-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.edit-clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 56px;
|
||||||
|
height: 28px;
|
||||||
|
background: button-bg;
|
||||||
|
border-radius: 6px;
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: "Remove";
|
||||||
|
color: text-secondary-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.remove-clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+299
-5
@@ -1,6 +1,300 @@
|
|||||||
// AGENTS.md §10 Settings page.
|
// AGENTS.md §10 / §11 settings page.
|
||||||
//
|
//
|
||||||
// Placeholder for Phase 2 C1. C3 will lay out the five required sections
|
// C3 lays out the five sections (Connection / Notifications / Startup /
|
||||||
// (Connection / Notifications / Startup / Appearance / Advanced), use the
|
// Appearance / Advanced) with controls. The Appearance section is the only
|
||||||
// Appearance section to drive the live `Theme.appearance` switch, and wire
|
// one wired back to the model: the radio selection drives
|
||||||
// up the actual save pipeline (Phase 5).
|
// `MainWindow.theme` via the `appearance-changed` callback. Everything else
|
||||||
|
// is a UI shell — Phase 5 wires it to `AppConfig` + Settings → state.json.
|
||||||
|
//
|
||||||
|
// Sub-components MUST be defined before the components that reference them
|
||||||
|
// in Slint 1.x, so `SettingsToggleRow` / `AppearanceRadio` come first.
|
||||||
|
|
||||||
|
import { SectionLabel } from "./components.slint";
|
||||||
|
import { ServerCard } from "./server-card.slint";
|
||||||
|
|
||||||
|
// ─── Sub-components ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export component SettingsToggleRow inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> value: false;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
callback toggled(bool);
|
||||||
|
|
||||||
|
in property <color> row-bg: #28292d;
|
||||||
|
in property <color> row-border: #3a3b40;
|
||||||
|
in property <color> label-color: #e6e6e6;
|
||||||
|
in property <color> toggle-on: #4ea1ff;
|
||||||
|
in property <color> toggle-off: #6a6a6a;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: row-bg;
|
||||||
|
border-color: row-border;
|
||||||
|
border-width: 1px;
|
||||||
|
opacity: enabled ? 1.0 : 0.5;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label;
|
||||||
|
color: label-color;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: value ? toggle-on : toggle-off;
|
||||||
|
y: parent.height / 2 - self.height / 2;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
x: value ? parent.width - self.width - 2px : 2px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => {
|
||||||
|
root.toggled(!root.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component AppearanceRadio inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <string> description;
|
||||||
|
in property <bool> selected: false;
|
||||||
|
callback chosen();
|
||||||
|
|
||||||
|
in property <color> row-bg: #28292d;
|
||||||
|
in property <color> row-border: #3a3b40;
|
||||||
|
in property <color> row-border-selected: #4ea1ff;
|
||||||
|
in property <color> label-color: #e6e6e6;
|
||||||
|
in property <color> hint-color: #6a6a6a;
|
||||||
|
|
||||||
|
width: 160px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: row-bg;
|
||||||
|
border-color: selected ? row-border-selected : row-border;
|
||||||
|
border-width: selected ? 2px : 1px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 2px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label;
|
||||||
|
color: label-color;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: description;
|
||||||
|
color: hint-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.chosen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export component SettingsPage inherits Rectangle {
|
||||||
|
// Appearance (live wired)
|
||||||
|
in-out property <int> appearance-index: 2;
|
||||||
|
callback appearance-changed(int);
|
||||||
|
|
||||||
|
// Notifications (UI shell)
|
||||||
|
in-out property <bool> desktop-notify: true;
|
||||||
|
in-out property <bool> sound: true;
|
||||||
|
in-out property <bool> notify-high: true;
|
||||||
|
in-out property <bool> notify-max: true;
|
||||||
|
|
||||||
|
// Startup (UI shell)
|
||||||
|
in-out property <bool> start-with-windows: false;
|
||||||
|
in-out property <bool> start-minimized: false;
|
||||||
|
in-out property <bool> close-to-tray: true;
|
||||||
|
|
||||||
|
// Advanced (collapsed by default per §10)
|
||||||
|
in-out property <bool> advanced-expanded: false;
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
in property <color> divider-color: #3a3b40;
|
||||||
|
in property <color> hint-color: #6a6a6a;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 24px;
|
||||||
|
spacing: 24px;
|
||||||
|
|
||||||
|
// CONNECTION
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
SectionLabel { label: "CONNECTION"; text-color: hint-color; }
|
||||||
|
ServerCard {
|
||||||
|
server-name: "Home";
|
||||||
|
server-url: "https://gotify.example.com";
|
||||||
|
enabled: true;
|
||||||
|
is-current: true;
|
||||||
|
}
|
||||||
|
ServerCard {
|
||||||
|
server-name: "NAS";
|
||||||
|
server-url: "http://192.168.50.103:50334";
|
||||||
|
enabled: true;
|
||||||
|
is-current: false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTIFICATIONS
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
SectionLabel { label: "NOTIFICATIONS"; text-color: hint-color; }
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Enable desktop notification";
|
||||||
|
value: root.desktop-notify;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Play sound";
|
||||||
|
value: root.sound;
|
||||||
|
enabled: root.desktop-notify;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Notify on High priority";
|
||||||
|
value: root.notify-high;
|
||||||
|
enabled: root.desktop-notify;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Notify on Max priority";
|
||||||
|
value: root.notify-max;
|
||||||
|
enabled: root.desktop-notify;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STARTUP
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
SectionLabel { label: "STARTUP"; text-color: hint-color; }
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Start with Windows";
|
||||||
|
value: root.start-with-windows;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Start minimized to tray";
|
||||||
|
value: root.start-minimized;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Close button minimizes to tray";
|
||||||
|
value: root.close-to-tray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPEARANCE (live wired)
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
SectionLabel { label: "APPEARANCE"; text-color: hint-color; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
AppearanceRadio {
|
||||||
|
label: "System";
|
||||||
|
description: "Follow Windows theme";
|
||||||
|
selected: root.appearance-index == 0;
|
||||||
|
chosen => {
|
||||||
|
root.appearance-index = 0;
|
||||||
|
root.appearance-changed(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppearanceRadio {
|
||||||
|
label: "Light";
|
||||||
|
description: "Bright neutrals";
|
||||||
|
selected: root.appearance-index == 1;
|
||||||
|
chosen => {
|
||||||
|
root.appearance-index = 1;
|
||||||
|
root.appearance-changed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppearanceRadio {
|
||||||
|
label: "Dark";
|
||||||
|
description: "Graphite";
|
||||||
|
selected: root.appearance-index == 2;
|
||||||
|
chosen => {
|
||||||
|
root.appearance-index = 2;
|
||||||
|
root.appearance-changed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Compact message density";
|
||||||
|
value: false;
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADVANCED (collapsed by default per §10)
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: advanced-expanded ? "v" : ">";
|
||||||
|
color: hint-color;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "ADVANCED";
|
||||||
|
color: hint-color;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.advanced-expanded = !root.advanced-expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if advanced-expanded: VerticalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Log level: Info (release default)";
|
||||||
|
value: false;
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Clear local state (messages + window size)";
|
||||||
|
value: false;
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
SettingsToggleRow {
|
||||||
|
label: "Reset settings to defaults";
|
||||||
|
value: false;
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user