代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。
M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX
P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill
视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
(只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)
视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中
视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)
P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)
顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)
门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
401 lines
13 KiB
Rust
401 lines
13 KiB
Rust
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||
//!
|
||
//! ## 启动序列(顺序有讲究)
|
||
//!
|
||
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||
//!
|
||
//! ## 为什么截图要等 3 帧
|
||
//!
|
||
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::OnceLock;
|
||
use std::time::Instant;
|
||
|
||
use iced::{window, window::Direction, Element, Subscription, Task};
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
|
||
use crate::cli::Options;
|
||
use crate::fonts;
|
||
use crate::probe;
|
||
use crate::shell::{self, NavItem};
|
||
use crate::shot;
|
||
use crate::theme::{Families, Ui};
|
||
|
||
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||
width: 1180.0,
|
||
height: 760.0,
|
||
};
|
||
|
||
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||
|
||
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||
const MIN_SIZE: iced::Size = iced::Size {
|
||
width: 880.0,
|
||
height: 560.0,
|
||
};
|
||
|
||
/// 截图落盘的帧号(见模块文档)。
|
||
const CAPTURE_AT_FRAME: u32 = 3;
|
||
|
||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||
/// 首帧日志只打一次。
|
||
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||
|
||
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
|
||
pub struct App {
|
||
options: Options,
|
||
families: Families,
|
||
ui: Ui,
|
||
nav: NavItem,
|
||
window: Option<window::Id>,
|
||
frames: u32,
|
||
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||
theme_resolved: bool,
|
||
}
|
||
|
||
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||
#[derive(Debug, Clone)]
|
||
pub enum Message {
|
||
/// 启动即发一次(`iced::application` 的 boot 闭包)。
|
||
Boot,
|
||
/// `window::latest()` 的结果。
|
||
WindowReady(Option<window::Id>),
|
||
/// 系统主题探测结果。
|
||
SystemTheme(iced::theme::Mode),
|
||
/// 一帧画完(只有截图模式才订阅)。
|
||
Frame,
|
||
/// 导航项切换。
|
||
Select(NavItem),
|
||
/// 按下标题栏 → 拖窗。
|
||
DragWindow,
|
||
/// 按下边缘热区 → 缩放窗口。
|
||
ResizeStart(Direction),
|
||
/// 最小化。
|
||
Minimize,
|
||
/// 最大化/还原。
|
||
ToggleMaximize,
|
||
/// 关闭。
|
||
Close,
|
||
/// 截图回来了。
|
||
Captured(window::Screenshot),
|
||
}
|
||
|
||
impl App {
|
||
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||
let options_theme_given = options.shot_flavor().is_some();
|
||
Self {
|
||
ui: Ui::new(flavor, families),
|
||
families,
|
||
options,
|
||
nav: NavItem::Instances,
|
||
window: None,
|
||
frames: 0,
|
||
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||
theme_resolved: options_theme_given,
|
||
}
|
||
}
|
||
|
||
fn has_screenshot(&self) -> bool {
|
||
self.options.screenshot.is_some()
|
||
}
|
||
|
||
fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::Boot => {
|
||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||
|
||
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||
if self.options.shot_flavor().is_none() {
|
||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||
}
|
||
|
||
Task::batch(tasks)
|
||
}
|
||
Message::WindowReady(id) => {
|
||
self.window = id;
|
||
Task::none()
|
||
}
|
||
Message::SystemTheme(mode) => {
|
||
let flavor = match mode {
|
||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||
};
|
||
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||
self.set_flavor(flavor);
|
||
self.theme_resolved = true;
|
||
Task::none()
|
||
}
|
||
Message::Frame => self.on_frame(),
|
||
Message::Select(nav) => {
|
||
self.nav = nav;
|
||
Task::none()
|
||
}
|
||
Message::DragWindow => match self.window {
|
||
Some(id) => window::drag(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::ResizeStart(direction) => match self.window {
|
||
Some(id) => window::drag_resize(id, direction),
|
||
None => Task::none(),
|
||
},
|
||
Message::Minimize => match self.window {
|
||
Some(id) => window::minimize(id, true),
|
||
None => Task::none(),
|
||
},
|
||
Message::ToggleMaximize => match self.window {
|
||
Some(id) => window::toggle_maximize(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::Close => iced::exit(),
|
||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||
}
|
||
}
|
||
|
||
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||
fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||
if self.ui.flavor() == flavor {
|
||
return;
|
||
}
|
||
|
||
let next = Ui::new(flavor, self.families);
|
||
eprintln!(
|
||
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||
self.ui.name(),
|
||
next.name()
|
||
);
|
||
self.ui = next;
|
||
}
|
||
fn on_frame(&mut self) -> Task<Message> {
|
||
self.frames += 1;
|
||
|
||
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||
return Task::none();
|
||
}
|
||
|
||
match self.window {
|
||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||
None => Task::none(),
|
||
}
|
||
}
|
||
|
||
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||
let Some(path) = self.options.screenshot.clone() else {
|
||
return Task::none();
|
||
};
|
||
|
||
match shot::save(screenshot, &path) {
|
||
Ok(summary) => {
|
||
eprintln!("{}", summary.describe());
|
||
eprintln!(
|
||
"[shot] theme={} renderer={}",
|
||
shot::flavor_slug(&self.ui),
|
||
renderer_note(self.options.backend)
|
||
);
|
||
}
|
||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||
}
|
||
|
||
iced::exit()
|
||
}
|
||
|
||
fn subscription(&self) -> Subscription<Message> {
|
||
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||
if self.wants_frames() {
|
||
window::frames().map(|_| Message::Frame)
|
||
} else {
|
||
Subscription::none()
|
||
}
|
||
}
|
||
|
||
/// 是否订阅帧事件(= 截图模式)。
|
||
fn wants_frames(&self) -> bool {
|
||
self.has_screenshot()
|
||
}
|
||
|
||
fn theme(&self) -> iced::Theme {
|
||
self.ui.theme()
|
||
}
|
||
|
||
fn title(&self) -> String {
|
||
APP_TITLE.to_owned()
|
||
}
|
||
|
||
fn view(&self) -> Element<'_, Message> {
|
||
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||
if let Some(started) = STARTED.get() {
|
||
eprintln!(
|
||
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||
started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
}
|
||
}
|
||
|
||
shell::view(
|
||
&self.ui,
|
||
self.nav,
|
||
APP_TITLE,
|
||
self.nav.label(),
|
||
"Skeleton only: titlebar, navigation rail and one card. \
|
||
Business pages, the component library and the glass shader are follow-up missions.",
|
||
self.options.force_state,
|
||
)
|
||
}
|
||
}
|
||
|
||
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||
if cfg!(feature = "gpu") {
|
||
match backend {
|
||
crate::Backend::Cpu => "cpu(forced)",
|
||
crate::Backend::Gpu => "gpu(preferred)",
|
||
crate::Backend::Auto => "auto",
|
||
}
|
||
} else {
|
||
"cpu(build without wgpu)"
|
||
}
|
||
}
|
||
|
||
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||
if flavor.is_dark() {
|
||
"ull-m3-dark"
|
||
} else {
|
||
"ull-m3-light"
|
||
}
|
||
}
|
||
|
||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||
pub(crate) fn run(options: Options) -> iced::Result {
|
||
let _ = STARTED.set(Instant::now());
|
||
|
||
let probe_started = Instant::now();
|
||
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
|
||
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
|
||
let needs_probe = options.backend == crate::Backend::Auto;
|
||
let probe = if needs_probe {
|
||
probe::Probe::run()
|
||
} else {
|
||
probe::Probe::default()
|
||
};
|
||
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
|
||
probe::apply(options.backend, &probe);
|
||
if cfg!(feature = "gpu") {
|
||
if needs_probe {
|
||
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||
} else {
|
||
eprintln!(
|
||
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||
options.backend.name()
|
||
);
|
||
}
|
||
}
|
||
|
||
let font_started = Instant::now();
|
||
let resolved = fonts::resolve();
|
||
eprintln!(
|
||
"[font] 求值耗时 {:.0} ms",
|
||
font_started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
eprintln!("{}", resolved.describe());
|
||
if resolved.has_synthetic() {
|
||
eprintln!(
|
||
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
|
||
);
|
||
}
|
||
let families = resolved.families();
|
||
|
||
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||
let boot_options = options.clone();
|
||
|
||
let application = iced::application(
|
||
move || {
|
||
(
|
||
App::new(boot_options.clone(), families, flavor),
|
||
Task::done(Message::Boot),
|
||
)
|
||
},
|
||
App::update,
|
||
App::view,
|
||
)
|
||
.title(App::title)
|
||
.theme(App::theme)
|
||
.subscription(App::subscription)
|
||
.default_font(iced::Font {
|
||
family: iced::font::Family::Name(families.text),
|
||
..iced::Font::DEFAULT
|
||
})
|
||
.window(window::Settings {
|
||
size: WINDOW_SIZE,
|
||
min_size: Some(MIN_SIZE),
|
||
decorations: false,
|
||
resizable: true,
|
||
..window::Settings::default()
|
||
});
|
||
|
||
match options.scale_factor {
|
||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||
None => application.run(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
#[test]
|
||
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||
app.nav = NavItem::Logs;
|
||
assert_eq!(app.ui.name(), "ull-m3-light");
|
||
|
||
app.set_flavor(SchemeFlavor::Dark);
|
||
|
||
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
|
||
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
|
||
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||
}
|
||
|
||
#[test]
|
||
fn window_size_is_the_acceptance_size() {
|
||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||
const {
|
||
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slug_matches_the_theme_name() {
|
||
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||
}
|
||
|
||
#[test]
|
||
fn frames_are_only_requested_in_screenshot_mode() {
|
||
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||
|
||
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||
assert!(
|
||
app.wants_frames(),
|
||
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||
);
|
||
}
|
||
}
|