范围:15 个现成组件的构造方式、关键 API 与已核实的行为细节(源码基线 26.7.0),包括渲染契约中
visible与font的职责划分、共享行为与不变量违反后的后果。组件速查见组件快速参考。
渲染契约
每个组件的绘制都经由 extractRenderState(GuiGraphicsExtractor, int, int, float) 这一个入口,各组件在方法开头执行守卫后再绘制自身。visible = false 的组件既不会进入绘制,也会在命中检查中被跳过(各组件守卫的可见性分支作用于绘制侧,InteractionManager.hovering 的 isVisible 检查作用于命中侧)。
未赋 font 的文本类组件静默不画。 绘制文本的组件在守卫里还会检查 font == null。Screen 从未设置字体时(init() 里未链 .font(font)),组件直接跳过整个绘制,不崩溃、不抛异常,只是文字连同底色一起消失。症状:标签位置一片空白、font.width(...) 量出的文本宽为 0;修复见 使用指南 的字体提示(在 init() 创建组件时用 .font(font) 传入 Screen 的字体)。这条不是所有组件的统一首行。ProgressBar、Slider、Toggle、SelectBox 等只查 if (!visible) return;,Button、TextBox 才查 if (!visible || font == null) return;。守卫随组件而异,核实方法是查看对应源码 extractRenderState 的首行。
事件遵循「命中且消费才排他」:组件只在自己的几何范围内处理 mousePressed 等事件,返回 true 表示本事件由它消费,父容器与其他组件不再收到。组件的父类归属(谁继承 Element、谁继承 TooltippingElement、谁继承 FocusableElement)见核心概念的「元素与组件层次」,与速查表的「继承自」列一致。
颜色模式:带配色的组件都通过构造参数注入一个颜色 record(如 ButtonColors),并提供 blue() 默认值;换肤时替换整个 record 即可。事件模式:点击、悬停、拖动都由框架按命中分发,组件只需覆盖所需的方法。
Button
按钮是最常用的可点击组件,点击时执行一个动作:
Button btn = new Button("Click me", 120, 30)
.action(() -> System.out.println("Clicked!"));
关键 API:.action(Runnable) 注册点击回调;.text() 读取当前文案;.setEnabled(boolean) 切换可用状态(禁用时忽略点击并清除悬停/按下态)。构造重载支持 Component/String 文案与自定义 ButtonColors(record 字段:bg, border, text, hoverBg, hoverBorder, pressBg, pressBorder, disabledText,默认 ButtonColors.blue())。
细节:mousePressed 只在 enabled、visible 且为左键时接受按下;动作实际发生在 pressReleased(e, hit),且按下后仍悬停才触发,把指针拖出按钮再松开不会误触。
Toggle
开关表示布尔状态,尺寸固定 30×14:
Toggle toggle = new Toggle(0, 0)
.on(true)
.onChange(() -> System.out.println("Toggled!"));
关键 API:.on(boolean) 设置状态;.isOn() 读取状态;.onChange(Runnable) 在状态翻转时回调。构造重载 Toggle(int x, int y[, ToggleColors]),颜色默认 ToggleColors.blue()。
细节:点击后组件翻转自身状态再调用 onChange,回调中无需手动改回。
SelectBox
复选框表示选中与否,尺寸固定 9×9:
SelectBox box = new SelectBox()
.onClick(() -> System.out.println("Now: " + box.isSelected()));
关键 API:.onClick(Runnable) 注册回调;.isSelected() 读取选中态。构造重载 SelectBox([SelectBoxColors]),颜色默认 SelectBoxColors.blue()。
细节:点击时组件先翻转内部选中位,再执行 onClick,因此回调里读到的 isSelected() 已经是新值。
Slider
值滑块将连续值映射到轨道,高度固定 16(轨道 4、滑块 8×16):
Slider slider = new Slider(240)
.steps(10)
.onChanged(v -> System.out.println("Volume: " + v));
关键 API:.value(float) / .value() 读写当前值(写入时自动钳制);.steps(int) 设置离散步数;.nonlinear(boolean) 切换非线性映射(适配音量这类感知曲线);.onChanged(Consumer<Float>) 在值变化时回调。构造重载 Slider(int width[, SliderColors]),颜色默认 SliderColors.blue()。
细节:滑块按下期间会带 BLOCK_HOVER | BLOCK_RIGHT 两枚额外 pressFlags:拖动时暂停其他元素的悬停,并拦截右键。flag 语义与拦截时机见元素系统。
Stepper
数字步进器带 −/+ 按钮和可编辑读数,高度固定 22:
Stepper stepper = new Stepper(120)
.mode(Stepper.Mode.PERCENT)
.range(0, 100)
.suffix("%");
关键 API:.mode(Mode) 在 FREE(自由步进)、PERCENT(百分比钳制)、FIXED(固定标签档位)之间切换;.value(double) / .range(double, double) / .step(double) 设定值与范围;.fixedLabels(String...)、.suffix(String|Component) 定制读数外观;.onChanged(Consumer<Double>) 回调;.displayFormatter(Function<Double, String>) 自定义格式化;.allowInput(boolean) 开关直接键入。构造重载 Stepper(int w[, SpinColors]),颜色默认 SpinColors.blue()。
细节:FIXED 模式由 fixedLabels 提供档位文本,此时 .allowInput(true) 不生效,因为档位只能逐档切换,不能键入任意值。键入期间(读数显示编辑缓冲)点击左、右增减钮,会先把已输入的数字提交成当前值,再在其上步进,不会出现“读数不变却从旧值开始计数”的竞态;编辑态的三种退出方式见特殊行为。
TextBox
单行文本输入框,支持光标、选择、撤销与输入法:
TextBox input = new TextBox(200, 24)
.placeholder("Enter name...")
.maxLength(32)
.onChanged(v -> System.out.println(v))
.onEnter(() -> submit());
关键 API:.text(String) / .text() 读写正文;.placeholder(String|Component) 占位提示(失焦且为空时显示);.maxLength(int) 限制长度;.onChanged(Consumer<String>) 每次正文变化回调;.onEnter(Runnable) 回车回调;.focus() 主动聚焦。构造重载 TextBox(int w, int h[, TextBoxColors])。
细节:撤销栈上限 MAX_UNDO = 50,超出后丢弃最旧记录;输入法预编辑文本经 preeditUpdated(PreeditUpdatedEvent) 组装成浮层,确认上屏后才写入正文;右键菜单提供剪切/复制/粘贴,三项都会正确进出撤销栈。
AutoCompleteBox
带候选下拉的文本输入框,在 TextBox 能力之上增加过滤与键盘选择:
AutoCompleteBox ac = new AutoCompleteBox(200, 24)
.dataset(List.of("apple", "apricot", "banana"))
.onSelected(v -> System.out.println("Picked: " + v))
.onChanged(v -> System.out.println(v));
关键 API:.dataset(List<String>) 提供候选池;.onChanged(Consumer<String>) 文本变化;.onSelected(Consumer<String>) 从下拉选中后回调;.onEnter(Runnable)、.maxLength(int) 同 TextBox;.isOpen()、.filteredItems() 查询下拉状态。
细节与特殊情况见特殊行为,包括下拉打开条件、渲染上限、撤销上限与面板配色对齐。
SelectDropdown
下拉选择器收起时是一个 22 高的按钮,展开后把选项列表推成浮层面板:
SelectDropdown dd = new SelectDropdown(160)
.options("Option 1", "Option 2", "Option 3")
.selected(0)
.maxVisible(6)
.onChanged(i -> System.out.println("Index: " + i));
关键 API:.options(List<String>|Component...) 设置选项;.selected(int) 预设选中项;.selectedIndex() / .selectedText() 读取结果;.onChanged(Consumer<Integer>) 选中回调;.maxVisible(int) 按行数限制面板高度(每行 20px,默认 160px 即 8 行)、.maxDropdownHeight(int) 按像素限制;.isOpen() 查询展开态;.toggle() / .close() 开合。
细节:SelectDropdown 本体继承 TooltippingElement;内部的 DropPanel 直接继承 Element,面板在选择、再次点击按钮或点击面板外区域时关闭,并自动将选中项滚动到可见区。构造重载 SelectDropdown(int w[, DropdownColors]),颜色默认 DropdownColors.blue()。配色与输入框逐值对齐:本体常态底色 0xFF222222、边框 0xFF555555,悬停边框提亮到 0xFF777777,展开使用聚焦蓝 0xFF3B82F6;面板底色/边框取同一组,行高亮取 0x403B82F6(与输入框选区同值同不透明度),全不透明、没有半透明提亮。
ListView
通用可滚动列表,行的绘制方式由子类决定:
ListView<String> list = new ListView<>(200, 300)
.items(List.of("Row 1", "Row 2", "Row 3"))
.onSelected(i -> System.out.println("Selected row: " + i));
关键 API:.items(List<T>) / .items() 读写数据(写入整体替换列表);.itemHeight(int) 行高;.onSelected(Consumer<Integer>) 行选中回调;.selectedIndex() 读取当前行;悬停/选中高亮、滚动条与滚轮滚动由组件提供。要定制行的外观,继承后覆写 protected void drawRow(T item, int idx, boolean sel, boolean hov, ...)。构造重载 ListView(int w, int h[, ListViewColors]),颜色默认 ListViewColors.blue()。
ItemList
可编辑的 ListView,在可滚动列表之上增加了行内添加、去重与复制:
ItemList<String> list = new ItemList<>(200, 300)
.items(new ArrayList<>(List.of("Item 1")))
.dedupe(true)
.onAdd(name -> System.out.println("Add: " + name))
.onDuplicate(name -> System.out.println("Duplicate: " + name))
.onChange(lv -> System.out.println("Size: " + lv.items().size()));
关键 API:.dedupe(boolean) 开启同名去重;.onAdd(Consumer<String>)、.onDuplicate(Consumer<String>) 回调新增与复制;.onChange(Consumer<ListView<T>>) 在任何列表变化后触发;.renderer(BiConsumer<T, StackPanel>) 由回调决定每行内容的排布(行容器是一个 StackPanel)。其余滚动、选中能力继承自 ListView。构造 ItemList(int w, int h)。
细节:ItemList 继承 ListView,类注释写明它是「可编辑的 ListView」,与「ItemView 的列表」无关(后者应使用 ListView<ItemView> 组合)。
ItemView
物品格,渲染一个 Minecraft 物品并带悬停名称提示:
ItemView slot = new ItemView(Items.DIAMOND_SWORD)
.count(1)
.decorations(true);
ItemView stackSlot = new ItemView(playerStack, ItemView.Size.SMALL)
.item(anotherStack); // .item(...) setter 可在构造后换内容
关键 API:构造有四种形式:ItemView(Item item)、ItemView(ItemStack stack)(均默认 Size.LARGE),或带尺寸的 ItemView(Item item, Size size)、ItemView(ItemStack stack, Size size);尺寸枚举 Size.SMALL = 18、Size.LARGE = 26;换内容的 setter .item(Item) 与 .item(ItemStack)(与构造等价,均标脏);.count(int) 右下角数量;.contextMenu(MenuMode) 设置右键菜单模式,取值 SIMPLE(仅物品名称)、ADVANCED(完整 tooltip 行)、CUSTOM(名称 + customMenuLine(...) 追加的自定义行);.decorations(boolean)、.bgColor(int)、.hoverColor(int) 控制外观。
细节与特殊情况:创建 ItemView 的屏幕必须运行在游戏内,因为组件从 ItemStack 取渲染与提示数据,非游戏内环境无法获取物品实例,会直接报错(ItemView 的 javadoc 警告原文即此意)。菜单模式默认 SIMPLE;ADVANCED 模式经 getTooltipLines(...) 取完整信息。
Tooltip
提示气泡是单例,任何 TooltippingElement 子类均可挂载提示,通常不需要手动 new Tooltip:
button.tooltip("Click to save"); // 任意 TooltippingElement 上直接挂
Tooltip.show(x, y, "Manual tip"); // 手动定位显示
Tooltip.cursor(mx, my, viewportW, viewportH); // 每帧喂光标,驱动悬停提示
关键 API:实例侧 .tooltip(String|Component|Supplier<Component>) 挂载提示、.dismissTooltip() 关闭;静态侧 Tooltip.show(x, y, ...)、Tooltip.showAt(cursorX, cursorY, ...)、Tooltip.hide()、Tooltip.cursor(int x, int y, int vw, int vh)(屏幕每帧调用一次)、Tooltip.track(TooltippingElement<?>, Font);Tooltip 自身继承 Element,并提供 .expand(ExpandDir) 控制展开方向。
细节:Tooltip.cursor 只在目标元素 isVisible() 且通过 Element.isHit 时才选中它,因此隐藏元素不会弹出提示;组件提示统一通过 .tooltip(...) 方法挂载,不构造 Tooltip 实例。
提示的缩放、重排、换行与换边行为
提示条自身四段布局逻辑(内距 PAD_X = 8/PAD_Y = 4,与光标间距 GAP = 10,行距 2):
- 缩放与透明度:绘制使用逼近值而非定值。宽、高、透明度三个游标每 tick 以系数 0.75 指数逼近目标;展开沿
.expand(ExpandDir)方向执行,上下展开先逼近高度,其余方向先逼近宽度,到达目标前用 scissor 裁剪已绘制部分。横向展开带锚定边:右展开时右缘固定在x + width,宽度从 0 向左增长。收起(hover 丢失或点击兜底)将三个游标一起逼近 0,动画结束后移除节点。 - 换行:按 Unicode 码点逐字断行(无空格语种照常折行,每行至少保留一个字符),显式
\n直接分行;可用宽度按当前边取「视口宽 − 光标横坐标 −GAP− 左右内距×2」,不足 40 时钳制到 200,避免极端窄宽度下继续断行。定宽取最宽行 + 左右内距×2,定高取行数 ×(行高 + 行距)+ 上下内距×2;纵向排列在光标下方,光标y + GAP + 高超出视口时整体移动到光标上方。 - 重排(同文本复用):
showAt遇到同文本时直接复用当前节点,只更新边侧、视口与换行度量,度量有变化才重算,节点不重建、不闪烁;文本变化时才移除当前节点,重建后推入浮层。提示浮层以「不参与外部点击关闭」的形式推入(dismissOnOutsideClick = false),其关闭仅由 hover 丢失触发,点击只作兜底且不消费事件。 - 换边:侧边采用三步决策。默认为光标右侧(换行宽度以视口右缘为界,正常情况下右侧总是放得下);右侧放不下且左侧放得下时换到左侧;两侧都放不下时维持原边,避免在临界宽度来回抖动。换边时位置以系数 0.3 滑动逼近新锚点;若换边同时引起换行变化,文本做交叉淡化,即旧文先淡出、新文再淡入,各占过渡时间一半;提示整体透明度低于 0.4 时跳过双重绘制,直接替换文本。
ContextMenu
右键菜单继承 Element,通常经 builder 构造后调用 .show(x, y):
ContextMenu menu = ContextMenu.builder(font)
.item("Copy", () -> copy())
.item("Delete", () -> delete(), true) // 第三个参数是 enabled
.maxHeight(260)
.build();
// 在 mousePressed 里:
if (e.button() == 1) { menu.show((int) e.x(), (int) e.y()); return true; }
关键 API:静态 ContextMenu.builder(Font) → .item(String|Component, Runnable[, enabled[, closeOnClick[, bgColor]]])、.colors(...)、.maxHeight(int) → .build();实例侧 .items(List<MenuItem>)、.addItem(String|Component, Runnable[, enabled])、.maxHeight(int)、.font(Font)、.show(int, int)、.hide();MenuItem 是 record (text, action, enabled, closeOnClick, bgColor)。颜色默认 ContextMenuColors.blue()。
细节:菜单展示宽度按最长项计算,高度受 maxHeight 钳制;右键点击菜单外部区域会关闭它,这属于浮层的外部关闭语义,覆盖层类型对照见覆盖层。
MessageFlyout
消息浮条(toast)同样继承 Element,向当前激活的 InteractionManager 推送:
new MessageFlyout()
.type(0xFF4CAF50) // 边框色(类型指示色)
.message("Saved successfully!")
.autoDismiss(3000) // 3 秒后自动关闭
.addAction(new Button("Undo", 60, 16).action(() -> undo()))
.show();
关键 API:.type(int) 边框指示色(读写对偶 type());.message(String|Component) / .message();.bgColor(int)、.textColor(int);.autoDismiss(long ms)(0 表示不自动关闭);.onDismiss(Runnable) 关闭回调;.addAction(Element<?>) 附加动作按钮;.show() / .show(String|Component) 推送到 InteractionManager.active。文本最大宽度钳制在 260px。
细节:多个浮条同时 .show() 时按覆盖层规则叠放,互不打断对方的自动关闭计时;按 ESC 或点击浮条的关闭区域立即触发 onDismiss。
MessageFlyout 关闭按钮定位
关闭按钮的字形放大到 CLOSE_SCALE = 2.0 倍绘制(经 TextDrawUtil.draw),定位不再用固定 PAD,而是 closeInset() 算出的内缩量,即距上边与距右边取同一个值。
- 取值:
(单行高度 - 字形高) / 2,单行高度是PAD * 2 + font.lineHeight。字形高度大于一行正文,上下各仅有该差值的余量:内缩量更大则贴近边缘,更小则覆盖正文。 - 单行:该内缩量使关闭按钮在浮条内垂直居中,并与正文行对齐。
- 多行:沿用同一内缩量,锚定在右上角;该值不随行数变化,消息在单行与多行之间切换时关闭按钮位置不变。
- 命中框随缩放变化:
closeGlyphX()/closeGlyphY()由绘制、refreshHover、mousePressed三处共用,命中框使用TextDrawUtil.scaledWidth/scaledHeight计算缩放后的宽高。
ProgressBar
进度条只读展示 0–1 的完成度:
ProgressBar bar = new ProgressBar(200, 16)
.progress(0.65f)
.fill(0xFF4CAF50);
关键 API:.progress(float) / .progress() 读写进度(内部钳制到 0–1);.fill(int) 填充色;.background(int) 底色。构造形式有三种:ProgressBar(int w, int h)、ProgressBar(int w)、ProgressBar(int w, int h, int accent)。
细节:组件继承 Element,没有点击交互;extractRenderState 开头同样以 if (!visible) return; 守卫,隐藏后既不绘制也不参与命中。
特殊行为
visible(false) 对绘制与命中的影响
将 visible 置 false 会同时关闭两条路径:绘制侧,各组件 extractRenderState 开头守卫先查可见性(if (!visible) return; 或与 font == null 的合取,Button、SelectBox、Slider、Toggle、ProgressBar、SelectDropdown 首行都拦截 !visible),LayoutElement 还会按 isVisible() 跳过子元素;命中侧,InteractionManager.hovering 在 el == null || !el.isVisible() 时直接返回 false,Hit 遍历同样剔除不可见节点。可见性按子树生效,即隐藏父级后子元素不会穿透父级的隐藏,整棵子树一起消失且无法命中。核实某处守卫时,查看该组件 extractRenderState 的首行;语义概述见核心概念的「字体、可见性与外边距」。
AutoCompleteBox 下拉的打开条件
showDropdown() 首行是 if (filtered.isEmpty()) return;,零匹配时不会置 open,即输入文字后不弹出下拉通常是当前输入未命中任何候选项。列表本身也有上限:filter() 收集到 20 条即 break,面板高度上限为 MAX_DD_H = 150,列表显示被截断即源于此。调试时在回调或日志中读取 isOpen() 与 filteredItems(),空列表可解释该现象。撤销栈上限另有常量 MAX_UNDO = 50。
输入框、按钮、下拉、菜单与列表的共用配色
同一组值:底色 0xFF222222、常态边框 0xFF555555、悬停边框 0xFF777777、聚焦/激活色 0xFF3B82F6、选区/行高亮底 0x403B82F6、正文 0xFFFFFFFF、禁用正文 0x80FFFFFF,一律全不透明。适用范围是 TextBox、AutoCompleteBox 本体与 Stepper,Button 与 Toggle,两处下拉面板、ContextMenu、ListView、Slider 滑块边框。
Button:底色0xFF222222,常态即绘制边框0xFF555555(不只在悬停时绘制),悬停动画将边框过渡到0xFF777777、底色提升到0xFF2E2E2E,按下时边框与底色都替换为强调色0xFF3B82F6。Toggle:开态轨道0xFF3B82F6、关态轨道0xFF222222(共用底色,不再使用半透明黑)、滑块0xFFFFFFFF。Slider:轨道0x30FFFFFF、已填充段0xFF3B82F6、滑块0xFFFFFFFF(悬停0xFF93C5FD)、滑块边框0xFF555555(新增的SliderColors.thumbBorder字段)。SelectDropdown本体按同一组颜色分三档设置边框:常态0xFF555555、悬停0xFF777777、展开0xFF3B82F6,后者取自DropdownColors.btnFocusBorder;本体不是可聚焦元素,“展开”即其激活态。
早期下拉与菜单使用的半透明黑底(0x30000000)、更暗的面板底(0xFF1E1E1E、0xFF1A1A1A)与更淡的高亮(0x303B82F6)已全部移除。换肤时替换整个颜色 record 即可;核对一致性只需检查这七个色位。
SelectBox 仍为例外,使用独立的三档底色(0xFF2A2A2A/0xFF444444/0xFF333333);它是 9×9 的小方块,共用底色会使填充块与边框失去对比。
Stepper 编辑态的三种退出方式
allowInput(true) 时点击中央区即进入编辑态,读数显示编辑缓冲。编辑态有三种退出方式,语义不同:
- 回车:提交,缓冲解析成数字写入当前值(解析失败则维持原值);
- 左右增减钮:先提交,再在刚提交的值上执行
+step/-step,即明确采用该数值,因此不是撤销; ESC:撤销,回到进入编辑态时的值。
失焦(点击其他区域)不提交也不撤销,直接结束编辑态、读数回到当前值。带 .suffix(...) 的读数在编辑期间不显示单位,确认后按同一格式恢复;FIXED 模式不进入编辑态(档位只能逐档切换)。
TextBox 的撤销上限与输入法预编辑
撤销历史最多保留 50 步(MAX_UNDO = 50),pushUndo 超限时移除最旧记录,因此连续编辑后按撤销无法回到最开头;该上限是组件内常量,链式 API 不暴露。中文等 IME 输入经 preeditUpdated(PreeditUpdatedEvent) 组装为浮层文本,候选确认后才写入正文并触发 onChanged,因此候选未确认时不会触发 onChanged 回调。核对实现时读取 pushUndo 的裁剪循环与 preeditUpdated。
预编辑浮层的提交与撤回
预编辑浮层通过 e.setPreeditOverlay(Renderable) 提交。该调用没有对称的自动撤销:MC 收到非 null 的浮层后持续持有它,本框下一帧不再提交不会使浮层消失。因此取消的那一帧必须显式补一次 setPreeditOverlay(null),否则候选上屏或失焦后浮层会保留在屏幕上。
两个输入框组件(TextBox、AutoCompleteBox)采用同一套约定:
- 提交时将
preeditSubmitted置true;撤销时只在preeditSubmitted为真的情况下发那一次 null,随即置回false。这样只撤回本组件提交过的浮层,不会清除同一帧内另一个输入框正在提交的浮层。 - 撤回不放在
if (focused)分支内。失焦时该分支不会执行,浮层会保留,因此判定与撤回都放在聚焦判定之外。 - 渲染早退(
!visible或无字体)同样执行撤回:浮层已经提交,元素这一帧不绘制也必须撤回。 focusLost()里将preeditOverlay置空,与既有的textInputManager().onTextInputFocusChange(false)放在一处。
自建输入组件按这四点实现即可符合 MC 的持有语义。
Slider 拖动期间接管悬停与右键
Slider.pressFlags() 返回 CAPTURE_DRAG | CAPTURE_RELEASE | BLOCK_HOVER | BLOCK_RIGHT:按下期间 InteractionManager 按这四位拦截,拖动时指针经过的按钮不再产生悬停高亮,右键也不会被下游按普通点击处理。其他组件默认 pressFlags 只含捕获按下/抬起两枚(CAPTURE_DRAG | CAPTURE_RELEASE),各组件按需追加。flag 的完整语义、叠加与清除时机见元素系统,需要整表时读取该页。
Slider 的滑块按比例逼近最新数值
滑块绘制读取显示值 animVal,不读取权威数值 value:每帧渲染时将剩余差距按固定比例 APPROACH = 0.4f 收敛,即 animVal += (value - animVal) * APPROACH,差距小于 0.001f 时直接赋值。该机制是无会话的逐帧逼近,没有起点、时长、缓动曲线等会话参数;目标连续变化时只有一条收敛路径,连续拖动、逐档滚动、按档点击使用同一逻辑。底条填充宽度与滑块位置读取同一 animVal,两者同步变化。
权威数值由输入路径即时写入:按下/拖动执行 value = toNorm(指针坐标)(nonlinear 时坐标经平方映射,steps > 0 时量化到档),滚轮按档位步进;链式 .value(float) 为程序化写入,显示值同步直接赋值,不经过逼近。hovered 由渲染路径逐帧读取悬停仲裁结果。
拖块内部的白色填充按非线性缓动缩放:常态铺满拖块(缩放 1.0),按下拖动时缩至一半(0.5),松开恢复,缓动使用 Animator + Easing.EASE_OUT_CUBIC 120 毫秒。拖块底色使用边框色作为框体,填充缩至一半时框体随之显示。程序化 .value(float) 不触发填充变化。
Slider 与 SelectBox 的控件尺寸变更
Slider 的轨道高 4→3 像素,滑块从「撑满全高的竖条」改为「竖向居中的小方块」:拖块边长 8 像素(受高度约束,不超过高度减 4),底色使用 SliderColors.thumbBorder 作为框体,内部白色填充常态铺满框体、按下拖动时非线性缩至一半,外层再绘制一圈 SliderColors.thumbBorder 边框。SelectBox 的 GAP 从 1 提高到 2,选中块内缩 2 像素,因此边框与填充之间显示未选中时的背景色,即填充物是内缩的方块,而非以背景色铺满。两者都是纯外观改动,命中区域与命中判定未变。
滚动条与滚轮的层级归属
点击与滚轮都采用「命中元素优先、沿父链回退、第一个消费即截断」的同构分发,因此组件内返回 true 即可阻止外层继续处理。两类现象分别归属不同层级。
StackPanel的滚动条:它是独立的元素对象(ScrollBar),位于children末位,即本容器最高层,命中查找逆序优先命中它,因此落在条带上的按下在它所在层被消费,下层内容元素无法获取,容器不必再参与,也不需要鼠标捕获。拖动时滚动值与目标值一起写入:滑块位置按当前值绘制,只改目标值会产生缓动滞后,表现为拖动后滑块跟不上指针。- 滚轮事件不向父级传递(
Stepper、Slider):滚轮分发与按下同构,从命中元素起沿父链回退、第一个消费即截断,因此Stepper/Slider消费滚轮后,外层容器不再滚动。点击容器空白处时,起点由Hit.deepestLayoutAt给出(矩形包含该点的最深布局容器);嵌套容器同理,内层空白处只有内层容器滚动。
Slider 采用另一条路径:其 pressFlags() 申报 CAPTURE_DRAG | CAPTURE_RELEASE,按下链识别后拖动事件只发送给它。捕获的申请、释放时机与语义见交互引擎。
创建组件
现成组件不满足需求时,从以下三个基类之一派生自定义组件:
| 基类 | 适用场景 |
|---|---|
Element<T> | 纯绘制、只收鼠标 |
TooltippingElement<T> | 需要工具提示 |
FocusableElement<T> | 需要键盘输入与焦点 |
必须实现两个方法:
measure():测量自身尺寸;固定尺寸组件留空。extractRenderState(GuiGraphicsExtractor, int, int, float):绘制入口;开头执行visible && font != null守卫,再调用layout()。
交互遵循框架口径:
- 悬停查询调用
InteractionManager.hovering(this, mx, my),即完整仲裁(可见性、挂起期、最上层归属,浮层树优先),而非裸矩形; - 带回应的点击按「按下记录状态、松开触发」实现:
mousePressed命中后记录待触发状态并返回true;返回true使本组件成为这次按下的消费者,pressFlags默认含CAPTURE_RELEASE,松开事件必然派发到本组件; - 松开动作定义在
pressReleased(e, hit)中:交互管理器在按下会话结束时调用它,传入的hit表示松开位置是否命中本组件(完整悬停仲裁,含遮挡与浮层),该值决定是否执行动作; - 列表、菜单一类多项组件在松开时用释放坐标重算所在项,与按下记录的项比对,同一项才触发;
- 自定义命中只覆写
shapeHit,不覆写isHit(见不变量)。
示例(配色块:悬停时边框提亮、按下时整体变暗、松开时触发回调):
public class ColorSwatch extends Element<ColorSwatch> {
private int color;
private final Animator hoverAnim = new Animator();
private final Animator pressAnim = new Animator();
private boolean pending;
private Runnable onClick;
public ColorSwatch(int color, int size) {
super(size, size);
this.color = color;
hoverAnim.set(0);
pressAnim.set(0);
}
public ColorSwatch onClick(Runnable r) { this.onClick = r; return this; }
@Override protected void measure() {} // 固定尺寸,无需测量
@Override
public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
if (!visible || font == null) return;
layout();
int ax = getAbsoluteX(), ay = getAbsoluteY();
int borderColor = Colors.lerp(0xFF555555, 0xFF3B82F6, hoverAnim.get());
e.fill(ax - 1, ay - 1, ax + width + 1, ay + height + 1, borderColor);
e.fill(ax, ay, ax + width, ay + height, color);
int pa = (int)(pressAnim.get() * 0x50); // 按下视觉:整块压一层半透明白
if (pa > 0) e.fill(ax, ay, ax + width, ay + height, (pa << 24) | 0xFFFFFF);
}
@Override public boolean mousePressed(MousePressedEvent e) {
if (e.button() != 0 || !InteractionManager.hovering(this, e.x(), e.y())) return false;
pending = true; // 按下只记状态,动作留给松开
pressAnim.animate(0, 1, 60, Easing.EASE_IN_CUBIC);
return true;
}
@Override public boolean pressReleased(MouseReleasedEvent e, boolean hit) {
boolean wp = pending; pending = false;
pressAnim.animate(1, 0, 100, Easing.EASE_OUT_CUBIC);
if (wp && hit && onClick != null) onClick.run(); // hit:交互管理器按完整悬停仲裁算出的命中
return wp;
}
@Override public void mouseEntered(HoverEvent e) {
hoverAnim.to(1f, 120, Easing.EASE_OUT_CUBIC);
}
@Override public void mouseExited(HoverEvent e) {
hoverAnim.to(0f, 120, Easing.EASE_OUT_CUBIC);
}
}
配色 record 与 component 包的位置,以及新增组件的完整流程,见贡献者侧常见实现模式。
不变量
编写自定义组件或修改布局时,两条不变量及其违反后果如下:
- 组件不得绕过
markDirty直接改字段。 所有链式 setter 在修改状态后调用markDirty();若在组件中直接写入width = 100这类赋值,dirty 标志不会置位,惰性布局不会重新执行,自身与整条父链的测量/排列全部失效,界面停留在旧几何。需要直接赋值时手动补markDirty();传播规则的权威说明见布局系统。 - 自定义命中只覆写
shapeHit,不覆写isHit。Element.isHit将形状命中与祖先裁剪边界组合成统一谓词,是分发、外部关闭、块查询共用的入口;覆写它会丢失裁剪语义,导致滚动区外误命中、覆盖层外部关闭判定错误。组件只需判定该点是否落在自身形状内,树搜索与分层由框架完成;H0/H1/L1/H3/L3/H5 分层的权威说明见交互引擎与元素系统。
组件快速参考
| 组件 | 继承自 | 尺寸 | 关键 API |
|---|---|---|---|
Button | TooltippingElement | W×H | .action(Runnable)、.setEnabled(boolean)、.text()、.tooltip(String|Component|Supplier<Component>)、.dismissTooltip() |
Toggle | TooltippingElement | 30×14 | .on(boolean)、.isOn()、.onChange(Runnable) |
SelectBox | TooltippingElement | 9×9 | .onClick(Runnable)、.isSelected() |
Slider | TooltippingElement | W×16 | .value(float)、.steps(int)、.nonlinear(boolean)、.onChanged(Consumer<Float>) |
Stepper | FocusableElement | W×22 | .mode(Mode)、.value(double)、.range(double,double)、.fixedLabels(String...)、.allowInput(boolean) |
TextBox | FocusableElement | W×H | .text(String)、.placeholder(...)、.maxLength(int)、.onChanged(Consumer<String>)、.onEnter(Runnable)、.focus() |
AutoCompleteBox | FocusableElement | W×H | .dataset(List<String>)、.onSelected(Consumer<String>)、.onChanged(...)、.isOpen()、.filteredItems() |
SelectDropdown | TooltippingElement | W×22 | .options(...)、.selected(int)、.onChanged(Consumer<Integer>)、.maxVisible(int)、.isOpen()、.toggle()(内部 DropPanel extends Element) |
ListView<T> | TooltippingElement | W×H | .items(List<T>)、.itemHeight(int)、.onSelected(Consumer<Integer>)、.selectedIndex()、覆写 drawRow(...) |
ItemList<T> | ListView<T> | W×H | .renderer(BiConsumer<T,StackPanel>)、.dedupe(boolean)、.onAdd(...)、.onDuplicate(...)、.onChange(...) |
ItemView | TooltippingElement | 18 / 26 | ItemView(Item) / ItemView(ItemStack) / 带 Size 重载、.item(Item)、.count(int)、.contextMenu(MenuMode)、.decorations(boolean) |
Tooltip | Element | 自动 | 静态 show/showAt/hide/cursor/track;实例侧用任意元素的 .tooltip(...)(单例,勿自行 new) |
ContextMenu | Element | 自动 | ContextMenu.builder(font).item(...).maxHeight(260).build()、.addItem(...)、.show(int,int) |
MessageFlyout | Element | 自动 | .type(int)、.message(...)、.autoDismiss(long)、.onDismiss(Runnable)、.addAction(...)、.show() |
ProgressBar | Element | W×H | .progress(float)、.fill(int)、.background(int) |
尺寸列中 W×H 表示构造时给定宽高;「18 / 26」对应 ItemView.Size.SMALL / LARGE;「自动」表示组件按内容自测尺寸。