Initial InputEvent handling. Clickable "Wrap" text label.
This commit is contained in:
parent
50e9ca5b34
commit
1596dab72d
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ type Label struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Text string
|
||||
Align TextAlign
|
||||
FontSize unit.Sp // 0 = theme default
|
||||
|
|
@ -65,20 +66,23 @@ type Label struct {
|
|||
|
||||
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{
|
||||
id: id,
|
||||
region: region,
|
||||
visible: true,
|
||||
interactions: interactions,
|
||||
Text: text,
|
||||
FontSize: fontSize,
|
||||
Align: align,
|
||||
|
|
@ -90,12 +94,14 @@ type Icon struct {
|
|||
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) 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
|
||||
|
|
@ -138,6 +145,7 @@ type TextField struct {
|
|||
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,6 +158,7 @@ type ListView struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Items []ListItem
|
||||
ScrollOffset int
|
||||
Selected int
|
||||
|
|
@ -158,6 +167,7 @@ type ListView struct {
|
|||
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,6 +181,7 @@ type AlphaIndex struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Letters []string
|
||||
ActiveLetter string
|
||||
}
|
||||
|
|
@ -178,6 +189,7 @@ type AlphaIndex struct {
|
|||
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 {
|
||||
|
|
@ -193,6 +205,7 @@ type Button struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Text string
|
||||
Enabled bool
|
||||
Primary bool
|
||||
|
|
@ -200,9 +213,10 @@ type Button struct {
|
|||
|
||||
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 }
|
||||
|
||||
|
|
@ -222,6 +236,7 @@ type SearchBar struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Query string
|
||||
Match int
|
||||
Total int
|
||||
|
|
@ -231,6 +246,7 @@ type SearchBar struct {
|
|||
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 {
|
||||
|
|
@ -249,6 +265,7 @@ type Cursor struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Line int
|
||||
Column int
|
||||
Blinking bool
|
||||
|
|
@ -258,6 +275,7 @@ type Cursor struct {
|
|||
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
|
||||
|
|
@ -282,6 +301,7 @@ type MergeHunk struct {
|
|||
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
|
||||
|
|
@ -298,6 +318,7 @@ type Toast struct {
|
|||
id string
|
||||
region Region
|
||||
visible bool
|
||||
interactions []Interaction
|
||||
Text string
|
||||
Timeout int // milliseconds
|
||||
}
|
||||
|
|
@ -305,6 +326,7 @@ type Toast struct {
|
|||
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 {
|
||||
|
|
@ -321,11 +343,13 @@ type Spacer struct {
|
|||
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) Interactions() []Interaction { return s.interactions }
|
||||
|
||||
// NewSpacer creates a visible Spacer element.
|
||||
func NewSpacer(height Dp) Spacer {
|
||||
|
|
@ -368,10 +392,21 @@ 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
|
||||
Handler func(any)
|
||||
Data any
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
// 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 AlignStart:
|
||||
drawX = reg.X
|
||||
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
|
||||
// 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user