一个基于 iced 的运行时驱动 UI 框架。
核心构想:控件、布局、行为三者解耦。布局是运行期文本文件——
同一个二进制,换设备只需换布局文件;深浅色不靠任何外部配置,直接写死在
每个控件代码里,用 iced 的 Theme 作为标准接口。
| 体系 | 位置 | 内容 | 决定什么 |
|---|---|---|---|
| 控件 | src/widgets/*.rs |
每个控件一个文件,实现 WidgetDef |
控件行为 + 深浅色配色 |
| 布局 | layouts/{common,desktop}/*.ron |
平铺的两张表:areas + widgets | 界面长什么样 |
Bern 默认保留操作系统标题栏;应用可以通过 WindowOptions 单独隐藏标题栏,
不影响其他 Bern 项目:
iced::application(boot, update, view)
.window(
bern::WindowOptions::new()
.hide_title_bar(true)
.into_settings(),
)
.run()hide_title_bar(true) 在 macOS 上会保留 decorations,并启用透明标题栏、
隐藏标题文字及 full-size content view。这样内容可以延伸到窗口顶部,同时
保留 AppKit 的原生圆角、阴影和窗口按钮。其他平台目前回退为关闭窗口装饰;
如需鼠标拖动或自定义窗口按钮,应在应用界面层提供相应交互。
window_controls 是从 Flutter NipaPlay 的 WindowControlButtons 直接移植的
自定义标题栏控件。每个矩形按钮为 46×40px,完整三键总宽 138px;悬浮和
按下背景使用 90ms easeOutCubic 动画。普通按钮随深浅色显示半透明背景,
关闭按钮悬浮为 #E81123、按下为 #C50F1F 并使用白色图标。
// 完整:最小化、最大化/还原、关闭
Widget(id: "window_controls", kind: "window_controls", area: "chrome",
props: {
"maximized": "false",
"leading_items": "dark_mode_rounded:切换主题:theme_toggle:20,settings_rounded:设置:settings:20",
})
// 只保留关闭键,宽度自动缩为 46px
Widget(id: "dialog_close", kind: "window_controls", area: "title",
props: { "close_only": "true" })控件发布 EventKind::WindowControl(action);原生窗口直接从应用的 update
返回 bern::perform_window_control_action(action)。maximized 用于在
crop_square_rounded(最大化)与 filter_none_rounded(还原)图标及 Tooltip
之间切换。close_only 版本也可以由应用把 Close 解释成关闭虚拟窗口,而
不是退出整个进程。
leading_items 会在最小化键左侧插入任意数量的同风格矩形按钮,格式为
icon:tooltip:event_id[:icon_size],多项用逗号分隔。点击扩展按钮发布普通
Pressed 事件;它们的 event_id 和 Tab 项一样必须注册到应用的 ids.rs。
完整三大键固定占 138px,每增加一项总宽自动增加 46px;close_only=true
时忽略扩展项并保持 46px 宽。
三大键和扩展键的 Tooltip 与 icon_button 共用同一套主题样式:深色为深灰
底白字,浅色为近白底黑字;样式在 Bern 当前主题构建时解析,不受应用外层
固定 iced 主题影响。
布局文件不写嵌套树,而是两张平表,用 id 表达层级:
Layout(
name: "phone login",
areas: [
Area(id: "root", kind: Stack),
Area(id: "form", kind: Column, parent: "root", padding: 16, spacing: 10),
],
widgets: [
// 页面背景就是一个普通控件:rect + z: -1
Widget(id: "bg", kind: "rect", area: "root", z: -1, size: Fill),
Widget(id: "greeting", kind: "title", area: "form", props: { "text": "Welcome" }),
Widget(id: "login", kind: "button", area: "form", size: Fill, props: { "label": "Sign in" }),
],
)Area是布局容器(Row / Column / Stack),parent指向父区域——任意深度都能表达,但没有缩进地狱;Widget的area声明它属于哪个区域,size声明尺寸策略(Auto/Fill/Fixed(px)/Weight(n));- 区域内排列规则:子区域在前,控件在后(Stack 内按
z排序)。
所有交互控件(button / icon_button / text_input)的 id 集中在应用
的一个 ids.rs 文件里管理,这是单一事实来源:
pub const THEME_TOGGLE: &str = "theme_toggle";
pub const ALL: &[&str] = &[THEME_TOGGLE, /* ... */];- 启动时
registry.ids().register_all(ids::ALL)注册; - 框架构建布局时校验:交互控件 id 必须已注册,否则报
BuildError::UnregisteredId——布局里拼错/漏改 id 会在启动时直接暴露; - 应用代码一律用常量匹配事件,不手写字符串字面量;
ids.rs里带一个测试,反向校验布局里的交互 id 与文件声明完全一致, 防止两侧漂移。
布局文件可以像积木一样组合:用 kind: "layout" 控件把另一个布局文件
作为控件绘制。src 通过 LayoutStore 解析——先找 desktop 目录,找不到
再回退到 common 目录。
// layouts/common/login_form.ron —— 共享积木
Layout(
name: "login_form",
areas: [ Area(id: "form", kind: Column, padding: 16, spacing: 10) ],
widgets: [
Widget(id: "greeting", kind: "title", area: "form", props: { "text": "Welcome" }),
Widget(id: "login", kind: "button", area: "form", size: Fill, props: { "label": "Sign in" }),
],
)// layouts/desktop/login_page.ron —— 桌面页面,把积木画进来
Layout(
name: "login_page",
areas: [ Area(id: "root", kind: Stack) ],
widgets: [
Widget(id: "bg", kind: "rect", area: "root", z: -1, size: Fill),
Widget(id: "form", kind: "layout", area: "root", props: { "src": "login_form" }),
],
)被嵌入的布局里所有 id 都会加上前缀(form.greeting、form.login),
所以同一个积木可以在一个页面里用多次,事件也能区分是哪个实例发出的。
layout 控件支持 size: Weight(...) / Fill 占用父级剩余空间,并可用
align_x: "left|center|right"、align_y: "top|center|bottom" 对齐内嵌
内容。例如顶栏右侧操作区:
Widget(id: "topbar_actions", kind: "layout", area: "topbar", size: Weight(1),
props: { "src": "topbar_actions", "align_x": "right" })需要让 Row / Column / Stack 容器本身填满父级时,可在 Area 上分别
设置 width: Fill、height: Fill;两轴分开配置不会让桌面顶栏意外占满
整个窗口高度。
bern 内嵌了 Flutter 的 Material Icons 字体与名称映射(Apache-2.0),
icon_button 和 icon 控件的图标名直接用 Flutter 里的名字:
Widget(id: "back", kind: "icon_button", area: "actions",
props: { "icon": "arrow_back_rounded", "tooltip": "返回" })
Widget(id: "heart", kind: "icon", area: "actions", props: { "name": "favorite_rounded", "size": "16" })icon_button 的 tooltip 是强制属性且不能为空;鼠标悬浮会显示提示文字。
缺失时布局构建直接返回 BuildError::MissingProp,避免只有图标却没有可发现
含义的按钮进入应用。Tooltip 会跟随当前主题:深色模式使用深灰容器和白字,
浅色模式使用近白容器和黑字;默认 6px 圆角,文字内边距为上下 3px、左右
9px。
- 8825 个图标,名字与
Icons.xxx完全一致(add、dark_mode、favorite_rounded……); - 未知名字回退为普通文本字形(
"→"这类字符仍然可用); - 应用启动时调用一次
bern::icons::load()加载字体:
fn boot() -> (App, iced::Task<AppMessage>) {
(App::load(), bern::icons::load().map(AppMessage::FontLoaded))
}框架的 [core::morph] 模块是引擎级的「图标形变」基础:用 ttf-parser
直接从内嵌字体提取字形轮廓(二次/三次贝塞尔全部采样成折线),按弧长均匀
重采样、归一化到 24×24 图标网格,再在两个字形之间逐点插值。配合带过冲
回弹的果冻缓动([morph::jelly]),图标从 1 切换到 2 时是真正的矢量
扭曲形变,而不是交叉淡入。
icon_button的iconprop 变化时自动触发形变(例如主题开关的light_mode_rounded↔dark_mode_rounded),morph_duration_ms调时长 (默认 420ms);morph_icon是独立的形变图标控件,布局里可直接使用:
Widget(id: "toggle", kind: "morph_icon", area: "actions",
props: { "icon": "dark_mode_rounded", "size": "20" })- 任意两个图标都能形变:轮廓数量不一致时(月牙 1 轮廓 → 太阳 9 轮廓), 缺失的轮廓从对方字形质心“生长/收缩”,视觉上就是果冻鼓包/回缩;
- 字形提取有缓存,同一字符只解析一次;动画运行在控件自身状态里,引擎
自动跟随主题揭示(
RevealWrapper),控件无需额外订阅。
深浅色不允许在单独的地方(主题文件、配置项)配置。每个控件在它自己的
.rs 文件里内置浅色和深色配色,构建时从 BuildContext::theme
(&iced::Theme)取色:
rect背景 =theme.palette().backgroundtext/title=theme.palette().textbutton= 主题的 primary 系列色(含 hover/pressed)icon_button=theme.palette().text
切换深浅色就是切换 iced::Theme(Light/Dark)这一个标准接口,所有控件
自动跟着变,没有任何外部配置参与。
框架里有一个主题路由([ThemeRouter]):它是运行期唯一持有当前
iced::Theme 和 primary 强调色的地方。set_accent 会重建当前深浅模式的
自定义 iced 主题,theme_for 在切换模式时保留强调色;registry.build
把主题分发给每个控件。控件只负责「拿到主题 → 用自己的调色板着色」。
rect 背景控件在颜色因交互切换时(例如点击主题开关),会以按下按钮的
坐标为原点做圆形揭示动画,与 nipaplay 的深浅色切换一致:
- 切到深色:新颜色从按钮位置向外铺开,直到盖满控件;
- 切回浅色:反向——旧颜色圆从边缘向内收缩到按钮位置,露出新颜色;
- 非交互式的颜色变化(没有按钮坐标)直接切换,不做动画;
- 时长默认 420ms、
easeOutCubic缓动,可用duration_ms属性调整:
Widget(id: "bg", kind: "rect", area: "root", z: -1, size: Fill,
props: { "duration_ms": "600" })机制:可交互控件(如 icon_button)按下时把自身中心坐标写入共享的
[PressOrigin],背景控件构建时读取并消费它。
点击主题开关时,深浅色模式不会立即切换,而是走通知式两阶段流程:
- 背景从按钮位置开始圆形揭示目标色(此时模式还是旧的);
- 引擎层自动跟随:注册表构建每个控件时自动用
[
RevealWrapper] 包裹(背景控件除外),wrapper 在事件驱动下把控件坐标 注册进 [ThemeReveal] 协调器(不轮询); - 圆形扫过控件坐标时,协调器向该控件投递一条一次性命令
(
take_command),wrapper 用目标主题重新构建该控件——按钮、文本、 标题、图标全部随扩散依次切换到目标色,控件本身无需任何订阅代码; - 整个扩散动画完成后(所有控件必然已被覆盖),背景发布
LayoutMessage::ThemeRevealDone; - 应用收到通知后才真正切换
ThemeRouter——保证动画全程可见,且切换瞬间 每个控件下方的背景颜色都已改变。
扩散方向由背景自动判定:变暗时圆向外扩大(扫到即命令),变亮时圆向内
收缩(控件离开旧色圆即命令)。非交互式颜色变化(没有按钮坐标)直接切换。
这一层是框架的底层机制:控件零参与(引擎自动包裹),应用只负责在收到
ThemeRevealDone 后切换模式,互不耦合。
所有控件都能整体放大:registry.scale() 持有一个共享缩放因子
(默认 1.0,范围 1.0..=2.0)。Registry::build 会把整棵界面包进一个
缩放包装器:
- 布局用「虚拟窗口」(窗口尺寸 ÷ scale)排布内容,再整体放大 scale 倍, 内容始终填满窗口、不会跑出屏幕;
- 绘制对整棵子树施加缩放变换,鼠标坐标按 1/scale 反向换算,点击照常命中;
- 控件代码零感知,应用设置一次、下一次构建整棵界面就按新比例渲染:
app.registry.scale().set(1.5);helloworld 的视频页有一个「缩放滑块」,value 0..1 映射到 1.0..2.0。
h_tab 控件就是 nipaplay 主界面左上角那排 Tab 的移植:加粗标签、
悬浮时 1.1 倍平滑放大(200ms ease-out),选中的标签用主题强调色,底部
有一条 3px 高的胶囊指示器,切换时在标签之间滑动(300ms)。
Widget(id: "nav", kind: "h_tab", area: "topbar",
props: { "items": "首页:tab_home,视频:tab_video,媒体库:tab_library" })items是逗号分隔的label:key列表,key就是该项的交互 id—— 和按钮一样写在应用的ids.rs里,构建布局时框架会校验每一项都已注册;- 按下某个 Tab 发布
(key, Pressed)事件,应用按 id 切换内容;高亮和 胶囊移动由控件自己维护,应用无需驱动; - 所有标签始终加粗:选中与未选中标签都用粗体字族渲染;
selectedprop 只负责当前项的强调色和底部胶囊位置(应用点击后写回,和滑块 value 同一套路); - 多页面切换:应用收到 Tab 事件后,把页面容器(
kind: "layout") 的src换成另一个布局文件即可换页——helloworld 里就是hello_card/page_video/page_library三个独立布局在运行时切换, 同一个二进制不重新编译; - 配色全部来自 iced 主题(深浅色写死在控件代码里):未选中标签 深色 60% / 浅色 54% 透明度,选中标签和胶囊用主题强调色;
- 可调属性:
font_size、hover_scale、duration_ms(悬浮)、indicator_ms(胶囊滑动)、indicator_height、indicator_radius、item_padding、selected。
iced 0.14 的字体库默认没有粗体字面,
Weight::Bold会被忽略。框架的fonts模块启动时加载系统粗体中文字体(macOS 用 Hiragino Sans GB), 应用在 boot 里调用一次bern::fonts::load_bold()即可让加粗生效; 找不到粗体字体会优雅回退为常规字重。
slider(可拖动)和 progress(纯展示)是视频播放进度条的移植,
照抄 nipaplay 的 VideoProgressBar:
- 4px 胶囊轨道(文字色低透明度),已播放部分用文字色胶囊段——深色下 是白色(与 nipaplay 完全一致),浅色下自动变深,任何主题都可见;
- 28 × 16 胶囊滑块(圆角 = 高的一半,跟随主题文字色)+ 两道柔和阴影;
- 悬浮时滑块放大 8%(160ms easeOutCubic,逐帧动画);
- 按下时滑块被弹簧压扁(变窄变长,刚度 620 / 阻尼 22),松开后换 低阻尼弹簧(刚度 360 / 阻尼 7.2)过冲回弹并振荡落定——这就是 nipaplay 的果冻动画,参数原样照抄。
// 可拖动滑块:发布 (id, Changed(value)),value ∈ 0..=1
Widget(id: "seek", kind: "slider", area: "root", props: { "value": "0.35" })
// 纯展示进度条:不响应交互,按 value 显示
Widget(id: "seek_progress", kind: "progress", area: "root", props: { "value": "0.35" })value属性是唯一数据源:拖动/点击后应用把新值写回布局,下一次构建 即按新值渲染(helloworld 里set_seek更新滑块);- 滑块是交互控件,id 照旧写在
ids.rs;内嵌在页面布局里时事件 id 会带 嵌入前缀(如page.seek),应用用{容器id}.{控件id}匹配; - 可调属性:
value、size(宽度策略)。
dropdown 控件与 nipaplay 的模糊下拉菜单外观一致:
Widget(id: "lib_sort", kind: "dropdown", area: "root",
props: { "items": "名称:sort_name,大小:sort_size,时间:sort_time",
"selected": "sort_name" })- 40px 圆角触发器:边框在打开时变强调色,右侧箭头旋转 180°;
- 点击弹出下方菜单:200ms 淡入 + 0.95→1 缩放,圆角 6 面板 + 柔和阴影, 菜单项带分隔线、选中底色、悬浮高亮;
- 点击菜单项发布
(item_key, Pressed)并关闭;点击菜单外(遮罩)关闭; items的键是交互 id(进 ids.rs),selected由应用点击后写回—— 和 Tab / 滑块同一套路;- 菜单浮层通过 iced overlay 实现,引擎的缩放包装器(ScaleWrapper)与 主题揭示包装器都会自动转发浮层,缩放状态下菜单位置/尺寸同样按比例。
round_button 是 nipaplay 大屏操作按钮的移植:固定的圆角 8 表面,
悬浮时表面不变、只有内容放大并出现强调色描边:
Widget(id: "lib_sync", kind: "round_button", area: "root",
props: { "icon": "sync_rounded", "label": "同步" })- 浅色 82% / 深色 10% 的白色填充,圆角 8,默认带 1px 文字色描边 (一眼可辨「有容器」);
- 悬浮时内容放大 1.035(140ms easeOutCubic)+ 描边换 2px 强调色;
- 图标(Material,带果冻形变)+ 粗体标签,内容色浅色 black87 / 深色白;
- 可调属性:
label、icon、icon_size、font_size、scale、duration_ms; 按下发布(id, Pressed)并记录主题揭示原点。 - 悬浮时图标和文字变成主题强调色(移开恢复),配合内容放大有明确的 交互反馈。
action_button 和 icon_button 同一个悬浮缩放核心,但没有容器——
只有图标和文字,悬浮时整体放大:
Widget(id: "lib_sort_btn", kind: "action_button", area: "root",
props: { "icon": "sort_by_alpha_rounded", "label": "排序" })- 21px 图标 + 8px 间距 + 15px 粗体标签(nipaplay 动作按钮布局);
- 无背景、无边框,悬浮放大默认 1.08(140ms easeOutCubic),
scale/duration_ms可调; - 图标 Material(带果冻形变),颜色跟随文字色;按下发布
(id, Pressed)。 - 悬浮时图标和文字变成主题强调色(和
icon_button一致)。
text_input 直接照抄 nipaplay 媒体库的搜索框外观:
Widget(id: "lib_search", kind: "text_input", area: "root",
props: { "placeholder": "搜索媒体库", "value": "" })- 提示文字(
placeholder)由布局文件传入,默认显示; - 左侧 Material
search_rounded图标,正文用粗体; - 浅色 82% / 深色 9% 的白色填充,圆角 8 边框(文字色 10%),聚焦时 强调色 2px 边框;
- 输入发布
(id, TextChanged(text)),应用把新词写回布局value—— 和滑块/下拉同一套路(布局是唯一数据源)。
split_pane 把页面像桌面多窗口工作区一样切成多个区块,区块之间使用
主题自适应的 1px 细线分隔。horizontal 表示从左到右排列,vertical
表示从上到下排列:
Widget(id: "workspace", kind: "split_pane", area: "root", size: Fill,
props: {
"direction": "horizontal",
"panes": "navigation,editor,inspector",
"weights": "1,4,2",
"divider_width": "1",
"divider_inset": "16",
})panes是逗号分隔的布局名,由LayoutStore解析并分别嵌入每个区块;weights控制区块比例,缺省时等分;嵌入控件的事件 id 会自动带上workspace.pane0、workspace.pane1等前缀;- 不传
panes时,用sections: "3"创建纯分区背景,内容可由页面自己的 区域覆盖上去; - 某个 pane 对应的布局里可以再次放置
split_pane,从而组合左右和上下分区; divider_inset控制分隔线两端留白(默认 12px):竖线留出上下边距, 横线留出左右边距;- 背景复用
rect的深浅色圆形揭示动画。当前边界为静态分隔线,不支持拖拽。
virtual_window 参考 Nipaplay 的 NipaplayWindow:在当前页面里显示一个
带圆角、阴影和窗口控制区的模态虚拟窗口。窗口使用全视口 overlay,即使
声明在页面内容区里也能覆盖顶部 Tab;内容由另一个 RON 布局提供:
Widget(id: "settings_window", kind: "virtual_window", area: "root", size: Fill,
props: {
"src": "settings_content",
"title": "设置",
"width": "850",
"height_factor": "0.8",
"scrim_alpha": "0.06",
})- 默认居中,普通模式保留 20px 外边距;双击标题栏切换到保留 10px 边距的 铺满模式,再次双击还原;
- 标题栏支持鼠标拖动,窗口始终保留一段可见标题区,避免完全拖出页面;
- 默认 15px 圆角、柔和阴影和 250ms
easeOutBack入场缩放; - macOS 默认显示左上红色关闭点,其他平台显示右上 Fluent 关闭按钮;可用
close_style: "mac"/"fluent"强制指定; - 点击关闭按钮或窗口外遮罩会发布
(window_id, Other("close"));窗口 id 因此需要注册在应用ids.rs; src指向LayoutStore中的内容布局,内部事件 id 自动带窗口前缀;- 常用属性:
width、height、height_factor、margin、filled_margin、radius、title_height、title_size、content_padding、scrim_alpha、dismiss_on_scrim、show_close、close_style、animation_ms、mode。
Bern 提供 NipaPlay 桌面设置页需要的布局积木和独立设置项控件:
side_tab:items直接传入icon:文本:编号,多项用逗号分隔;点击后由 控件自身发布选中的编号,应用只需切换右侧页面;rounded_container:用src承载一个子布局,默认 8px 圆角、0.5px 半透明边框,可调整padding、radius、background_opacity和border_opacity;switch:Fluent 风格胶囊开关,使用当前主题的 primary 强调色并发布Toggled(bool);scroll_layout:只负责为src指向的子布局提供滚动视口,不保存页面内容;divider:由布局显式放置的主题自适应细分割线;setting_label:只绘制设置项的图标、标题和副标题,由布局把它与已有的switch、dropdown、slider放进同一个Row;setting_color、setting_hotkey、setting_action、setting_info:用于 Bern 没有通用控件可复用的颜色、快捷键、操作和信息设置项。
Widget(id: "settings_nav", kind: "side_tab", area: "left", size: Fill,
props: { "items": "palette_rounded:外观:0,language_rounded:语言:1",
"selected": "0" })
Widget(id: "auto_play", kind: "switch", area: "row",
props: { "value": "true" })
Widget(id: "card", kind: "rounded_container", area: "content",
props: { "src": "playback_options", "radius": "8" })
// playback_options.ron:同一 Row 里直接组装说明文字和已有控件
Area(id: "row_auto_next", kind: Row, parent: "root",
padding: 14, spacing: 12, align_y: "center", width: Fill)
Widget(id: "auto_next_label", kind: "setting_label", area: "row_auto_next",
size: Weight(1), props: { "icon": "skip_next_rounded",
"title": "自动连播", "subtitle": "播放结束后自动播放下一集" })
Widget(id: "auto_next", kind: "switch", area: "row_auto_next",
props: { "value": "true" })
Widget(id: "play_core", kind: "dropdown", area: "row_play_core",
size: Fixed(200), props: {
"items": "MDK:play_core_mdk,Libmpv:play_core_libmpv,Erika:play_core_erika",
"selected": "play_core_mdk",
})推荐结构是“滚动页布局 → 分区标题 + rounded_container → 语义化分区子布局
→ setting_label + 已有交互控件 + divider”。交互控件和下拉选项的语义化
id 必须集中登记到应用的 ids.rs;应用行为层只负责把新值写回布局节点。
页面背景 rect 还支持 light_color / dark_color 的 #RRGGBB 或
#AARRGGBB 精确色值,同时保留主题圆形揭示动画。
Bern/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── core/
│ │ ├── widget.rs # WidgetDef、LayoutMessage、事件桥
│ │ ├── registry.rs # 注册表 + 布局运行时(areas -> iced 树)
│ │ ├── layout.rs # RON 布局解析(areas + widgets)
│ │ ├── morph.rs # 矢量形变底层(字形提取/重采样/果冻插值)
│ │ └── store.rs # 布局目录加载(common + 设备)
│ ├── icons/ # Material Icons(内嵌字体 + 名称映射)
│ └── widgets/ # 每个控件一个文件
│ ├── h_tab.rs # 横向 Tab(悬浮放大 + 底部胶囊指示器)
│ ├── rect.rs # 背景/色块
│ ├── title.rs # 标题
│ ├── icon.rs # 图标
│ ├── morph_icon.rs # 形变图标(矢量果冻切换)
│ ├── icon_button.rs # 图标按钮(悬浮放大 + 图标形变)
│ ├── split_pane.rs # 左右/上下多窗口分区 + 细线边界
│ ├── virtual_window.rs # 可拖动、可铺满的模态虚拟窗口
│ ├── side_tab.rs # 左侧设置 Tab(图标 + 文本 + 编号)
│ ├── rounded_container.rs # 可嵌入布局的圆角矩形容器
│ ├── scroll_layout.rs # 可滚动的子布局容器
│ ├── divider.rs # 布局显式组装的细分割线
│ ├── switch.rs # Fluent 风格设置开关
│ ├── setting_label.rs # 设置项图标、标题和副标题
│ ├── setting_color.rs # 独立颜色设置项
│ ├── setting_hotkey.rs # 独立快捷键设置项
│ ├── setting_action.rs # 独立操作设置项
│ ├── setting_info.rs # 独立信息设置项
│ ├── slider.rs # 滑块 + 进度条(胶囊轨道 + 果冻滑块)
│ ├── text.rs
│ ├── text_input.rs
│ └── button.rs
└── layouts/
├── common/
│ └── login_form.ron
└── desktop/
└── login_page.ron
assets/fonts/ # 内嵌的 MaterialIcons-Regular.otf + 许可证
let store = bern::LayoutStore::load("layouts/desktop", "layouts/common")?;
let layout = store.resolve("login_page").expect("layout exists");
let registry = bern::builtin_registry();
let element = registry.build(layout, &iced::Theme::Dark, &store)?;布局里的控件事件以 (widget_id, event) 的通用形式产生
(bern::LayoutMessage::Event),应用层再映射到自己的类型化消息。