diff --git a/cmd/pad/main.go b/cmd/pad/main.go index ce1f54c..c34f221 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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() } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index e26b93d..68486c3 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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() diff --git a/internal/editor/state.go b/internal/editor/state.go index 9f6755a..7425a6e 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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}, + }), }, ) diff --git a/internal/ui/element.go b/internal/ui/element.go index 7026f19..105bbc0 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -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). diff --git a/internal/ui/render.go b/internal/ui/render.go index 4f6a6a8..8a240ad 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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.