From 5c40c64b5f92f6a5f922a3f4b598c290889b95db Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 18:18:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M93=20r3=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=A4=8D=E5=AE=A1=20r1=20=E5=9B=9B=E6=9D=A1=E8=BF=94?= =?UTF-8?q?=E5=B7=A5=EF=BC=88Canvas=20=E4=B8=8D=E5=90=83=20=CE=B1=20/=20?= =?UTF-8?q?=E5=B9=B3=E9=93=BA=E8=A1=A5=E7=BC=9D=20/=20=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E4=B8=8A=E7=A7=BB=20/=20=E6=96=87=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml` 自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。 P2-2(口径违约)Canvas 层不许吃面板 α - `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明, 只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 / 页头带)没有各自特判。 - 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、 Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。 P2-1 平铺不许留缝 - `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`, 按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀 (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。 - `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形): `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。 - 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1, 断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。 P2-3 诊断上移到首屏 - 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」 紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。 P2-4 文档对齐 - `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」, 窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。 - 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释), 保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。 门禁(工作区级,最终树):`cargo fmt --all --check` 干净; `cargo clippy --workspace --all-targets -- -D warnings` 无输出; `cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。 出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、 `r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。 --- crates/app-iced/src/app/state.rs | 2 +- crates/app-iced/src/app/tests/appearance.rs | 47 ++++++++++- crates/app-iced/src/app/update.rs | 2 +- crates/app-iced/src/lib.rs | 2 +- .../src/pages/appearance/background.rs | 82 +++++++++++++------ .../src/pages/appearance/background/tests.rs | 45 +++++++--- crates/app-iced/src/pages/appearance/mod.rs | 2 +- .../app-iced/src/pages/appearance/picture.rs | 7 +- .../app-iced/src/pages/appearance/section.rs | 33 ++++---- crates/app-iced/src/platform/mod.rs | 2 +- .../src/platform/win32/transparency.rs | 8 +- crates/app-iced/src/shell/mod.rs | 2 +- crates/app-iced/src/theme.rs | 32 ++++---- 13 files changed, 181 insertions(+), 85 deletions(-) diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 5f44ca1..6ab4ce7 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -110,7 +110,7 @@ pub struct App { pub(super) refreshes: u64, /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 pub(super) auto_selected: bool, - /// 窗口高度(背景图马赛克的视口判据;宽度在 `width`)。 + /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 pub(super) height: f32, /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index 1b56eb2..ac2b8df 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -144,9 +144,9 @@ fn a_broken_background_path_falls_back_silently_but_says_why() { ); } -/// 抓得住:真 PNG → 背景真的生效(有马赛克、有格数、配色走「背景图取色」这一级)。 -/// 抓不住:马赛克的观感(36dp 网格是已登记的降级),以及填充三种档的视觉差异(几何由 -/// `background` 的纯函数测试守)。 +/// 抓得住:真 PNG → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。 +/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的 +/// 「覆盖 ≥ 视口」断言守)。 #[test] fn a_decodable_png_makes_the_background_and_the_palette_active() { let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id())); @@ -211,6 +211,47 @@ fn the_settings_page_renders_the_real_appearance_group() { let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); } +/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 +/// +/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas, +/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。 +/// 抓不住:真实合成后的观感(那由截图看)。 +#[test] +fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() { + use unlucky_design::elevation::MaterialLayer; + + let mut app = headless(Options::default(), SchemeFlavor::Light); + app.appearance.apply(Change::PanelAlpha(0.55)); + app.rebuild_ui(); + + let ui = app.ui(); + assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui"); + assert_eq!( + ui.layer(MaterialLayer::Canvas).a, + 1.0, + "窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量" + ); + for layer in [MaterialLayer::Card, MaterialLayer::Overlay] { + assert!( + (ui.layer(layer).a - 0.55).abs() < 1e-6, + "{layer:?} 必须吃面板 α(实际 {})", + ui.layer(layer).a + ); + } + + // CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。 + let mut cpu = headless( + Options { + backend: Backend::Cpu, + ..Options::default() + }, + SchemeFlavor::Light, + ); + cpu.appearance.apply(Change::PanelAlpha(0.55)); + cpu.rebuild_ui(); + assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0"); +} + /// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调 /// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。 /// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。 diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index dcfec9b..9cd355d 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -87,7 +87,7 @@ impl App { Message::Window(event) => { if let window::Event::Resized(size) = event { self.width = size.width; - // 高度是背景马赛克的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 + // 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 self.height = size.height; self.view_dirty = true; self.refresh_view(); diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 66f6ac8..342fa77 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -12,7 +12,7 @@ //! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | -//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI —— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | +//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | //! //! ## 消费纪律 //! diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index 8023c6d..29eace1 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -1,11 +1,10 @@ //! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。 //! -//! ## 真图直出(M93 r2 修订) +//! ## 真图(M93 r2 起) //! -//! r1 因为 iced 的 `image` feature 没开,只能把像素画成 36dp 网格的马赛克。r2 打开了 -//! `iced/image`(`crates/app-iced/Cargo.toml`),于是**真图直出**:填充由 iced 的 `ContentFit` -//! 负责(`Cover` / `Contain`),平铺由本模块的 [`tiles`] 算网格、渲染层真的铺 N 个图块。 -//! 马赛克那条路已删除(不再有「每格一个颜色」的降级)。 +//! 渲染走 iced 的 `image`(`crates/app-iced/Cargo.toml` 的 `image-without-codecs`):`Cover` / +//! `Contain` 交给 `ContentFit`,`Tile` 用本模块的 [`tiles`] 算网格后由 `picture` 真铺图块。 +//! 本模块只负责「解码一次(取色 + 诊断 + 尺寸)」与「网格/暗化的纯数学」。 //! //! | 档 | 渲染 | 露底色 | //! |---|---|---| @@ -36,10 +35,19 @@ pub const MAX_EDGE: u32 = 256; /// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。 pub const MAX_SOURCE_EDGE: u32 = 16_384; -/// 平铺档一次最多铺多少个图块(列 × 行)。超了就不铺——避免一张 8×8 的小图在 4K 上拼出 -/// 上万个子控件。 +/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`tiles`] 的不变量。 pub const MAX_TILES: u32 = 64; +/// 图块放大步长(每次乘这个数,直到块数落进预算)。 +pub const TILE_SCALE_STEP: f32 = 1.15; + +/// 图块缩放上限(**只是安全阀**,正常情况下远达不到)。 +/// +/// 取 1024 而不是 64:`cols·rows ≤ MAX_TILES` 与「覆盖 ≥ 视口」在**极小图**上要求很大的缩放 +/// (1×1 的图在 1180×760 上要 ~118 倍才能用 64 块盖满)。取小了就等于两者不可兼得——那一侧 +/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。 +pub const MAX_TILE_SCALE: f32 = 1024.0; + /// 解码失败的原因(人话在 [`LoadError::describe`])。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadError { @@ -165,13 +173,15 @@ pub fn dim_alpha(dim: f32) -> f32 { } } -/// 平铺档的网格(列 × 行)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 平铺档的网格(列 × 行 × 图块缩放)。 +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Tiles { /// 列数。 pub cols: u32, /// 行数。 pub rows: u32, + /// 单块图相对原尺寸的缩放(`1.0` = 原尺寸;超预算时**只放大图块**,不减少覆盖)。 + pub scale: f32, } impl Tiles { @@ -180,33 +190,51 @@ impl Tiles { pub fn count(self) -> u32 { self.cols * self.rows } + + /// 一块图的实际边长(dp):`源尺寸 × scale`。 + #[must_use] + pub fn tile_size(self, source: (u32, u32)) -> (f32, f32) { + ( + source.0.max(1) as f32 * self.scale, + source.1.max(1) as f32 * self.scale, + ) + } + + /// 铺满后覆盖的宽高(dp):**恒 ≥ 视口**([`tiles`] 的硬不变量,单测钉住)。 + #[must_use] + pub fn covered(self, source: (u32, u32)) -> (f32, f32) { + let (w, h) = self.tile_size(source); + (self.cols as f32 * w, self.rows as f32 * h) + } } -/// 平铺档要铺几列几行(原图 1 像素 = 1 dp;总块数不超过 [`MAX_TILES`])。 +/// 平铺档的网格(原图 1 像素 = 1 dp)。 /// -/// 图比视口大时是 `1×1`(等于「以原尺寸裁切」),与小图平铺是同一套算式。 +/// **硬不变量:`covered >= viewport`**——任何情况都不许留缝(复审 r1 P2-1 的返工点)。 +/// +/// r1 的写法是「超预算就减列/减行」,于是 120×120 的图需要 10×7=70 块 > [`MAX_TILES`], +/// 列数被砍到 9 ⇒ 覆盖宽 1080 < 1180,右缘 100px 裸白。现在的做法是**放大图块步长**: +/// `scale` 从 1.0 起按 [`TILE_SCALE_STEP`] 逐级变大,直到 `ceil(视口/图块) 的乘积 ≤ 预算`。 +/// 图块按同一比例放大 ⇒ 图不变形;`ceil` 保证每行/每列都盖过视口边界(多出来的部分由渲染器裁掉)。 +/// +/// 图比视口大时是 `1×1, scale=1.0`(= 以原尺寸裁切),与小图平铺是同一套算式。 #[must_use] pub fn tiles(source: (u32, u32), viewport: (f32, f32)) -> Tiles { - let width = source.0.max(1) as f32; - let height = source.1.max(1) as f32; - let cols = ceil_div(viewport.0.max(1.0), width); - let rows = ceil_div(viewport.1.max(1.0), height); - - // 超预算时按比例退:先保列再保行,至少留 1×1(否则什么都画不出来)。 let mut tiles = Tiles { - cols: cols.max(1), - rows: rows.max(1), + cols: 1, + rows: 1, + scale: 1.0, }; - while tiles.count() > MAX_TILES { - if tiles.cols > 1 { - tiles.cols -= 1; - } else if tiles.rows > 1 { - tiles.rows -= 1; - } else { - break; + + loop { + let (width, height) = tiles.tile_size(source); + tiles.cols = ceil_div(viewport.0.max(1.0), width); + tiles.rows = ceil_div(viewport.1.max(1.0), height); + if tiles.count() <= MAX_TILES || tiles.scale >= MAX_TILE_SCALE { + return tiles; } + tiles.scale = (tiles.scale * TILE_SCALE_STEP).min(MAX_TILE_SCALE); } - tiles } fn ceil_div(value: f32, step: f32) -> u32 { diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index 0c552ec..ec1c626 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -97,21 +97,44 @@ fn dim_is_clamped_to_the_supported_range() { assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)"); } +/// **P2-1 的守门**:任何尺寸/视口组合下,平铺网格铺出来的覆盖宽高都必须 ≥ 视口(不许留缝), +/// 同时块数不超预算(不留缝的代价只能是「放大图块」,不能是「少铺」)。 +/// +/// 抓得住:谁把 `tiles()` 改回「超预算就减列」——120×120 @ 1180×760 那条会立刻红 +/// (r1 实测右缘裸白 100px)。 +/// 抓不住:渲染器把单块图铺进格子时的实际像素对齐(有没有半像素缝由截图看)。 #[test] -fn tile_geometry_counts_enough_tiles_and_stays_within_budget() { - // 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切); - // ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。 - // 抓不住:铺出来的缝(渲染层的事,看截图)。 - let small = tiles((200, 200), (640.0, 480.0)); - assert_eq!((small.cols, small.rows), (4, 3)); - assert_eq!(small.count(), 12); +fn tile_grid_always_covers_the_viewport_within_budget() { + let cases = [ + ((120, 120), (1180.0, 760.0)), + ((300, 200), (1180.0, 760.0)), + ((16, 16), (1920.0, 1080.0)), + ((8, 8), (3840.0, 2160.0)), + ((1600, 900), (1180.0, 760.0)), + ((1, 1), (1180.0, 760.0)), + ]; + for (source, viewport) in cases { + let grid = tiles(source, viewport); + let (covered_w, covered_h) = grid.covered(source); + assert!( + covered_w >= viewport.0 - 0.01 && covered_h >= viewport.1 - 0.01, + "{source:?} @ {viewport:?} 覆盖 {covered_w}×{covered_h} 小于视口(会留缝):{grid:?}" + ); + assert!( + grid.count() <= MAX_TILES, + "{source:?} @ {viewport:?} 块数 {} 超预算", + grid.count() + ); + assert!(grid.scale >= 1.0, "图块只许放大,不许缩小:{grid:?}"); + } + // 图比视口大 ⇒ 1×1 原尺寸裁切(scale 必须还是 1.0,否则等于缩小了原图)。 let big = tiles((1600, 900), (1180.0, 760.0)); - assert_eq!((big.cols, big.rows), (1, 1), "图比视口大 ⇒ 以原尺寸裁切"); + assert_eq!((big.cols, big.rows, big.scale), (1, 1, 1.0)); - let tiny = tiles((16, 16), (3840.0, 2160.0)); - assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算"); - assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块"); + // 小图按原尺寸铺够(不放大时)。 + let small = tiles((300, 200), (1180.0, 760.0)); + assert_eq!((small.cols, small.rows, small.scale), (4, 4, 1.0)); } /// 编译期钉住:填充档解析走 core 的词表(`cover`/`contain`/`tile`)。 diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 0f217e6..cdb85da 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -17,7 +17,7 @@ //! | 文件 | 是什么 | 有没有 IO | //! |---|---|---| //! | [`alpha`] | 两层 α 的钳位链、CPU 档降级、滑块映射、下发字节 | 无(纯函数) | -//! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) | +//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | //! | `section` | 设置页「外观」分组(真控件) | 无 | diff --git a/crates/app-iced/src/pages/appearance/picture.rs b/crates/app-iced/src/pages/appearance/picture.rs index ea859e1..646ea9d 100644 --- a/crates/app-iced/src/pages/appearance/picture.rs +++ b/crates/app-iced/src/pages/appearance/picture.rs @@ -3,11 +3,10 @@ //! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是 //! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。 //! -//! ## 为什么是「真图」 +//! ## 三种填充 //! -//! M93 r1 走的是「36dp 网格马赛克」(那时 iced 的 `image` feature 没开)。r2 打开后改成本文件: -//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做),`Tile` 用 -//! [`background::tiles`] 算行列数后真的铺 N 个图块(`ContentFit::None` = 原尺寸)。 +//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做);`Tile` 用 +//! [`background::tiles`] 算行列后铺 N 个图块(单块尺寸 = 原尺寸 × `tiles.scale`)。 //! //! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=dim」**:图不透明时它与「向底色插值」 //! 数学等价([`background::dim_alpha`] 有推导),而普通 alpha 混合 CPU/GPU 两档都支持。 diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index e8b00af..671193f 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -120,7 +120,21 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { .width(Length::Fill), ); - // ⑤ 填充 + // ⑤ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** + // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 + for note in state.diagnostics() { + rows = rows.push(note_line(ui, note, ui.color(Role::Error))); + } + if let Some(note) = state.backend_note() { + rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + if let Some(note) = state.palette_note() { + rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + let resolved = format!("Colours: {}", state.palette_level()); + rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); + + // ⑥ 填充 rows = rows.push(choice_row( ui, "Fill", @@ -146,7 +160,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑥ 暗化 + // ⑦ 暗化 rows = rows.push(slider_row( ui, "Dim", @@ -161,7 +175,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ⑦ 配色来源 + // ⑧ 配色来源 rows = rows.push(choice_row( ui, "Colour source", @@ -187,19 +201,6 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑧ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默** - for note in state.diagnostics() { - rows = rows.push(note_line(ui, note, ui.color(Role::Error))); - } - if let Some(note) = state.backend_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state.palette_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - let resolved = format!("Colours: {}", state.palette_level()); - rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - card(ui, rows) } diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index 0689fbe..3c37f98 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -2,7 +2,7 @@ //! //! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; -//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。 +//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)。 //! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 65821cf..2985ab8 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -11,8 +11,12 @@ //! ## 只做 LWA_ALPHA //! //! `LWA_COLORKEY` / `DwmEnableBlurBehindWindow` 一律不用:色键会把某个颜色整块变透明(视觉 -//! 事故),模糊是另一条路线(D36 已排除)。`WS_EX_LAYERED` 是调用前提,由窗口创建侧设置, -//! 本文件不负责——它只做「给定句柄,把 α 下发下去」。 +//! 事故),模糊是另一条路线(D36 已排除)。 +//! +//! `WS_EX_LAYERED`(`SetLayeredWindowAttributes` 的调用前提)**也归本文件**:加扩展位只有 +//! `Get/SetWindowLongPtrW` 能走,必须 `unsafe`,而全仓放行 unsafe 的文件集合恰好只有本文件。 +//! 所以窗口创建侧(`app::boot`)不碰扩展样式,它只负责拿到句柄后先调 [`ensure_layered`] +//! 再调 [`apply_window_alpha`]——两件事都在同一个 unsafe 落点里,门禁仍是「集合相等」。 //! //! ## 非 Windows //! diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 122366c..fff52d9 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -63,7 +63,7 @@ pub fn view_with_content<'a>( view_with_background(ui, selected, app_title, None, content, forced) } -/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的背景图马赛克)。 +/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。 /// /// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!` /// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。 diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 000c62b..4d512db 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -1,18 +1,13 @@ //! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 //! -//! ## 边界(M61 `design-令牌约定.md` §0) -//! -//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方:组件层只调 [`Ui`], -//! 绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**` 守门)。 +//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调 +//! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。 //! //! ## 为什么不用 `iced::theme::Base` 承载 49 个角色 //! -//! iced 的 `Palette` 只有 6 个颜色,M3 有 49 个角色(M60 Q5 + M61 §9-0)。所以真源是本模块持有 -//! 的 [`Scheme`],iced 的 `Theme` 只用来「换名字」——名字变了 iced 才重算受主题影响的缓存, -//! `palette()` 只给 devtools 看,控件外观由各自的 `Style` 注入。 -//! -//! `success`/`warning` 在 M3 里没有对应角色,按语义最近的现成角色映射 -//! (`success → Secondary`、`warning → Tertiary`),任何控件都不要读这两个槽位。 +//! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme` +//! 只用来「换名字」(名字变了 iced 才重算缓存),控件外观由各自的 `Style` 注入。 +//! `success`/`warning` 没有对应角色,按邻近语义映射(→ `Secondary` / `Tertiary`),不许读。 use iced::border::Radius; use iced::theme::Palette; @@ -113,14 +108,19 @@ impl Ui { to_iced(self.scheme.get(role)) } - /// 材质层(Canvas/Card/Overlay)→ 容器色,**已乘上面板 α**(M93 的唯一来源)。 + /// 材质层(Canvas/Card/Overlay)→ 容器色;**只有令牌允许半透明的层才乘面板 α**(M93 唯一来源)。 /// - /// 为什么 α 施加在这里:面板 α 是**表面色**的属性,而全仓所有表面色都从这个函数出去 - /// (外壳的根容器/标题栏/导航轨、`pages::frame::card`、空态图标块……),乘一次就让 - /// 四页 + 外壳自动同源,调用点没有机会「各处自乘」。`α = 1.0` 时逐字节等于原色。 + /// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片), + /// 一次同源、调用点无从「各自自乘」。Canvas 层恒不透明(r1 P2-2):令牌 + /// [`MaterialLayer::allows_translucency`] 写死 `Canvas => false`(窗口底),而三个窗口键与 + /// 导航轨、页头带都取 Canvas——给它们乘 α 会让文字直接压在背景照片上(α=0.55 实测不可读)。 pub fn layer(&self, layer: MaterialLayer) -> Color { - self.color(layer.surface_role()) - .scale_alpha(self.panel_alpha) + let base = self.color(layer.surface_role()); + if layer.allows_translucency() { + base.scale_alpha(self.panel_alpha) + } else { + base + } } /// 当前面板 α(CPU 档由 `app` 侧强制成 1.0)。