Initial InputEvent handling. Clickable "Wrap" text label.

This commit is contained in:
Greg Pomerantz 2026-05-26 09:46:35 -04:00
parent 50e9ca5b34
commit 1596dab72d
5 changed files with 244 additions and 136 deletions

View File

@ -58,6 +58,9 @@ func run(w *app.Window) error {
mu.Lock()
currentElems := elems
renderer.Draw(gtx, currentElems)
if events := renderer.CheckGestures(e.Source); len(events) > 0 {
logic.InputChan() <- events
}
e.Frame(&ops)
mu.Unlock()
}

View File

@ -33,13 +33,6 @@ func (e ScaleEvent) apply(s *State) {
s.SetScale(e.Scale)
}
// InputEvent represents a user input event routed to an element.
type InputEvent struct {
ElementID string
Type ui.InputType
Data any
}
// ResultEvent represents a completed async task result.
type ResultEvent struct {
// Future: add result fields here
@ -50,18 +43,20 @@ type Logic struct {
state *State
configChan chan ConfigUpdate
frameChan chan []ui.Element
inputChan chan []InputEvent
inputChan chan []ui.InputEvent
resultChan chan ResultEvent
mu sync.Mutex
}
// NewLogic creates a new Logic instance.
func NewLogic() *Logic {
state := NewState()
TheState = state
return &Logic{
state: NewState(),
state: state,
configChan: make(chan ConfigUpdate),
frameChan: make(chan []ui.Element),
inputChan: make(chan []InputEvent),
inputChan: make(chan []ui.InputEvent),
resultChan: make(chan ResultEvent),
}
}
@ -78,7 +73,7 @@ func (l *Logic) FrameChan() <-chan []ui.Element {
}
// InputChan returns the input channel for the logic goroutine.
func (l *Logic) InputChan() chan<- []InputEvent {
func (l *Logic) InputChan() chan<- []ui.InputEvent {
return l.inputChan
}
@ -87,6 +82,9 @@ func (l *Logic) ResultChan() chan<- ResultEvent {
return l.resultChan
}
// TheState is the global editor state, set once at startup.
var TheState *State
// Scale returns the current scale factor (pixels per DP).
func (l *Logic) Scale() float32 {
return l.state.Scale()
@ -99,7 +97,10 @@ func (l *Logic) Run() {
case update := <-l.configChan:
update.apply(l.state)
l.frameChan <- l.state.layout()
case <-l.inputChan:
case events := <-l.inputChan:
for _, evt := range events {
evt.Handler(evt.Data)
}
l.frameChan <- l.state.layout()
case <-l.resultChan:
l.frameChan <- l.state.layout()

View File

@ -9,6 +9,7 @@ type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
WordWrap bool
Elems []ui.Element
}
@ -29,12 +30,17 @@ func (s *State) Scale() float32 {
func (s *State) layout() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
s.Elems = EditorLayout(dpW, dpH)
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
return s.Elems
}
// ToggleWordWrap toggles the word wrap setting.
func ToggleWordWrap(data any) {
TheState.WordWrap = !TheState.WordWrap
}
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10)
// --- Top bar: filename on row 1, icons on row 2 ---
@ -49,7 +55,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
// Row 1: filename
ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart),
ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
// Row 2: cut, copy, paste icons
ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
@ -66,13 +72,19 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
H: bottomBarHeight,
}
bottomBarW := bottomBarRegion.W
wrapText := "Wrap: Off"
if wordWrap {
wrapText = "Wrap: On"
}
bottomBar := ui.NewContainer(
bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter),
ui.NewLabel("Wrap: On", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd),
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
{Gesture: ui.Tap, Handler: ToggleWordWrap},
}),
},
)

View File

@ -53,49 +53,55 @@ func NewContainer(region Region, bg Color, children []Element) Container {
// Label displays static text.
type Label struct {
id string
region Region
visible bool
Text string
Align TextAlign
FontSize unit.Sp // 0 = theme default
Color Color // 0 = theme default
Bold bool
id string
region Region
visible bool
interactions []Interaction
Text string
Align TextAlign
FontSize unit.Sp // 0 = theme default
Color Color // 0 = theme default
Bold bool
}
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) Interactions() []Interaction { return l.interactions }
func (l Label) Draw(gtx layout.Context, r *Renderer) {
col := l.Color
if col == (Color{}) {
col = Color{R: 0, G: 0, B: 0, A: 255}
}
r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col)
r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col, l.id)
}
func (l Label) ID() string { return l.id }
// NewLabel creates a visible Label element.
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign) Label {
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign, id string, interactions []Interaction) Label {
return Label{
region: region,
visible: true,
Text: text,
FontSize: fontSize,
Align: align,
id: id,
region: region,
visible: true,
interactions: interactions,
Text: text,
FontSize: fontSize,
Align: align,
}
}
// Icon displays a named icon image.
type Icon struct {
id string
region Region
visible bool
Name string // icon name, e.g. "cut"
Size Dp // 0 = default icon size
id string
region Region
visible bool
interactions []Interaction
Name string // icon name, e.g. "cut"
Size Dp // 0 = default icon size
}
func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible }
func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible }
func (i Icon) Interactions() []Interaction { return i.interactions }
func (i Icon) Draw(gtx layout.Context, r *Renderer) {
img := r.icon(i.Name)
if img == nil {
@ -125,6 +131,7 @@ type TextField struct {
id string
region Region
visible bool
interactions []Interaction
Value string
Placeholder string
Focused bool
@ -135,9 +142,10 @@ type TextField struct {
WrapWidth Dp
}
func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
func (tf TextField) Interactions() []Interaction { return tf.interactions }
// Line represents a single line of text in a multiline TextField.
type Line struct {
@ -150,14 +158,16 @@ type ListView struct {
id string
region Region
visible bool
interactions []Interaction
Items []ListItem
ScrollOffset int
Selected int
}
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
func (lv ListView) Interactions() []Interaction { return lv.interactions }
// ListItem is a single entry in a ListView.
type ListItem struct {
@ -171,13 +181,15 @@ type AlphaIndex struct {
id string
region Region
visible bool
interactions []Interaction
Letters []string
ActiveLetter string
}
func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
// NewAlphaIndex creates a visible AlphaIndex element.
func NewAlphaIndex(region Region, letters []string) AlphaIndex {
@ -190,19 +202,21 @@ func NewAlphaIndex(region Region, letters []string) AlphaIndex {
// Button is an interactive button element.
type Button struct {
id string
region Region
visible bool
Text string
Enabled bool
Primary bool
id string
region Region
visible bool
interactions []Interaction
Text string
Enabled bool
Primary bool
}
func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible }
func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible }
func (b Button) Interactions() []Interaction { return b.interactions }
func (b Button) Draw(gtx layout.Context, r *Renderer) {
col := Color{R: 0, G: 0, B: 0, A: 255}
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, AlignStart, col)
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, AlignStart, col, b.id)
}
func (b Button) ID() string { return b.id }
@ -219,18 +233,20 @@ func NewButton(text string, enabled bool, primary bool, region Region) Button {
// SearchBar displays an in-editor search interface.
type SearchBar struct {
id string
region Region
visible bool
Query string
Match int
Total int
Forward bool
id string
region Region
visible bool
interactions []Interaction
Query string
Match int
Total int
Forward bool
}
func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id }
func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id }
func (sb SearchBar) Interactions() []Interaction { return sb.interactions }
// NewSearchBar creates a visible SearchBar element.
func NewSearchBar(region Region, query string, match, total int, forward bool) SearchBar {
@ -246,18 +262,20 @@ func NewSearchBar(region Region, query string, match, total int, forward bool) S
// Cursor displays the text cursor and optional selection highlight.
type Cursor struct {
id string
region Region
visible bool
Line int
Column int
Blinking bool
Selection *Selection
id string
region Region
visible bool
interactions []Interaction
Line int
Column int
Blinking bool
Selection *Selection
}
func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id }
func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id }
func (c Cursor) Interactions() []Interaction { return c.interactions }
// Selection represents a text selection range.
type Selection struct {
@ -270,6 +288,7 @@ type MergeHunk struct {
id string
region Region
visible bool
interactions []Interaction
HunkNumber int
TotalHunks int
LineRange string
@ -279,9 +298,10 @@ type MergeHunk struct {
Resolution HunkResolution
}
func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id }
func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id }
func (mh MergeHunk) Interactions() []Interaction { return mh.interactions }
// HunkResolution represents the resolution state of a merge hunk.
type HunkResolution int
@ -295,16 +315,18 @@ const (
// Toast displays a temporary notification.
type Toast struct {
id string
region Region
visible bool
Text string
Timeout int // milliseconds
id string
region Region
visible bool
interactions []Interaction
Text string
Timeout int // milliseconds
}
func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id }
func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id }
func (t Toast) Interactions() []Interaction { return t.interactions }
// NewToast creates a visible Toast element.
func NewToast(region Region, text string, timeout int) Toast {
@ -318,14 +340,16 @@ func NewToast(region Region, text string, timeout int) Toast {
// Spacer adds vertical or horizontal space.
type Spacer struct {
id string
region Region
visible bool
id string
region Region
visible bool
interactions []Interaction
}
func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id }
func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id }
func (s Spacer) Interactions() []Interaction { return s.interactions }
// NewSpacer creates a visible Spacer element.
func NewSpacer(height Dp) Spacer {
@ -368,11 +392,22 @@ const (
KeyUp
)
// InputEvent represents a user input event routed to an element.
// Interaction pairs a gesture type with a handler function.
type Interaction struct {
Gesture InputType
Handler func(any)
}
// Interactive is implemented by elements that respond to input events.
type Interactive interface {
ID() string
Interactions() []Interaction
}
// InputEvent represents a user input event with its handler.
type InputEvent struct {
ElementID string
Type InputType
Data any
Handler func(any)
Data any
}
// ConfigEvent represents a window configuration change (resize, orientation).

View File

@ -8,6 +8,8 @@ import (
_ "image/png"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
@ -28,17 +30,30 @@ type ScaleProvider interface {
Scale() float32
}
// clickReg pairs a gesture.Click with its handler.
type clickReg struct {
click *gesture.Click
handler func(any)
}
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
scale ScaleProvider
icons map[string]image.Image
theme Theme
shp *text.Shaper
scale ScaleProvider
icons map[string]image.Image
clicks map[string]clickReg
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
r := &Renderer{theme: th, shp: shp, scale: scale, icons: make(map[string]image.Image)}
r := &Renderer{
theme: th,
shp: shp,
scale: scale,
icons: make(map[string]image.Image),
clicks: make(map[string]clickReg),
}
r.loadIcons()
return r
}
@ -93,7 +108,45 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
clipRect.Pop()
}
// registerInteraction registers a gesture for an element.
// Only Tap is supported for now.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) {
if interaction.Gesture != Tap {
return
}
reg, ok := r.clicks[id]
if !ok {
reg = clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
// click.Add() is now called inside drawText, within the text clip
reg.handler = interaction.Handler
r.clicks[id] = reg
}
// CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source) []InputEvent {
var events []InputEvent
for _, reg := range r.clicks {
evt, ok := reg.click.Update(q)
if ok && evt.Kind == gesture.KindClick {
events = append(events, InputEvent{
Handler: reg.handler,
Data: evt,
})
}
}
return events
}
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
// Register interactions before drawing
if interactive, ok := e.(Interactive); ok {
for _, interaction := range interactive.Interactions() {
r.registerInteraction(interactive.ID(), interaction, gtx, e.Region())
}
}
reg := e.Region()
if container, ok := e.(Container); ok {
@ -125,7 +178,7 @@ func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
bgClip.Pop()
}
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, col Color) {
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, col Color, id string) {
if str == "" {
return
}
@ -136,36 +189,40 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
MaxLines: 1,
}
if align == AlignStart {
// No measurement needed — draw at reg.X directly
r.shp.LayoutString(params, str)
r.drawLineText(gtx, reg.X, reg.Y, col)
} else {
// Phase 1: measure text width via glyph iteration (consumes iterator)
r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance
}
// totalAdvance>>6 is in device pixels (shaper outputs device pixels).
// Divide by scale to convert to Dp so it matches region coordinate space.
textW := Dp(float32(totalAdvance>>6) / r.scale.Scale())
// Compute aligned X position
var drawX Dp
switch align {
case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2
case AlignEnd:
drawX = reg.X + reg.W - textW
default:
drawX = reg.X
}
// Phase 2: layout again (iterator consumed) and draw
r.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col)
// Measure text width via glyph iteration (consumes iterator)
r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance
}
textW := Dp(float32(totalAdvance>>6) / r.scale.Scale())
// Compute aligned X position
var drawX Dp
switch align {
case AlignStart:
drawX = reg.X
case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2
case AlignEnd:
drawX = reg.X + reg.W - textW
}
// Clip to just the text area, register click inside the clip
textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(drawX)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(drawX + textW)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
if cr, ok := r.clicks[id]; ok && cr.click != nil {
cr.click.Add(gtx.Ops)
}
// Layout again (iterator consumed) and draw
r.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col)
textClip.Pop()
}
// drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety.