Files
Unlucky-Launcher/crates/app-iced/src/components/scroll.rs
T
AskaEth 037c68d0e6 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)。
2026-11-08 15:11:54 +08:00

225 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
//!
//! ## 根因(读 iced 0.14 的源码,不是猜测)
//!
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
//!
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
//! 默认 `None`,右内边距**恒 0**;
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
//! width.max(scroller_width) + 2 * margin`。
//!
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
//! 「滚动条骑在卡片右边缘」。
//!
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
//!
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
//!
//! ## 本模块的做法:恒定槽位
//!
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
//!
//! ```text
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
//! │ 内容(可滚) │ 轨道 │
//! ```
//!
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
//!
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
//!
//! ## 谁该用
//!
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
use iced::widget::{container, scrollable};
use iced::{Element, Length, Padding, Theme};
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
///
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
rail_width + 2.0 * margin
}
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
///
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
#[cfg(test)]
pub fn content_width(viewport: f32, slot: f32) -> f32 {
(viewport - slot).max(0.0)
}
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
///
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
pub fn vertical<'a, Message, Renderer>(
rail_width: f32,
margin: f32,
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::text::Renderer + 'a,
{
scrollable(beside(rail_slot(rail_width, margin), content))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(rail_width)
.scroller_width(rail_width)
.margin(margin),
))
.height(Length::Fill)
.style(style)
.into()
}
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
fn beside<'a, Message, Renderer>(
slot: f32,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
container(content)
.width(Length::Fill)
.padding(Padding {
right: slot,
..Padding::ZERO
})
.into()
}
#[cfg(test)]
mod tests {
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
//!
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
//!
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
//!
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, scrollable, space};
use iced::{Rectangle, Size};
use super::*;
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
const RAIL: f32 = 6.0;
const MARGIN: f32 = 2.0;
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
const VIEWPORT: f32 = 424.0;
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
const LONG: f32 = 4000.0;
const SHORT: f32 = 120.0;
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
///
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let filler: Element<'_, (), Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
let mut element: Element<'_, (), Theme, ()> =
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
#[test]
fn the_content_width_leaves_the_rail_its_own_slot() {
let slot = rail_slot(RAIL, MARGIN);
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(
long.width,
content_width(VIEWPORT, slot),
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
);
assert_eq!(
short.width,
content_width(VIEWPORT, slot),
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
);
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
// 所以「内容右缘 + margin」就是滑块的左缘。
let content_right = long.x + long.width;
let pill_left = content_right + MARGIN;
let pill_right = pill_left + RAIL;
assert_eq!(
pill_right + MARGIN,
VIEWPORT,
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
);
}
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
#[test]
fn the_slot_wrapper_keeps_the_full_viewport_width() {
let (gutter, _) = laid_out(LONG);
assert_eq!(gutter.width, VIEWPORT);
}
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
#[test]
fn long_and_short_pages_share_the_same_left_edge_and_width() {
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(long.x, short.x, "左边界必须一致");
assert_eq!(long.width, short.width, "宽度必须一致");
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
}
#[test]
fn content_width_never_goes_negative() {
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
}
}