处理输入
指针、键盘、快捷键、滚动和焦点,附带一个可以拖动、打字和滚动的完整示例。
输入以有类型的回调形式出现在 app.Config 上。每个回调接收一个事件,返回一个 ui.EventResult,说明应用是否使用了这个事件,以及窗口是否需要重绘。下面的示例用到了所有回调:一个带可拖动分隔条的面板、一个可以点击并用方向键移动的列表、一次次按钮按下、一个键盘快捷键和滚轮滚动。布局用的是 flex,所以示例只处理页面自己无法声明的部分。
package main
import (
"fmt"
"log"
"runtime"
"github.com/vivaui/viva/app"
"github.com/vivaui/viva/flex"
"github.com/vivaui/viva/ui"
)
// Identities are names in a tree, not numbers: the application declares the
// tree once and every control it paints is a name inside a feature of it.
var (
tree = ui.Tree{}
page = tree.Feature("mail")
rows = tree.Sub(page, tree.Name("rows"))
divider = tree.ID(page, "divider")
list = tree.ID(page, "list")
)
var mailboxes = []string{"Inbox", "Drafts", "Sent", "Archive", "Trash"}
type model struct {
split flex.SplitterState
selected int
palette bool
context string
view flex.View
}
// primary is the modifier that means "the command key" on this platform.
func primary() ui.Modifiers {
if runtime.GOOS == "darwin" {
return ui.ModifierMeta
}
return ui.ModifierControl
}
func newConfig(m *model) app.Config {
theme := ui.LightTheme()
config := app.Config{Tree: &tree, Title: "Input", Width: 640, Height: 360}
config.Paint = func(canvas *ui.Canvas, size ui.Size) {
bounds := ui.Rect{Width: size.Width, Height: size.Height}
canvas.Fill(bounds, theme.Background)
m.view.Draw(canvas, theme, bounds, func(f *flex.Frame) {
f.Row().Fill().Children(func() {
f.ScrollArea(list).Width(m.split.Pos).Background(theme.Card).Children(func() {
for i, name := range mailboxes {
row := f.Row().Pad(8).PadX(12).HoverBackground(theme.Hover).Hit(rows.At(i), name)
if i == m.selected {
row.Background(theme.Hover)
}
if row.Children(func() { f.Text(name) }).Clicked() {
m.selected = i
}
}
})
// The splitter is the draggable divider: it captures the pointer, shows
// the resize cursor and keeps both panes within their minimum sizes.
f.Splitter(divider, &m.split).Mins(120, 200)
f.Column().Grow(1).Pad(16).Gap(8).Children(func() {
detail := fmt.Sprintf("%s selected", mailboxes[m.selected])
if m.palette {
detail = "Command palette"
}
f.Text(detail)
f.Text(m.context).Muted()
})
})
})
}
// OnPointer sees every press, move and release; Target is the identity under
// the pointer. Here a secondary press on a row is the start of a context menu.
config.OnPointer = func(event ui.PointerEvent) ui.EventResult {
if event.Kind == ui.PointerDown && event.Button == ui.PointerButtonSecondary && event.Target.Scope() == rows {
row, _ := event.Target.Name().Index()
m.context = "Context menu for " + mailboxes[row]
return ui.EventResult{Handled: true, Repaint: true}
}
return ui.EventResult{}
}
// OnKey is called for the control that has keyboard focus: a row of the list,
// or the divider, which moves with the arrow keys.
config.OnKey = func(event ui.KeyEvent) ui.EventResult {
if result := m.split.Key(event); result.Handled {
return result
}
if event.Target.Scope() != rows {
return ui.EventResult{}
}
switch event.Key {
case ui.KeyDown:
m.selected = min(m.selected+1, len(mailboxes)-1)
case ui.KeyUp:
m.selected = max(m.selected-1, 0)
default:
return ui.EventResult{}
}
return ui.EventResult{Handled: true, Repaint: true, Focus: rows.At(m.selected), FocusVisible: true}
}
config.OnShortcut = func(event ui.KeyEvent) ui.EventResult {
if event.Modifiers == primary() && event.Text == "k" {
m.palette = !m.palette
return ui.EventResult{Handled: true, Repaint: true}
}
return ui.EventResult{}
}
// The wheel goes to the scroll area under the pointer.
config.OnScroll = m.view.Wheel
return config
}
func main() {
m := &model{split: flex.SplitterState{Pos: 220}}
var canvas ui.Canvas
if err := app.RunCanvas(newConfig(m), &canvas); err != nil {
log.Fatal(err)
}
}
标识
命中区域由 ui.ID 标识:一个位置(ui.Scope)加上位置内的名字。零值 ID 表示“没有目标”。任何东西都不再手工编号。程序声明一棵 ui.Tree,为每个页面或浮层取一个 feature,为每个控件取一个名字;列表是它自己的 scope,行就是 ui.Index(i)。
var (
tree ui.Tree
mail = tree.Feature("mail")
rows = tree.Sub(mail, tree.Name("rows"))
hitSend = mail.ID(tree.Name("send")) // 一个控件
)
canvas.Hit(rows.ID(ui.Index(row)), bounds) // 绘制时,每行一个 ID
if id.Scope() == rows { // 点击到来时
row, _ := id.Name().Index()
open(row)
}
不同的位置或名字永远不会混淆,因为它们是被驻留(interned)的,而不是哈希或编号,所以不同人写的功能不会冲突,也没有会用尽的区间。tree.Within(id.Scope(), mail) 能判断一次点击是否发生在某个页面上,不论多深。用 Config.Tree 把这棵树交给窗口,日志和无头驱动里的标识就会以 mail/rows/#3 这样的路径显示。命名只发生一次:初始化包级变量时,或列表第一次出现时;绘制一个已存在的 ID 不会分配内存。
指针
点击在声明控件的地方处理:f.Button(...).Clicked(),或者 Hit 盒子上的 Clicked(),示例里列表的行就是这样。两个窗格之间的拖动也不需要处理函数:f.Splitter 会捕获指针、显示调整大小的光标,并让两个窗格都不小于各自的最小尺寸,示例只需要把第一个窗格的宽度设为 m.split.Pos。
OnPointer 负责剩下的部分。它收到按下、移动、抬起和离开事件,坐标是逻辑窗口像素,Target 是指针下方控件的标识。一次按下会在之后的移动和抬起事件中保持原来的目标,此时 Captured 为真,这正是你自己实现的拖动在指针离开控件后仍然有效的原因。示例用它处理行上的次按钮按下,也就是右键菜单的起点。
Button 区分主按钮和次按钮。ClickCount 为相隔很近的同一按钮连续按下编号,单击是 1,双击的第二次是 2。HitOptions.Cursor 设置命中区域被悬停或拖动时的指针形状(f.Splitter 已经在用 PointerCursorResizeHorizontal)。
键盘与焦点
OnKey 收到焦点所在命中区域的键盘事件。Target 说明是哪一个,所以处理函数在动手前要先检查它:示例检查它是不是某一行,并把分隔条的方向键交给 m.split.Key。Key 是导航键、编辑键和功能键的名字(KeyDown、KeyEscape、KeyEnter、KeyTab……)。对于 Ctrl+C 这样的组合,Key 是 KeyUnknown,Text 是不带修饰键的字符。
任何用 Hit、HitWithLabel 或 HitWithOptions 声明的命中区域都可以获得焦点。Tab 在它们之间移动,Enter 和空格激活获得焦点的按钮,标记了 SkipFocus 或 Disabled 的会被跳过。焦点由窗口替你跟踪:
Canvas.Focused返回持有焦点的命中区域,Canvas.FocusVisible只在应该绘制焦点环时才返回它。鼠标点击会让控件获得焦点但不显示焦点环;按 Tab 才会显示。Canvas.Hovered和Canvas.Pressed以同样的方式报告指针状态。
绘制代码读取这些值,而不是自己保存副本,所以悬停、按下和焦点永远不会和运行时的认知不一致。
要自己移动焦点,在 EventResult 里返回 Focus(以及 FocusVisible),或用 Blur 清除焦点。
快捷键
OnShortcut 在原生文本系统看到之前,先收到带 Control 或 Command 的可打印按键组合。只对应用自己拥有的组合返回 Handled:未处理的组合会继续走正常的文本编辑,所以在文本框里 Command-C 仍然是复制。在 Windows 上,获得焦点的普通文本框会先拿走标准编辑与剪贴板组合(复制、剪切、粘贴、全选、撤销、重做、Home/End、按词和按文档移动),终端和 Placed 编辑器不会。示例用 primary() 辅助函数来比较 Modifiers,因为常规的按键在 macOS 上是 Command,在其他平台上是 Control。
滚动
f.ScrollArea(id) 把偏移量保存在 flex.View 里,所以滚动不需要你保存任何状态:config.OnScroll = m.view.Wheel 把滚轮交给指针下方的区域。OnScroll 收到像素增量,附带窗口位置、修饰键、Viewport(当前窗口大小)和指针下方的命中区域,所以你自己的处理函数不依赖之前的某次指针移动。正的增量露出下方的内容。自己滚动的内容要用 Canvas.Clip 裁剪,避免它画到邻居上;被裁掉的命中区域也不再响应。
重绘
只有状态确实变了才返回 Repaint: true。什么都没改变的事件返回零值 EventResult,这不会触发重绘。