fix(m173): 设置页滚动条并排在内容右侧(右侧恒留 10dp 槽)

根因(读 iced 0.14 源码,不是猜):`scrollable` 的竖直滚动条是浮层——
`iced_widget-0.14.2/src/scrollable.rs:1970-1980` 把轨道定位在
`bounds.right - (width.max(scroller_width) + 2*margin)`,而同文件 `:437` 给内容的横向
约束是整个视口宽(`Scrollbar::spacing` 默认 None ⇒ 右内边距恒 0)。本仓 6 轨 + 2×2 内缩
= 10dp,于是这 10dp 整条压在表单列内容上(1180 与 880 宽窗口都一样)。

改法:`components::scroll::vertical()` 在内容右侧恒留一条 `轨宽 + 2×margin` 的槽,轨道画在
槽里;`pages/settings/frame.rs::scroll` 改成一行转发。不采用 iced 的 `Scrollbar::spacing`,
因为它只在滑块可见时才预留(`scrollable.rs:478-500`),长/短页面切换内容会横跳 10dp。

数值(1180×760、100% DPI、tiny-skia、实测像素):
- 卡片右缘 799 → 789(内容可用宽 424 → 414 = 424 − 10);
- 滑块仍是 792..797(没有移动),与内容之间隔 2px 页面底色(= SCROLLBAR_MARGIN);
- 长页面(外观组)与短页面(Java 组,无滑块)左边界 376、宽 414 **逐项相同**;
- 150% 缩放下 621px = 414×1.5、滑块 9px = 6×1.5、槽 15px = 10×1.5,同比例不重叠。

守卫(两条都是数值,不看图):`components/scroll.rs` 与 `pages/settings/tests/scroll_fit.rs`
用 `Renderer = ()` 真跑 iced 的 `layout()`,断言「内容宽 == 容器宽 − 槽宽」且长/短两态一致;
把 `beside` 的 padding 改回 `Padding::ZERO`,4 条断言立刻红(424 vs 414)。
This commit is contained in:
AskaEth committed 2026-11-08 15:11:54 +08:00
1 parent da5b837c7c
commit 037c68d0e6
5 files changed
+391 -15

No files matched your search

+34 -15
View File
@@ -12,8 +12,8 @@ pub(super) use super::super::frame::{
text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
};
use iced::widget::{column, container, mouse_area, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use iced::widget::{column, container, mouse_area, row};
use iced::{Alignment, Background, Border, Element, Length, Padding, Theme};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
@@ -137,20 +137,39 @@ fn group_item<'a>(
/// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。
///
/// 「外观」分组有 7 个控件 + 最多 3 行诊断,1180×760 下会超出可视区,所以这一层是必需的。
pub(super) fn scroll<'a>(
///
/// ## M173:滚动条**并排**在内容右侧
///
/// 原来这里是 `scrollable(content)` 裸调 + 轨宽 6 / 内缩 2。iced 的竖直滚动条是浮层:轨道按
/// 视口右边缘定位(`iced_widget-0.14.2/src/scrollable.rs:1970`),而内容拿到的横向约束是**整个
/// 视口宽**(同文件 `:437`,`Scrollbar::spacing` 默认 `None` ⇒ 右内边距 0)。于是滚动条占的那条
/// 10dp 竖带(轨 6 + 两侧内缩 2×2)整条压在表单列最右边——用户看到的就是「滚动条骑在卡片上」。
///
/// 改法在 [`crate::app::components::scroll::vertical`]:内容右侧恒留一条 10dp 的槽,轨道画在槽里。
/// 槽宽来自同一个式子 `轨宽 + 2 × margin`,所以:
///
/// - 内容可用宽度 = 视口宽(424)− 槽宽(10)= **414**;
/// - 长页面(出滑块)与短页面(不出滑块)**内容宽度一致**——槽是内边距,不依赖 iced 的
/// `is_scrollbar_visible`(那正是 `Scrollbar::spacing` 不能用在这里的理由:它只在可见时预留,
/// 滑块一出现内容就横跳 10dp)。
///
/// 数值由 `components/scroll.rs` 与 `tests/scroll_fit.rs` 的**布局级**断言钉住(`Renderer = ()`
/// 真跑 iced 的 `layout()`),不是看图。
///
/// 只泛型到 `Renderer` 是为了让上面那条断言能在 `()` 渲染器上跑;产品调用点不写泛型。
pub(super) fn scroll<'a, Renderer>(
ui: &'a Ui,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(SCROLLBAR_WIDTH)
.scroller_width(SCROLLBAR_WIDTH)
.margin(SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(scroll_style(ui))
.into()
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Renderer: iced::advanced::text::Renderer + 'a,
{
crate::app::components::scroll::vertical(
SCROLLBAR_WIDTH,
SCROLLBAR_MARGIN,
scroll_style(ui),
content,
)
}
/// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
@@ -40,6 +40,7 @@ mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
mod scroll_fit;
mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
@@ -0,0 +1,130 @@
//! M173:设置页表单列的**滚动条槽**(滚动条并排,不压内容)。
//!
//! 这里守的是整页级别的数值契约,不是「哪一组有哪些行」——所以它不属于任何 section 文件。
//!
//! ## 怎么量
//!
//! iced 的控件树支持在测试里真跑布局:`iced_core` 在 debug 构建下给 `()` 实现了全套 `Renderer`
//! trait(`iced_core-0.14.0/src/renderer/null.rs`),于是 `frame::scroll::<()>(…)` 返回的
//! `Element<_, Message, Theme, ()>` 能被 `Widget::layout` 量出**真的**子节点尺寸。不需要开窗,
//! 也不需要 GPU——这是「数值断言,不是看图」的落点。
//!
//! 节点树(`iced_core/src/layout.rs:201`:`padded` 恒为「自己 + 一个子节点」):
//!
//! ```text
//! scrollable → [槽容器(Fill,右内边距 = 槽宽) → [内容]]
//! ```
//!
//! ## 抓什么 / 抓不住什么
//!
//! 抓得住:内容右侧真的留出了「轨宽 + 两侧 margin」的槽;长/短页面(出/不出滑块)内容宽度与左
//! 边界**逐项相同**;把 `app::components::scroll` 的 padding 改回 0,本文件立刻红。
//! 抓不住:滑块画在槽里的**像素**位置(那由 `pages/settings` 的出图与 iced 自己的 `Rail::new`
//! 公式担保,见 `src/components/scroll.rs` 文档里的源码行号)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, space};
use iced::{Element, Length, Rectangle, Size, Theme};
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use super::super::frame;
use super::super::frame::{SCROLLBAR_MARGIN, SCROLLBAR_WIDTH};
use crate::pages::Message;
use crate::theme::{Families, Ui};
/// 设置页表单列宽:`frame_metrics` 的 `column`(= `measure_cap_px()`,1180 与 880 窗口下都是它)。
const COLUMN: f32 = 424.0;
/// 视口高(1180×760 减去标题栏 / 页头 / 上下页边距之后的量级;只要求「长页 > 它」「短页 < 它」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(正文远超视口 ⇒ 一定出滑块)。
const LONG: f32 = 4000.0;
/// 短页面(正文装得下 ⇒ 一定不出滑块)。
const SHORT: f32 = 120.0;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 滚动条槽宽(dp):轨道 + 两侧留白,与 iced 的 `total_scrollbar_width` 同口径。
fn slot() -> f32 {
SCROLLBAR_WIDTH + 2.0 * SCROLLBAR_MARGIN
}
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let ui = ui();
let filler: Element<'_, Message, Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
// 走的是**产品那条** `frame::scroll`(泛型只为换成 `()` 渲染器,几何一字不改)。
let mut element = frame::scroll::<()>(&ui, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(COLUMN, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本条 mission 的核心数值断言**:内容可用宽度 = 容器宽 − 滚动条占位。
#[test]
fn the_form_column_reserves_a_rail_slot_beside_the_content() {
let (gutter, content) = laid_out(LONG);
assert_eq!(slot(), 10.0, "6 轨宽 + 2×2 内缩 = 10dp 的槽");
assert_eq!(gutter.width, COLUMN, "槽容器自身仍是满宽");
assert_eq!(
content.width,
COLUMN - slot(),
"内容可用宽度必须是「容器宽 − 滚动条槽」,否则轨道就压在内容右缘上"
);
assert_eq!(content.width, 414.0, "424 − 10 = 414");
}
/// 长页面(出滑块)与短页面(不出滑块)——内容宽度与左边界必须**逐项相同**。
///
/// 这是 M173 要求的「两张对照图 + 数值」里的数值那一半:只给一张出滑块的图说明不了「滑块出现时
/// 内容会不会跳」,必须两态同框比。
#[test]
fn the_content_does_not_jump_between_a_long_and_a_short_page() {
let (long_gutter, long) = laid_out(LONG);
let (short_gutter, short) = laid_out(SHORT);
assert_eq!(long_gutter.x, short_gutter.x, "槽容器的左边界不许变");
assert_eq!(long_gutter.width, short_gutter.width, "槽容器的宽度不许变");
assert_eq!(long.x, short.x, "左边界不许横跳");
assert_eq!(long.width, short.width, "宽度不许横跳");
assert_eq!(short.x, 0.0, "槽只占右侧:左侧内边距为 0");
assert_eq!(
short.x + short.width,
COLUMN - slot(),
"内容右缘 = 轨道的左缘(再往右 10dp 才是轨道自己)"
);
}
/// 反证:`content_width` 这条纯量与实际布局量出来的是同一个数(两处不许各算一套)。
#[test]
fn the_layout_agrees_with_the_pure_quantity() {
let (_, content) = laid_out(LONG);
assert_eq!(
content.width,
crate::app::components::scroll::content_width(COLUMN, slot())
);
}