Add word-wrapped text display with two-finger scroll

- Renderer computes word wrap via single LayoutString pass with
  WrapHeuristically policy; glyphs drawn inline at FlagLineBreak
- Fixed line height (fontSize * 1.2), independent of glyph metrics
- Two-finger trackpad scroll via gesture.Scroll with vertical axis
- Display line feedback: renderer reports last glyph Y after each
  Draw; logic uses it to clamp scroll offset so last line stops
  at bottom of viewport with lineHeight/2 padding
- ScrollRange fix: {Min: -(1<<30), Max: 1<<30} ensures scroll
  delta is consumed (empty range consumes nothing via clampSplit)
- Line counting fix: only FlagLineBreak increments count; buffer
  flushes (32-glyph cap) draw but don't count
This commit is contained in:
Greg Pomerantz 2026-05-26 20:26:20 -04:00
parent ae04aabb70
commit 0246316d8f
6 changed files with 265 additions and 39 deletions

View File

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

View File

@ -44,6 +44,7 @@ type Logic struct {
configChan chan ConfigUpdate
frameChan chan []ui.Element
inputChan chan []ui.InputEvent
lastLineYChan chan int // last line Y (Dp, sent as int) feedback from renderer
resultChan chan ResultEvent
mu sync.Mutex
}
@ -57,6 +58,7 @@ func NewLogic() *Logic {
configChan: make(chan ConfigUpdate),
frameChan: make(chan []ui.Element),
inputChan: make(chan []ui.InputEvent),
lastLineYChan: make(chan int),
resultChan: make(chan ResultEvent),
}
}
@ -77,6 +79,11 @@ func (l *Logic) InputChan() chan<- []ui.InputEvent {
return l.inputChan
}
// DisplayLineChan returns the last line Y feedback channel.
func (l *Logic) DisplayLineChan() chan<- int {
return l.lastLineYChan
}
// ResultChan returns the result channel for the logic goroutine.
func (l *Logic) ResultChan() chan<- ResultEvent {
return l.resultChan
@ -97,6 +104,9 @@ func (l *Logic) Run() {
case update := <-l.configChan:
update.apply(l.state)
l.frameChan <- l.state.layout()
case y := <-l.lastLineYChan:
l.state.LastLineY = ui.Dp(y)
l.frameChan <- l.state.layout()
case events := <-l.inputChan:
for _, evt := range events {
evt.Handler(evt.Data)

View File

@ -4,12 +4,47 @@ import (
"pad/internal/ui"
)
// SampleText is a static lorem-ipsum text used for display-only testing.
// Roughly 3 KB, filling a few pages of the editor.
const SampleText = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architectus qui exercitationem ullam corporis suscipit dolorum et saepe fugiat. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
Neque porro consequatur autem velbeat viciis quam autem voluptas minus odio voluptatem. Quis autem vel natus autem sequis dolor tempor. Ut enim minima voluptate et quis autem sequia dolor tempor. Sed autem quia dolor sed consequat et voluptate autem sequia dolor tempor. Nemo enim sed consequat et voluptate autem sequia dolor tempor.
The quick brown fox jumps over the lazy dog. This is a short line to test how the editor handles lines that are much shorter than the wrap width. Some lines will be very long and wrap many times, while others fit on a single line easily.
Attitulam velis, te sum quae dolorem sequia dolor tempor. Ut enim minima voluptate et quis autem sequia dolor tempor. Sed autem quia dolor sed consequat et voluptate autem sequia dolor tempor. Nemo enim sed consequat et voluptate autem sequia dolor tempor.
There are also words that are extremelylonganddonothaveanywhitespacesinwhichcasewithheuristicswrappingthewholewordwilloverflowthewrapwidthratherthanbeingbrokenmidcharacter. This is expected behavior for a text editor long identifiers, URLs, or concatenated text should stay on one line even if they exceed the viewport width.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Two words: antidisestablishmentarianism and floccinauciniphilolipaecpelagioodontophont索菲乌斯 are examples of long words that may overflow the wrap width.
In conclusion, this sample text provides a variety of line lengths, word lengths, and paragraph structures to exercise the word wrap implementation. Short lines, long lines, very long words, normal words, empty lines all present here.`
// EditorFontSize is the font size used for editor text.
const EditorFontSize = 14 // unit.Sp
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
const EditorLineHeightScale = 1.2
// EditorLineHeight returns the fixed line height in Dp for the editor font.
func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
}
// State holds all application state owned by the logic goroutine.
type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
Elems []ui.Element
}
@ -39,6 +74,20 @@ func ToggleWordWrap(data any) {
TheState.WordWrap = !TheState.WordWrap
}
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
func HandleScroll(data any) {
delta := data.(int) // pixels
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
if TheState.ScrollOffset > TheState.MaxScroll {
TheState.ScrollOffset = TheState.MaxScroll
}
}
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10)
@ -88,5 +137,30 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
},
)
return []ui.Element{statusBar, bottomBar}
// --- Editor text area ---
editorY := statusBarRegion.Y + statusBarRegion.H
editorH := bottomBarRegion.Y - editorY
editorRegion := ui.Region{
X: margin, Y: editorY,
W: screenWidth - margin*2,
H: editorH,
}
// Compute max scroll offset from the last line baseline reported by the renderer.
// lastLineY is the shaper's Y value for the last line's baseline.
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
maxScroll := TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
if maxScroll < 0 {
maxScroll = 0
}
TheState.MaxScroll = maxScroll
editor := ui.NewTextField(
SampleText,
editorRegion,
editorRegion.W,
TheState.ScrollOffset,
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleScroll}},
)
return []ui.Element{statusBar, editor, bottomBar}
}

View File

@ -147,6 +147,26 @@ func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
func (tf TextField) Interactions() []Interaction { return tf.interactions }
// Draw renders the TextField. For multiline text, it shapes with word wrap
// and draws display lines inline — one LayoutString call, no double-shaping.
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset)
}
// NewTextField creates a visible multiline TextField.
func NewTextField(value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField {
return TextField{
region: region,
visible: true,
interactions: interactions,
Value: value,
Multiline: true,
WordWrap: true,
WrapWidth: wrapWidth,
ScrollOffset: scrollOffset,
}
}
// Line represents a single line of text in a multiline TextField.
type Line struct {
Text string

View File

@ -12,5 +12,9 @@ const (
ButtonPadding = Dp(8)
)
// LineHeightScale is the baseline-to-baseline spacing multiplier for editor text.
// Fixed at 1.2 — independent of which glyphs appear on a given line.
const LineHeightScale = 1.2
// NOTE: EditorLayout is defined in internal/editor/state.go.
// This file contains layout constants only.

View File

@ -6,10 +6,12 @@ import (
"image"
"image/color"
_ "image/png"
"time"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/input"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
@ -36,6 +38,12 @@ type clickReg struct {
handler func(any)
}
// scrollReg pairs a gesture.Scroll with its handler.
type scrollReg struct {
scroll *gesture.Scroll
handler func(any)
}
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
@ -43,6 +51,9 @@ type Renderer struct {
scale ScaleProvider
icons map[string]image.Image
clicks map[string]clickReg
scrolls map[string]scrollReg
displayLineCount int // number of display lines from last drawWrappedText
lastLineY Dp // last line baseline offset from text origin, in Dp
}
// New creates a new Renderer.
@ -53,6 +64,7 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
scale: scale,
icons: make(map[string]image.Image),
clicks: make(map[string]clickReg),
scrolls: make(map[string]scrollReg),
}
r.loadIcons()
return r
@ -109,23 +121,38 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
}
// registerInteraction registers a gesture for an element.
// Only Tap is supported for now.
// Supports Tap and Scroll.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) {
if interaction.Gesture != Tap {
return
}
switch interaction.Gesture {
case Tap:
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
// click.Add() is called inside drawText, within the text clip
reg.handler = interaction.Handler
r.clicks[id] = reg
case Scroll:
reg, ok := r.scrolls[id]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
r.scrolls[id] = reg
}
// Clip scroll gesture to element region
scrollClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
}.Push(gtx.Ops)
reg.scroll.Add(gtx.Ops)
scrollClip.Pop()
reg.handler = interaction.Handler
r.scrolls[id] = reg
}
}
// CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source) []InputEvent {
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent
for _, reg := range r.clicks {
evt, ok := reg.click.Update(q)
@ -136,9 +163,34 @@ func (r *Renderer) CheckGestures(q input.Source) []InputEvent {
})
}
}
for _, reg := range r.scrolls {
// gesture.Scroll.Update returns scroll delta in pixels.
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
// With Min==Max==0, clampSplit consumes zero scroll.
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30})
if delta != 0 {
events = append(events, InputEvent{
Handler: reg.handler,
Data: delta,
})
}
}
return events
}
// DisplayLineCount returns the number of display lines from the last
// drawWrappedText call. Used by the main loop to report back to logic.
func (r *Renderer) DisplayLineCount() int {
return r.displayLineCount
}
// LastLineY returns the last line baseline offset from the text origin, in Dp.
// Used by logic to compute max scroll without off-by-one errors.
func (r *Renderer) LastLineY() Dp {
return r.lastLineY
}
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
// Register interactions before drawing
if interactive, ok := e.(Interactive); ok {
@ -272,6 +324,71 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
t.Pop()
}
// drawWrappedText shapes text once with word wrap and draws display lines inline.
// One LayoutString call — no double-shaping. The shaper handles word boundary
// detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp) {
if str == "" {
return
}
// Fixed line height based on font size, not glyph metrics
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: int(r.toPx(wrapWidth)),
MaxLines: 0, // unlimited — wrap at MaxWidth
LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
LineHeightScale: 1.0, // use LineHeight directly, don't scale
WrapPolicy: text.WrapHeuristically,
}
r.shp.LayoutString(params, str)
// Clip to TextField region so text doesn't spill into status/bottom bars
textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
// Y position: region top minus scroll offset.
// The shaper's first.Y handles line spacing — each line's first.Y is
// ascent + lineHeight × lineIndex. drawLine adds first.Y to y,
// so passing the same y for all lines gives correct baseline spacing.
y := reg.Y - scrollOffset
col := Color{R: 0, G: 0, B: 0, A: 255} // black text
m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph
line := glyphs[:0]
lineCount := 0
var lastGlyphY float32 // shaper Y value of last glyph drawn
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
r.drawLine(gtx, line, reg.X, y, col)
lastGlyphY = float32(line[len(line)-1].Y)
line = line[:0]
if g.Flags&text.FlagLineBreak != 0 {
lineCount++
}
}
}
if len(line) > 0 {
r.drawLine(gtx, line, reg.X, y, col)
lastGlyphY = float32(line[len(line)-1].Y)
lineCount++
}
call := m.Stop()
call.Add(gtx.Ops)
textClip.Pop()
r.displayLineCount = lineCount
r.lastLineY = Dp(lastGlyphY / r.scale.Scale()) // pixels → Dp
}
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
if img == nil {
return