Files
endorphin-tessera-gui/src/shell/system_ui/widgets.rs
T

362 lines
16 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.
//! 用统一主题绘制宿主管理控件,并同步注册交互目标。
//! 每个控件的图形、角色、启用状态和 action 一起登记,绘制几何与命中几何保持一致。
//!
//! sidebar 和 developer_tools 的 Painter 为每帧创建 Ui,消费其显示列表与 targets,再由外层 Interaction 处理输入。
//! 这里的 Button 是宿主绘制描述,不是 Endor 的托管 Button;register 只登记 Action,不调用业务回调或立即执行动作。
//! 主题与 Interaction 是本轮输入快照;控件重绘不会自行改变焦点、按下捕获或滚动偏移,跨帧状态由外层事件处理维护。
use super::{
Axis, Interaction, Role, Target, VisualState, WidgetId,
theme::{self, Theme},
};
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
use std::hash::Hash;
/// 一次按钮绘制的借用描述,包括显示、角色和启用/选中状态。
pub struct Button<'a> {
// 只在本次构建借用按钮文本,最终显示列表命令拥有自己的字符串,不依赖这个描述存活。
pub label: &'a str,
pub selected: bool,
pub enabled: bool,
// 默认 Button,可显式切 Toggle/Tab;角色影响焦点/光标和 Tab 底线,不替代 Action 身份。
pub role: Role,
}
impl<'a> Button<'a> {
/// 创建默认启用、未选中的普通按钮描述,借用 label 而不分配文本。
// 侧栏与 Painter 用 builder 描述按钮,每帧重新构造,不在 Button 内保存 hover 或按下状态。
pub fn new(label: &'a str) -> Self {
Self {
label,
selected: false,
enabled: true,
role: Role::Button,
}
}
/// 按 builder 风格设置选中状态,返回描述供链式调用。
pub fn selected(mut self, value: bool) -> Self {
// 侧栏/工具构建链传入本轮选择结果,只改这个短期描述;Ui::button 稍后合成视觉状态,不在这里切换页面。
self.selected = value;
self
}
/// 设置启用状态,影响外观和目标的可激活资格。
pub fn enabled(mut self, value: bool) -> Self {
// 调用方按业务资格选择是否启用,后面登记到 Target.enabled;不删除动作身份,便于跨帧 reconcile 清理旧捕获。
self.enabled = value;
self
}
/// 设置控件语义角色,影响焦点资格、光标及 Tab 底线等外观。
pub fn role(mut self, value: Role) -> Self {
// Painter 可为同一按钮原语选择 Tab/Toggle 等角色,后续登记与绘制共用这一描述,不另建一份命中控件。
self.role = value;
self
}
}
/// 本帧宿主控件绘制上下文,共同拥有显示列表与交互目标登记入口。
pub struct Ui<A> {
// 本帧逻辑空间命令,scene 或 developer_tools 外层统一按设备比例缩放。
pub list: DisplayList,
// 按追加顺序保存交互目标,Interaction::hit 逆序查找,追加时序也是覆盖命中优先级。
pub targets: Vec<Target<A>>,
pub theme: Theme,
// 绘制使用跨帧交互状态的本轮副本,只查询 visual/captured;事件处理器在外部更新权威状态。
interaction: Interaction,
}
impl<A: Hash> Ui<A> {
/// 建立空显示列表和目标集合,保存本轮主题与交互状态快照。
// sidebar::draw 与 developer_tools::build 创建本帧 Ui,传入上次事件更新后的 Interaction 克隆。
pub fn new(theme: Theme, interaction: Interaction) -> Self {
Self {
list: DisplayList::default(),
targets: Vec::new(),
theme,
interaction,
}
}
/// 追加无圆角填充矩形命令;不注册交互目标。
// 背景、选中高亮、分隔线和手柄的通用原语,不改变任何跨帧状态。
pub fn fill(&mut self, r: Rect, color: Color) {
self.list.push(DrawCommand::FillRoundedRect {
rect: r,
radius: 0.0,
color,
});
}
/// 追加向内收缩的圆角描边,使传入矩形作为控件外边界。
// surface、text_field 和 swatch 绘制焦点/边框时调用,下面对底层描边矩形留出内缩空间。
fn outline(&mut self, r: Rect, color: Color, width: f32) {
self.list.push(DrawCommand::StrokeRoundedRect {
// 传给底层的描边矩形四边各缩半线宽,额外保留内侧空间;小矩形宽高钳到非负。
rect: Rect::new(
r.x + width / 2.0,
r.y + width / 2.0,
(r.width - width).max(0.0),
(r.height - width).max(0.0),
),
radius: 3.0,
width,
color,
});
}
/// 追加等宽或界面字体文字命令;调用方负责裁剪与可用宽度。
// Painter 与侧栏调用,界面标签固定 12/18 逻辑字号/行高,code 则使用统一等宽样式。
pub fn label(&mut self, x: f32, y: f32, value: &str, color: Color, code: bool) {
self.list.push(DrawCommand::Text {
origin: Point::new(x, y),
value: value.into(),
style: if code {
TextStyle::code()
} else {
TextStyle {
font_size: 12.0,
line_height: 18.0,
..TextStyle::ui(1)
}
},
color,
});
}
/// 把控件身份、几何、角色、启用状态和 action 一起加入本帧交互目标。
// 所有可交互控件共用登记入口;同一 id 的视觉响应与新矩形一起产生,但此处不执行命中测试或激活动作。
fn register(
&mut self,
id: WidgetId,
rect: Rect,
role: Role,
action: Option<A>,
selected: bool,
enabled: bool,
) -> VisualState {
// 先取得上帧输入对应的视觉状态,再登记本帧目标;绘制本身不触发 action。
let response = self.interaction.visual(id, selected, enabled);
self.targets.push(Target {
id,
rect,
role,
enabled,
action,
});
response
}
/// 按视觉状态绘制控件背景和焦点轮廓;本方法不注册目标。
// 按钮、文本框与行共用状态背景,base=None 时未交互/未选中可以完全不画底色。
fn surface(&mut self, rect: Rect, s: VisualState, base: Option<Color>) {
// 背景优先级为禁用、按下、悬停、选中、默认;焦点轮廓单独绘制,可与选中同时存在。
let color = if !s.enabled {
base
} else if s.pressed {
Some(self.theme.pressed())
} else if s.hovered {
Some(self.theme.hover())
} else if s.selected {
Some(self.theme.soft())
} else {
base
};
// 仅有实际状态色或调用方 base 才追加填充,不凭空覆盖底层背景。
if let Some(color) = color {
self.fill(rect, color);
}
// 焦点圈独立叠在背景之上,键盘聚焦的已选按钮可同时有选中底色和强调轮廓。
if s.focused {
self.outline(rect, self.theme.accent, 1.5);
}
}
/// 登记按钮目标,绘制状态背景、裁剪后的标签及可选 Tab 底线,返回组合视觉状态。
// 侧栏与 Painter 的按钮/切换入口,返回 VisualState 可供调用方进一步画图标或其他状态装饰。
pub fn button(&mut self, rect: Rect, action: A, button: Button<'_>) -> VisualState {
// 默认以 action 哈希为稳定身份;同一帧多个独立控件若 action 完全相同会共享身份,调用方需合理区分。
let id = WidgetId::new(&action);
// Action 在此移入目标集合,视觉状态先取回本地用于生成背景、文字颜色及可选底线。
let s = self.register(
id,
rect,
button.role,
Some(action),
button.selected,
button.enabled,
);
self.surface(rect, s, None);
self.list.push(DrawCommand::PushClip(rect));
// 扣掉左右各 8 的内边距后按真实字体截断,并用 PushClip 保证文字和标签底线不越出按钮。
let label = text::fit(
button.label,
(rect.width - 16.0).max(0.0),
TextStyle {
font_size: 12.0,
line_height: 18.0,
..TextStyle::ui(1)
},
);
self.label(
rect.x + 8.0,
rect.y + (rect.height - 18.0) / 2.0,
&label,
if s.enabled {
theme::TEXT
} else {
Color::rgb(151, 160, 172)
},
false,
);
// 仅 Tab 角色在选中时显示底部强调线,普通按钮选中只走 surface 背景。
if s.selected && button.role == Role::Tab {
self.fill(
Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0),
self.theme.accent,
);
}
// 标签和 Tab 底线绘制结束后恢复外层裁剪,返回的状态仅为本轮快照。
self.list.push(DrawCommand::PopClip);
s
}
/// 登记文本输入角色并绘制背景和焦点/悬停描边,不负责实际文本与光标。
// Network Filter 和 Settings 自定义颜色框使用,仅负责框的目标/外观,实际文字与编辑缓冲由工具状态维护。
pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) {
// Action 的稳定哈希同时关联本帧文本框目标和视觉状态;focused 还允许外层真实编辑焦点提供强调外观。
let s = self.register(
WidgetId::new(&action),
rect,
Role::TextField,
Some(action),
false,
true,
);
// 文本框固定白底,外部 focused 或键盘焦点都使用强调色描边,悬停次之。
self.surface(rect, s, Some(theme::BG));
self.outline(
rect,
if focused || s.focused {
self.theme.accent
} else if s.hovered {
theme::MUTED
} else {
theme::BORDER
},
if focused || s.focused { 1.5 } else { 1.0 },
);
}
/// A terminal prompt shares its surface with output; its caret is the focus cue.
/// 只登记无边框终端输入的交互区域,外观由调用方绘制。
// 终端输入与输出共享背景,只注册文本输入命中区,不绘制框线或文字,光标由 Console 负责。
// Console 使用无框输入命中区域,焦点由按下的 Focus 动作建立,光标与内容由 console::build 自行绘制。
pub fn inline_input(&mut self, rect: Rect, action: A) {
self.register(
WidgetId::new(&action),
rect,
Role::TextField,
Some(action),
false,
true,
);
}
/// 分别接受行背景区域和可见命中区域,登记行选择并绘制其状态背景。
// 行背景可绘制完整行,但 hit 可裁到可见部分;不要把裁剪区外的行注册成可点击区域。
// Painter 为请求、AST 等有动作的显示行调用,paint 可是完整行,hit 只允许可见片段。
pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) {
// 目标登记使用裁剪后的 hit,背景使用 paint;源行可以部分可见,不能用完整 paint 扩大鼠标可点击区域。
let s = self.register(
WidgetId::new(&action),
hit,
Role::Row,
Some(action),
selected,
true,
);
self.surface(paint, s, None);
}
/// 登记色块目标,绘制按压变暗、外圈及选中勾号。
// Settings 调色板调用,颜色来自候选色,selected 来自当前强调色比较;点击只登记主题 Action。
pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) {
// 色块 Action 由 Settings 消费,控件只登记/画色;selected 是传入比较结果,不在绘制时改当前主题。
let s = self.register(
WidgetId::new(&action),
rect,
Role::Swatch,
Some(action),
selected,
true,
);
// 色块保留传入颜色,按下时向正文深色混合 20% 变暗;外圈和选中勾号在下方单独叠加。
self.fill(
rect,
if s.pressed {
theme::mix(color, theme::TEXT, 0.2)
} else {
color
},
);
// 色块在交互或选中时增加外圈;按下变暗且加粗,选中再绘制勾号。
if s.hovered || s.focused || selected {
self.outline(
Rect::new(
rect.x - 3.0,
rect.y - 3.0,
rect.width + 6.0,
rect.height + 6.0,
),
if s.focused {
self.theme.accent
} else {
theme::TEXT
},
if s.pressed { 3.0 } else { 1.5 },
);
}
if selected {
self.label(rect.x + 10.0, rect.y + 6.0, "✓", theme::BG, false);
}
}
/// 登记扩大的拖动命中区,绘制捕获/悬停强调线。
// DeveloperTools 父入口最后统一调用,显式 id 来自分隔类别,axis 表示拖动方向而非线条延伸方向。
pub fn splitter(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
// 分隔线命中区四边扩展 3,提高细线可拖动性;真实绘制线宽与命中区域独立。
let hit = Rect::new(
rect.x - 3.0,
rect.y - 3.0,
rect.width + 6.0,
rect.height + 6.0,
);
// 无点击 action 但保留拖动角色与命中,外层 press 再用布局记录决定调整哪块区域。
let mut s = self.register(id, hit, Role::Splitter(axis), None, false, true);
// 分隔条捕获后即使拖出命中区仍显示按压态,不采用普通按钮“拖出取消外观”的规则。
s.pressed |= self.interaction.captured(id);
let color = if s.pressed || s.hovered {
self.theme.accent
} else {
theme::BORDER
};
// 悬停/捕获时沿操作轴加粗到 3 单位:Horizontal 画竖线,Vertical 画横线。
let line = if s.hovered || s.pressed {
match axis {
Axis::Horizontal => Rect::new(rect.x - 1.0, rect.y, 3.0, rect.height),
Axis::Vertical => Rect::new(rect.x, rect.y - 1.0, rect.width, 3.0),
}
} else {
rect
};
self.fill(line, color);
}
/// 登记滚动手柄目标并绘制其状态颜色,偏移和拖动倍率由外部维护。
// Painter 在内容裁剪弹出后调用,只有手柄几何和轴进入 Ui,内容偏移及倍率仍由外层状态管理。
pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
// 无点击 Action,外层用 Role::Scrollbar 和显式 WidgetId 选择拖动状态;Ui 不拥有内容长度或偏移计算。
let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true);
// 拖动捕获时即使指针移到手柄外仍保持强调色,不能套用普通按钮拖出后取消按下外观的规则。
s.pressed |= self.interaction.captured(id);
self.fill(
rect,
if s.pressed {
self.theme.accent
} else if s.hovered {
theme::mix(theme::MUTED, self.theme.accent, 0.55)
} else {
Color::rgb(161, 173, 190)
},
);
}
}