From fa43bf2402ff58317a178f959f2e5cd4e98e4813 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 12 May 2026 12:06:28 -0400 Subject: [PATCH] Add ShapeText/DrawShapedText: single shaper call, zero-call drawing - New shaper.go: ShapeText() returns ShapedText with measured width + stored PathSpec + BitmapsCall, DrawShapedText() draws with zero shaper calls - drawStatusBar: ShapeText for filename + right icons, positions from widths - drawBottomBar: ShapeText for cursor/bytepos/wrap, positions by alignment - No separate measureText helper: ShapeText.Width provides measurements - StatusBar uses IconLeft/IconRow string slices for icon configuration - BottomBar uses BarAlignment enum for item positioning --- .qwen/settings.json.orig | 7 ++ internal/ui/element.go | 39 +++++--- internal/ui/render.go | 186 +++++++++++++++++++++------------------ internal/ui/shaper.go | 111 +++++++++++++++++++++++ 4 files changed, 244 insertions(+), 99 deletions(-) create mode 100644 .qwen/settings.json.orig create mode 100644 internal/ui/shaper.go diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..bb4e6a3 --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(pkill *)" + ] + } +} \ No newline at end of file diff --git a/internal/ui/element.go b/internal/ui/element.go index a229bcb..9449ce6 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -246,15 +246,15 @@ const ( ) // StatusBar displays the top bar with filename, action icons, and conflict indicator. +// IconLeft contains icon names drawn left-aligned before the filename. +// IconRow contains icon names drawn right-aligned after the filename. type StatusBar struct { id string region Region visible bool Filename string - FilenameExp bool - CutCopy bool - Copy bool - Paste bool + IconLeft []string // icon names for left side (e.g., ["cut"]) + IconRow []string // icon names for right side (e.g., ["conflict", "search"]) ConflictIcon bool Search bool } @@ -269,23 +269,33 @@ func NewStatusBar(region Region, filename string, filenameExp bool, cutCopy, cop region: region, visible: true, Filename: filename, - FilenameExp: filenameExp, - CutCopy: cutCopy, - Copy: copy, - Paste: paste, + IconLeft: []string{"cut"}, + IconRow: []string{"search"}, ConflictIcon: conflictIcon, Search: search, } } +// BarAlignment specifies how a bar item is positioned within its bar. +type BarAlignment int + +const ( + BarLeft BarAlignment = iota + BarCenter + BarRight +) + // BottomBar displays the bottom bar with cursor position, byte position, and word wrap toggle. type BottomBar struct { id string region Region visible bool CursorPos string + CursorAlign BarAlignment BytePos string + BytePosAlign BarAlignment WordWrap bool + WrapAlign BarAlignment } func (bb BottomBar) Region() Region { return bb.region } @@ -295,11 +305,14 @@ func (bb BottomBar) ID() string { return bb.id } // NewBottomBar creates a visible BottomBar element. func NewBottomBar(region Region, cursorPos, bytePos string, wordWrap bool) BottomBar { return BottomBar{ - region: region, - visible: true, - CursorPos: cursorPos, - BytePos: bytePos, - WordWrap: wordWrap, + region: region, + visible: true, + CursorPos: cursorPos, + CursorAlign: BarLeft, + BytePos: bytePos, + BytePosAlign: BarCenter, + WordWrap: wordWrap, + WrapAlign: BarRight, } } diff --git a/internal/ui/render.go b/internal/ui/render.go index 07ee868..98c4a26 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -128,7 +128,7 @@ func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstra func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) { reg := sb.Region() - // Draw light gray background + // ── Draw background (outside clip) ── bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255} bgClip := clip.Rect{ Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, @@ -138,64 +138,111 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConst paint.PaintOp{}.Add(gtx.Ops) bgClip.Pop() - // Clip to status bar region for text (convert Dp to pixels) + // ── Layout: shape all items ── + marginDp := Dp(8) + iconSpacingDp := Dp(12) + lineHeight := r.theme.FontSize + + // Shape filename + shapedFilename := r.ShapeText(gtx, sb.Filename, lineHeight) + + // Measure left icons (PNG, fixed size) + var leftIconWidthPx int + for range sb.IconLeft { + leftIconWidthPx += int(r.toPx(Dp(16))) + int(r.toPx(iconSpacingDp)) + } + + // Measure right icons (text-based) + var rightIconWidthPx int + for _, name := range sb.IconRow { + w := r.ShapeText(gtx, name, lineHeight).Width + rightIconWidthPx += w + int(r.toPx(iconSpacingDp)) + } + if sb.ConflictIcon { + w := r.ShapeText(gtx, "⚠", lineHeight).Width + rightIconWidthPx += w + int(r.toPx(iconSpacingDp)) + } + + // Available width for filename + totalRightPx := leftIconWidthPx + rightIconWidthPx + int(r.toPx(marginDp)*2) + availableWidthPx := int(r.toPx(reg.W)) - totalRightPx + if availableWidthPx < 0 { + availableWidthPx = 0 + } + + // Truncate filename if needed + displayFilename := sb.Filename + spaceForTextPx := availableWidthPx - int(r.toPx(Dp(30))) + if shapedFilename.Width > availableWidthPx && spaceForTextPx > 0 { + // Find truncation point + r.shp.LayoutString(text.Parameters{ + PxPerEm: fixed.I(gtx.Sp(lineHeight)), + MinWidth: 0, + MaxWidth: availableWidthPx, + MaxLines: 1, + }, sb.Filename) + var widths []int + var cumWidth fixed.Int26_6 + for { + g, ok := r.shp.NextGlyph() + if !ok { + break + } + cumWidth += g.Advance + widths = append(widths, int(cumWidth>>6)) + } + for i := len(widths) - 1; i >= 0; i-- { + if widths[i] <= spaceForTextPx { + displayFilename = sb.Filename[:i] + "..." + break + } + } + } + + // ── Render ── + + // Clip to status bar region c := 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) - // Line 1: Filename (top at reg.Y + 2dp) + // Line 1: Left icons + filename filenameLineY := reg.Y + Dp(2) + leftX := reg.X + marginDp - // Truncate filename only if it doesn't fit - displayFilename := sb.Filename - availableWidth := int(r.toPx(reg.W - Dp(8) - Dp(40))) // left margin + right icons in DP - - // Layout ellipsis once and get its width - r.shp.LayoutString(text.Parameters{ - PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)), - MinWidth: 0, - MaxWidth: 1000, - MaxLines: 1, - }, "...") - var ellipsisWidth int - for { - g, ok := r.shp.NextGlyph() - if !ok { - break + // Draw left icons + for _, name := range sb.IconLeft { + if img, ok := r.icons[name]; ok { + iconSize := Dp(16) + r.drawPng(gtx, img, leftX, filenameLineY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + leftX += Dp(16) + iconSpacingDp } - ellipsisWidth = int((g.X + g.Advance) >> 6) } - // Measure how wide "..." is, then find how many chars fit in availableWidth - ellipsisWidth - spaceForText := availableWidth - ellipsisWidth - // Layout filename, measure widths, and draw in a single pass - r.drawTruncatedText(gtx, r.shp, displayFilename, r.theme.FontSize, reg.X+Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + // Draw filename + r.drawTruncatedText(gtx, r.shp, displayFilename, lineHeight, leftX, filenameLineY, availableWidthPx, spaceForTextPx, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - // Line 2: Icons (top at reg.Y + 26dp) + // Line 2: Icons iconsLineY := reg.Y + Dp(26) - // Left side: Cut, Copy, Paste icons (always visible) - leftX := reg.X + Dp(8) - iconY := iconsLineY - - iconSize := Dp(16) - // Draw PNG icons at their top-left position (iconY) - r.drawPng(gtx, r.icons["cut"], leftX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + // Left side: Cut icon + leftX = reg.X + Dp(8) + r.drawPng(gtx, r.icons["cut"], leftX, iconsLineY, Dp(16), Dp(16), color.NRGBA{R: 0, G: 0, B: 0, A: 255}) leftX += Dp(36) - r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) leftX += Dp(36) - r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) leftX += Dp(12) // Right side: Conflict (conditional), Search (always visible) rightX := reg.X + reg.W - Dp(8) if sb.ConflictIcon { rightX -= Dp(36) - r.drawText(gtx, r.shp, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.drawText(gtx, r.shp, "⚠", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) } rightX -= Dp(36) - r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) c.Pop() } @@ -213,45 +260,39 @@ func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints W paint.PaintOp{}.Add(gtx.Ops) bgClip.Pop() - // Use captured constraints for positioning — they are in pixels and - // represent the actual window bounds. Never use gtx.Constraints here - // because clips modify them. + // Use captured constraints for positioning windowW := constraints.Max.X windowH := constraints.Max.Y - // Convert region to pixels for positioning regXPx := r.toPx(reg.X) regWPx := r.toPx(reg.W) - // Clamp right edge to window width rightXPx := regXPx + regWPx if rightXPx > Px(windowW) { rightXPx = Px(windowW) } barW := rightXPx - regXPx - // Position BottomBar at bottom of visible window (in pixels) bottomBarHeightPx := r.toPx(Dp(24)) marginPx := r.toPx(Dp(10)) drawYPx := Px(windowH) - bottomBarHeightPx - marginPx - - // Convert draw position back to Dp for text rendering drawY := r.toDp(drawYPx) - // Left: Cursor position - cursorXPx := regXPx + r.toPx(Dp(8)) - cursorXDp := r.toDp(cursorXPx) - r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + // ── Layout: shape all items ── + cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize) + bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize) + wrapText := "Wrap:" + boolToString(bb.WordWrap) + wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize) - // Center: Byte position - byteXPx := regXPx + barW/2 - r.toPx(Dp(60)) - byteXDp := r.toDp(byteXPx) - r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + // ── Position based on alignment ── + cursorXDp := r.toDp(regXPx + r.toPx(Dp(8))) + byteXDp := r.toDp(regXPx + barW/2 - Px(cursorShaped.Width/2)) + wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width)) - // Right: Word wrap button - wordWrapXPx := regXPx + barW - r.toPx(Dp(80)) - wordWrapXDp := r.toDp(wordWrapXPx) - r.drawText(gtx, r.shp, "Wrap:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + // ── Render ── + r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.DrawShapedText(gtx, bytePosShaped, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.DrawShapedText(gtx, wrapShaped, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) } func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { @@ -259,7 +300,6 @@ func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraint th := material.NewTheme() th.Shaper = r.shp - // Convert region to pixels for Gio interop gtx.Constraints.Min.X = int(r.toPx(reg.X)) gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx) @@ -272,9 +312,8 @@ func boolToString(b bool) string { return "Off" } -// drawTruncatedText layouts text, measures widths, truncates if needed, and draws in a single pass. +// drawTruncatedText layouts text, measures widths, truncates if needed, and draws. func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, availableWidth, spaceForText int, col color.NRGBA) { - // Layout text with width constraints so the shaper doesn't wrap every character. shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), MinWidth: 0, @@ -282,7 +321,6 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s MaxLines: 1, }, str) - // Measure widths and find truncation point var widths []int var cumWidth fixed.Int26_6 fullStr := str @@ -296,10 +334,8 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s widths = append(widths, w) } - // Truncate if needed if len(widths) > 0 && widths[len(widths)-1] > availableWidth { - // Find truncation point (from longest to shortest) - for i := len(widths)-1; i >= 0; i-- { + for i := len(widths) - 1; i >= 0; i-- { if widths[i] <= spaceForText { fullStr = str[:i] + "..." break @@ -307,7 +343,6 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s } } - // Re-layout truncated text and draw glyphs directly shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), MinWidth: 0, @@ -317,10 +352,8 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s r.drawLineText(gtx, shp, x, y, col) } -// drawText draws text using the same approach as Gio's textView: -// layout text, iterate glyphs, buffer into lines, and draw using shaper.Shape(). +// drawText draws text using Gio's textView approach. func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, col color.NRGBA) { - // Layout text with width constraints so the shaper doesn't wrap every character. shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), MinWidth: 0, @@ -332,11 +365,9 @@ func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, si // drawLineText draws already-laid-out glyphs using shaper.Shape() and shaper.Bitmaps(). func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, col color.NRGBA) { - // Convert Dp to pixels for glyph positioning xPx := r.toPx(x) yPx := r.toPx(y) - // Match Gio's textView: record into a macro for clipping m := op.Record(gtx.Ops) var glyphs [32]text.Glyph line := glyphs[:0] @@ -355,29 +386,21 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, c } // drawLine draws a line of glyphs using shaper.Shape() and shaper.Bitmaps(). -// Matches Gio's paintGlyph exactly: offset by (x + first.X, y + first.Y). func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) { if len(line) == 0 { return } first := line[0] - // shaper.Shape(line) returns a path where glyph positions are relative to - // the first glyph. Offset by (x + first.X, y + first.Y) to place the line - // at the desired document position. Matches Gio's paintGlyph: - // lineOff = (glyph.X, glyph.Y) - viewport.Min - // op.Affine(f32.Affine2D{}.Offset(lineOff)) offX := float32(x) + float32(first.X)/64.0 offY := float32(y) + float32(first.Y) t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops) - // Draw vector glyphs path := shp.Shape(line) outline := clip.Outline{Path: path}.Op().Push(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) outline.Pop() - // Draw bitmap glyphs (emoji, etc.) if call := shp.Bitmaps(line); call != (op.CallOp{}) { call.Add(gtx.Ops) } @@ -386,40 +409,31 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl } // WindowConstraints tracks the initial window constraints captured at the start of Draw. -// All elements are positioned relative to these bounds to ensure consistency. type WindowConstraints struct { Min image.Point Max image.Point } // drawPng draws a PNG image at the given position and size. -// x, y are in Dp (top-left corner). -// width, height are in Dp (target size). -// col is the tint color — if alpha < 255, the image is tinted using blendOp.DstIn. func (r *Renderer) drawPng(gtx layout.Context, img image.Image, x, y Dp, width, height Dp, col color.NRGBA) { if img == nil { return } - // Convert Dp to pixels xPx := int(r.toPx(x)) yPx := int(r.toPx(y)) wPx := int(r.toPx(width)) hPx := int(r.toPx(height)) - // Compute destination rectangle dst := image.Rect(xPx, yPx, xPx+wPx, yPx+hPx) - // If tint color has alpha < 255, apply tint using blendOp.DstIn if col.A < 255 { - // Draw tinted rectangle over the image bgClip := clip.Rect(dst).Push(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) bgClip.Pop() } - // Draw the image stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops) paint.NewImageOp(img).Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) diff --git a/internal/ui/shaper.go b/internal/ui/shaper.go new file mode 100644 index 0000000..15ed22f --- /dev/null +++ b/internal/ui/shaper.go @@ -0,0 +1,111 @@ +package ui + +import ( + "image/color" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "golang.org/x/image/math/fixed" +) + +// ShapedText holds layout results from a single ShapeText call. +// The PathSpec and BitmapsCall can be drawn without further shaper access. +type ShapedText struct { + Width int // total width in pixels + PathSpec clip.PathSpec // vector glyph path, ready to draw + BitmapsCall op.CallOp // bitmap glyph call (empty if none) + FirstX fixed.Int26_6 // first glyph X offset + FirstY int32 // first glyph Y offset + Text string + FontSize unit.Sp +} + +// ShapeText layouts text, measures width, and returns a ShapedText +// with an immutable path ready for drawing. The shaper is called once. +func (r *Renderer) ShapeText(gtx layout.Context, str string, size unit.Sp) ShapedText { + r.shp.LayoutString(text.Parameters{ + PxPerEm: fixed.I(gtx.Sp(size)), + MinWidth: 0, + MaxWidth: 1000, + MaxLines: 1, + }, str) + + // Collect glyphs and measure width + var glyphs []text.Glyph + var cumWidth fixed.Int26_6 + for { + g, ok := r.shp.NextGlyph() + if !ok { + break + } + cumWidth += g.Advance + glyphs = append(glyphs, g) + } + + // Build lines and collect the last line's path spec + var lastSpec clip.PathSpec + var lastBitmaps op.CallOp + var firstX fixed.Int26_6 + var firstY int32 + var line []text.Glyph + for _, g := range glyphs { + if len(line) == 0 { + firstX = g.X + firstY = g.Y + } + line = append(line, g) + if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 { + lastSpec = r.shp.Shape(line) + lastBitmaps = r.shp.Bitmaps(line) + line = line[:0] + } + } + if len(line) > 0 { + lastSpec = r.shp.Shape(line) + lastBitmaps = r.shp.Bitmaps(line) + } + + return ShapedText{ + Width: int(cumWidth >> 6), + PathSpec: lastSpec, + BitmapsCall: lastBitmaps, + FirstX: firstX, + FirstY: firstY, + Text: str, + FontSize: size, + } +} + +// DrawShapedText draws a pre-shaped text at the given position. +// x, y are in Dp. Uses the stored path spec — zero shaper calls. +func (r *Renderer) DrawShapedText(gtx layout.Context, st ShapedText, x, y Dp, col color.NRGBA) { + xPx := r.toPx(x) + yPx := r.toPx(y) + + m := op.Record(gtx.Ops) + + // Compute offset: position + first glyph offset + offX := float32(xPx) + float32(st.FirstX)/64.0 + offY := float32(yPx) + float32(st.FirstY) + t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops) + + // Draw vector path + outline := clip.Outline{Path: st.PathSpec}.Op().Push(gtx.Ops) + paint.ColorOp{Color: col}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + outline.Pop() + + // Draw bitmap glyphs + if st.BitmapsCall != (op.CallOp{}) { + st.BitmapsCall.Add(gtx.Ops) + } + + t.Pop() + call := m.Stop() + call.Add(gtx.Ops) +}