feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)

D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
This commit is contained in:
AskaEth committed 2026-11-02 09:29:31 +08:00
1 parent 00f132d8ce
commit f159529936
69 files changed
+441 -129

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
//! 主题与外观的**纯**判据:轮询节拍、背景图缓存键与量化、系统主题取值。
//!
//! 这一层原本混在 `crates/app` 的 `ui_bind.rs` / `theme_runtime.rs` 里,与「把值灌进窗口属性」
//! 的绑定代码缠在一起,于是两个 UI 版都得各写一遍。抽到这里的只有**判据**:什么时候该重读配置、
//! 什么时候该重建背景图、系统主题该报哪个值。灌属性仍然由 UI 侧做,本模块不碰任何框架类型。
use crate::prefs::BgFill;
/// 配置热切轮询间隔(拍数,33ms/拍 → 约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效)。
pub const CONFIG_POLL_TICKS: u64 = 30;
/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。
pub const SYSTEM_THEME_POLL_TICKS: u64 = 90;
/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。
pub const TILE_REGENERATE_STEP: u32 = 128;
/// 系统主题取值(UI 侧根组件属性就用这三个值:0 未知 / 1 浅色 / 2 深色)。
pub const SYSTEM_THEME_UNKNOWN: i32 = 0;
/// 浅色。
pub const SYSTEM_THEME_LIGHT: i32 = 1;
/// 深色。
pub const SYSTEM_THEME_DARK: i32 = 2;
/// 背景图加工输入(值相同就不重新解码)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackgroundKey {
/// 背景图路径
pub path: String,
/// 填充模式
pub fill: BgFill,
/// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0))
pub viewport: (u32, u32),
}
/// 本拍是否该比对一次配置文件 `mtime`(热切的采样点)。
pub fn config_poll_due(ticks: u64) -> bool {
ticks.is_multiple_of(CONFIG_POLL_TICKS)
}
/// 本拍是否该兜底探测系统主题。
///
/// 启动时先探一次(`probed == false`),之后只在「主题是 auto + 后端不认识系统主题」时
/// 按 [`SYSTEM_THEME_POLL_TICKS`] 兜底轮询。
pub fn system_theme_poll_due(probed: bool, auto: bool, backend_known: bool, ticks: u64) -> bool {
!probed || (auto && !backend_known && ticks.is_multiple_of(SYSTEM_THEME_POLL_TICKS))
}
/// 系统主题取值(纯映射,便于单测)。
pub fn system_theme_code(theme: unlucky_core::platform::theme_probe::SystemTheme) -> i32 {
use unlucky_core::platform::theme_probe::SystemTheme;
match theme {
SystemTheme::Light => SYSTEM_THEME_LIGHT,
SystemTheme::Dark => SYSTEM_THEME_DARK,
SystemTheme::Unknown => SYSTEM_THEME_UNKNOWN,
}
}
/// 探测本机系统主题并给出 UI 侧属性值。
pub fn system_theme_value() -> i32 {
system_theme_code(unlucky_core::platform::theme_probe::probe())
}
/// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。
///
/// 非 tile 模式恒为 (0, 0):cover/contain 不依赖窗口尺寸,缩放只让渲染器拉伸。
pub fn tile_viewport(fill: BgFill, size: (u32, u32)) -> (u32, u32) {
if fill != BgFill::Tile {
return (0, 0);
}
(
size.0 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
size.1 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
)
}
#[cfg(test)]
mod tests {
use super::{
config_poll_due, system_theme_code, system_theme_poll_due, tile_viewport, BackgroundKey,
CONFIG_POLL_TICKS, SYSTEM_THEME_DARK, SYSTEM_THEME_LIGHT, SYSTEM_THEME_POLL_TICKS,
SYSTEM_THEME_UNKNOWN, TILE_REGENERATE_STEP,
};
use crate::prefs::BgFill;
use unlucky_core::platform::theme_probe::SystemTheme;
#[test]
fn config_poll_fires_every_interval() {
assert!(config_poll_due(0));
assert!(!config_poll_due(1));
assert!(config_poll_due(CONFIG_POLL_TICKS));
assert!(!config_poll_due(CONFIG_POLL_TICKS + 1));
}
#[test]
fn system_theme_probe_backs_off_after_first_read() {
// 启动时无条件探一次
assert!(system_theme_poll_due(false, false, true, 1));
// 探过、主题非 auto:不再轮询
assert!(!system_theme_poll_due(
true,
false,
false,
SYSTEM_THEME_POLL_TICKS
));
// 探过、auto、后端认识系统主题:不再轮询
assert!(!system_theme_poll_due(
true,
true,
true,
SYSTEM_THEME_POLL_TICKS
));
// 探过、auto、后端不认识:按间隔兜底
assert!(!system_theme_poll_due(
true,
true,
false,
SYSTEM_THEME_POLL_TICKS - 1
));
assert!(system_theme_poll_due(
true,
true,
false,
SYSTEM_THEME_POLL_TICKS
));
}
#[test]
fn system_theme_codes_are_stable() {
assert_eq!(
system_theme_code(SystemTheme::Unknown),
SYSTEM_THEME_UNKNOWN
);
assert_eq!(system_theme_code(SystemTheme::Light), SYSTEM_THEME_LIGHT);
assert_eq!(system_theme_code(SystemTheme::Dark), SYSTEM_THEME_DARK);
}
#[test]
fn tile_viewport_quantizes_and_ignores_other_fills() {
assert_eq!(tile_viewport(BgFill::Cover, (1180, 760)), (0, 0));
assert_eq!(tile_viewport(BgFill::Contain, (1180, 760)), (0, 0));
let (width, height) = tile_viewport(BgFill::Tile, (1180, 760));
assert_eq!(width % TILE_REGENERATE_STEP, 0);
assert_eq!(height % TILE_REGENERATE_STEP, 0);
assert_eq!((width, height), (1152, 768));
}
#[test]
fn background_key_compares_by_value() {
let key = BackgroundKey {
path: "a.png".to_string(),
fill: BgFill::Tile,
viewport: (256, 256),
};
assert_eq!(key.clone(), key);
assert_ne!(
key,
BackgroundKey {
path: "a.png".to_string(),
fill: BgFill::Tile,
viewport: (128, 256),
}
);
}
}