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() mu.Lock()
currentElems := elems currentElems := elems
renderer.Draw(gtx, currentElems) renderer.Draw(gtx, currentElems)
if events := renderer.CheckGestures(e.Source); len(events) > 0 {
logic.InputChan() <- events
}
e.Frame(&ops) e.Frame(&ops)
mu.Unlock() mu.Unlock()
} }

View File

@ -33,13 +33,6 @@ func (e ScaleEvent) apply(s *State) {
s.SetScale(e.Scale) 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. // ResultEvent represents a completed async task result.
type ResultEvent struct { type ResultEvent struct {
// Future: add result fields here // Future: add result fields here
@ -50,18 +43,20 @@ type Logic struct {
state *State state *State
configChan chan ConfigUpdate configChan chan ConfigUpdate
frameChan chan []ui.Element frameChan chan []ui.Element
inputChan chan []InputEvent inputChan chan []ui.InputEvent
resultChan chan ResultEvent resultChan chan ResultEvent
mu sync.Mutex mu sync.Mutex
} }
// NewLogic creates a new Logic instance. // NewLogic creates a new Logic instance.
func NewLogic() *Logic { func NewLogic() *Logic {
state := NewState()
TheState = state
return &Logic{ return &Logic{
state: NewState(), state: state,
configChan: make(chan ConfigUpdate), configChan: make(chan ConfigUpdate),
frameChan: make(chan []ui.Element), frameChan: make(chan []ui.Element),
inputChan: make(chan []InputEvent), inputChan: make(chan []ui.InputEvent),
resultChan: make(chan ResultEvent), resultChan: make(chan ResultEvent),
} }
} }
@ -78,7 +73,7 @@ func (l *Logic) FrameChan() <-chan []ui.Element {
} }
// InputChan returns the input channel for the logic goroutine. // 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 return l.inputChan
} }
@ -87,6 +82,9 @@ func (l *Logic) ResultChan() chan<- ResultEvent {
return l.resultChan 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). // Scale returns the current scale factor (pixels per DP).
func (l *Logic) Scale() float32 { func (l *Logic) Scale() float32 {
return l.state.Scale() return l.state.Scale()
@ -99,7 +97,10 @@ func (l *Logic) Run() {
case update := <-l.configChan: case update := <-l.configChan:
update.apply(l.state) update.apply(l.state)
l.frameChan <- l.state.layout() 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() l.frameChan <- l.state.layout()
case <-l.resultChan: case <-l.resultChan:
l.frameChan <- l.state.layout() l.frameChan <- l.state.layout()

View File

@ -9,6 +9,7 @@ type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32 scale float32
WordWrap bool
Elems []ui.Element Elems []ui.Element
} }
@ -29,12 +30,17 @@ func (s *State) Scale() float32 {
func (s *State) layout() []ui.Element { func (s *State) layout() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), 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 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. // 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) margin := ui.Dp(10)
// --- Top bar: filename on row 1, icons on row 2 --- // --- 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.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []ui.Element{
// Row 1: filename // 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 // 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("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), 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, H: bottomBarHeight,
} }
bottomBarW := bottomBarRegion.W bottomBarW := bottomBarRegion.W
wrapText := "Wrap: Off"
if wordWrap {
wrapText = "Wrap: On"
}
bottomBar := ui.NewContainer( bottomBar := ui.NewContainer(
bottomBarRegion, bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []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("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), ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel("Wrap: On", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd), 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

@ -56,6 +56,7 @@ type Label struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Text string Text string
Align TextAlign Align TextAlign
FontSize unit.Sp // 0 = theme default FontSize unit.Sp // 0 = theme default
@ -65,20 +66,23 @@ type Label struct {
func (l Label) Region() Region { return l.region } func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible } 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) { func (l Label) Draw(gtx layout.Context, r *Renderer) {
col := l.Color col := l.Color
if col == (Color{}) { if col == (Color{}) {
col = Color{R: 0, G: 0, B: 0, A: 255} 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 } func (l Label) ID() string { return l.id }
// NewLabel creates a visible Label element. // 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{ return Label{
id: id,
region: region, region: region,
visible: true, visible: true,
interactions: interactions,
Text: text, Text: text,
FontSize: fontSize, FontSize: fontSize,
Align: align, Align: align,
@ -90,12 +94,14 @@ type Icon struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Name string // icon name, e.g. "cut" Name string // icon name, e.g. "cut"
Size Dp // 0 = default icon size Size Dp // 0 = default icon size
} }
func (i Icon) Region() Region { return i.region } func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible } 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) { func (i Icon) Draw(gtx layout.Context, r *Renderer) {
img := r.icon(i.Name) img := r.icon(i.Name)
if img == nil { if img == nil {
@ -125,6 +131,7 @@ type TextField struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Value string Value string
Placeholder string Placeholder string
Focused bool Focused bool
@ -138,6 +145,7 @@ type TextField struct {
func (tf TextField) Region() Region { return tf.region } func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible } func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id } 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. // Line represents a single line of text in a multiline TextField.
type Line struct { type Line struct {
@ -150,6 +158,7 @@ type ListView struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Items []ListItem Items []ListItem
ScrollOffset int ScrollOffset int
Selected int Selected int
@ -158,6 +167,7 @@ type ListView struct {
func (lv ListView) Region() Region { return lv.region } func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible } func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id } func (lv ListView) ID() string { return lv.id }
func (lv ListView) Interactions() []Interaction { return lv.interactions }
// ListItem is a single entry in a ListView. // ListItem is a single entry in a ListView.
type ListItem struct { type ListItem struct {
@ -171,6 +181,7 @@ type AlphaIndex struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Letters []string Letters []string
ActiveLetter string ActiveLetter string
} }
@ -178,6 +189,7 @@ type AlphaIndex struct {
func (ai AlphaIndex) Region() Region { return ai.region } func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible } func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id } func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
// NewAlphaIndex creates a visible AlphaIndex element. // NewAlphaIndex creates a visible AlphaIndex element.
func NewAlphaIndex(region Region, letters []string) AlphaIndex { func NewAlphaIndex(region Region, letters []string) AlphaIndex {
@ -193,6 +205,7 @@ type Button struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Text string Text string
Enabled bool Enabled bool
Primary bool Primary bool
@ -200,9 +213,10 @@ type Button struct {
func (b Button) Region() Region { return b.region } func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible } 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) { func (b Button) Draw(gtx layout.Context, r *Renderer) {
col := Color{R: 0, G: 0, B: 0, A: 255} 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 } func (b Button) ID() string { return b.id }
@ -222,6 +236,7 @@ type SearchBar struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Query string Query string
Match int Match int
Total int Total int
@ -231,6 +246,7 @@ type SearchBar struct {
func (sb SearchBar) Region() Region { return sb.region } func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible } func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id } func (sb SearchBar) ID() string { return sb.id }
func (sb SearchBar) Interactions() []Interaction { return sb.interactions }
// NewSearchBar creates a visible SearchBar element. // NewSearchBar creates a visible SearchBar element.
func NewSearchBar(region Region, query string, match, total int, forward bool) SearchBar { func NewSearchBar(region Region, query string, match, total int, forward bool) SearchBar {
@ -249,6 +265,7 @@ type Cursor struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Line int Line int
Column int Column int
Blinking bool Blinking bool
@ -258,6 +275,7 @@ type Cursor struct {
func (c Cursor) Region() Region { return c.region } func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible } func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id } func (c Cursor) ID() string { return c.id }
func (c Cursor) Interactions() []Interaction { return c.interactions }
// Selection represents a text selection range. // Selection represents a text selection range.
type Selection struct { type Selection struct {
@ -270,6 +288,7 @@ type MergeHunk struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
HunkNumber int HunkNumber int
TotalHunks int TotalHunks int
LineRange string LineRange string
@ -282,6 +301,7 @@ type MergeHunk struct {
func (mh MergeHunk) Region() Region { return mh.region } func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible } func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id } 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. // HunkResolution represents the resolution state of a merge hunk.
type HunkResolution int type HunkResolution int
@ -298,6 +318,7 @@ type Toast struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
Text string Text string
Timeout int // milliseconds Timeout int // milliseconds
} }
@ -305,6 +326,7 @@ type Toast struct {
func (t Toast) Region() Region { return t.region } func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible } func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id } func (t Toast) ID() string { return t.id }
func (t Toast) Interactions() []Interaction { return t.interactions }
// NewToast creates a visible Toast element. // NewToast creates a visible Toast element.
func NewToast(region Region, text string, timeout int) Toast { func NewToast(region Region, text string, timeout int) Toast {
@ -321,11 +343,13 @@ type Spacer struct {
id string id string
region Region region Region
visible bool visible bool
interactions []Interaction
} }
func (s Spacer) Region() Region { return s.region } func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible } func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id } func (s Spacer) ID() string { return s.id }
func (s Spacer) Interactions() []Interaction { return s.interactions }
// NewSpacer creates a visible Spacer element. // NewSpacer creates a visible Spacer element.
func NewSpacer(height Dp) Spacer { func NewSpacer(height Dp) Spacer {
@ -368,10 +392,21 @@ const (
KeyUp 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 { type InputEvent struct {
ElementID string Handler func(any)
Type InputType
Data any Data any
} }

View File

@ -8,6 +8,8 @@ import (
_ "image/png" _ "image/png"
"gioui.org/f32" "gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/input"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
@ -28,17 +30,30 @@ type ScaleProvider interface {
Scale() float32 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. // Renderer consumes a slice of elements and draws them.
type Renderer struct { type Renderer struct {
theme Theme theme Theme
shp *text.Shaper shp *text.Shaper
scale ScaleProvider scale ScaleProvider
icons map[string]image.Image icons map[string]image.Image
clicks map[string]clickReg
} }
// New creates a new Renderer. // New creates a new Renderer.
func New(th Theme, shp *text.Shaper, scale ScaleProvider) *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() r.loadIcons()
return r return r
} }
@ -93,7 +108,45 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
clipRect.Pop() 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) { 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() reg := e.Region()
if container, ok := e.(Container); ok { if container, ok := e.(Container); ok {
@ -125,7 +178,7 @@ func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
bgClip.Pop() 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 == "" { if str == "" {
return return
} }
@ -136,36 +189,40 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
MaxLines: 1, MaxLines: 1,
} }
if align == AlignStart { // Measure text width via glyph iteration (consumes iterator)
// 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) r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6 var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() { for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance 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()) textW := Dp(float32(totalAdvance>>6) / r.scale.Scale())
// Compute aligned X position // Compute aligned X position
var drawX Dp var drawX Dp
switch align { switch align {
case AlignStart:
drawX = reg.X
case AlignCenter: case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2 drawX = reg.X + (reg.W - textW) / 2
case AlignEnd: case AlignEnd:
drawX = reg.X + reg.W - textW 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.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col) r.drawLineText(gtx, drawX, reg.Y, col)
}
textClip.Pop()
} }
// drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety. // drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety.