package ui import ( "bytes" "embed" "image" "image/color" _ "image/png" "strings" "gioui.org/f32" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "gioui.org/widget/material" "golang.org/x/image/math/fixed" ) //go:embed icons/*.png var iconFS embed.FS // StateReader provides access to scale and window dimensions from State. // Implemented by editor.State to avoid import cycles. type StateReader interface { Scale() float32 ScreenWidth() Dp ScreenHeight() Dp } // Renderer consumes a slice of elements and draws them. type Renderer struct { theme Theme shp *text.Shaper state StateReader // read-only access to State for scale/window size icons map[string]image.Image // loaded PNG icons } // New creates a new Renderer. func New(th Theme, shp *text.Shaper) *Renderer { r := &Renderer{theme: th, shp: shp, icons: make(map[string]image.Image)} r.loadIcons() return r } // loadIcons loads all PNG icons from the embedded filesystem. func (r *Renderer) loadIcons() { files := []string{"cut.png", "copy.png", "paste.png", "search.png", "conflict.png", "wrapOn.png", "wrapOff.png"} for _, filename := range files { data, err := iconFS.ReadFile(filename) if err != nil { continue // skip missing icons } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { continue } name := strings.TrimSuffix(filename, ".png") r.icons[name] = img } } // SetState sets the State reference for the Renderer. // The Renderer reads scale and window size from State directly. func (r *Renderer) SetState(state StateReader) { r.state = state } // toPx converts Dp to physical pixels using the scale from State. func (r *Renderer) toPx(dp Dp) Px { return ToPx(dp, r.state.Scale()) } // toDp converts physical pixels to Dp using the scale from State. func (r *Renderer) toDp(px Px) Dp { return ToDp(px, r.state.Scale()) } // Draw iterates elements and draws each in slice order (back-to-front). // All scale and window size values come from State, never from gtx. func (r *Renderer) Draw(gtx layout.Context, elems []Element) { // Use window dimensions from State — convert DP to pixels using State.Scale state := r.state windowWPx := int(r.toPx(state.ScreenWidth())) windowHPx := int(r.toPx(state.ScreenHeight())) initialConstraints := WindowConstraints{ Min: image.Point{X: 0, Y: 0}, Max: image.Point{X: windowWPx, Y: windowHPx}, } for _, e := range elems { if !e.Visible() { continue } switch v := e.(type) { case Label: r.drawLabel(gtx, v, initialConstraints) case ListView: r.drawListView(gtx, v, initialConstraints) case StatusBar: r.drawStatusBar(gtx, v, initialConstraints) case BottomBar: r.drawBottomBar(gtx, v, initialConstraints) case Button: r.drawButton(gtx, v, initialConstraints) default: // unknown element type, skip } } } func (r *Renderer) drawLabel(gtx layout.Context, l Label, _ WindowConstraints) { reg := l.Region() th := material.NewTheme() th.Shaper = r.shp size := l.FontSize if size == 0 { size = r.theme.FontSize } // 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, size, l.Text).Layout(gtx) } func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstraints) { reg := lv.Region() 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)) for _, item := range lv.Items { material.Label(th, r.theme.FontSize, item.Text).Layout(gtx) } } func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) { reg := sb.Region() th := material.NewTheme() th.Shaper = r.shp // Draw light gray background 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))}, Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))}, }.Push(gtx.Ops) paint.ColorOp{Color: bgColor}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) bgClip.Pop() // Clip to status bar region for text (convert Dp to pixels) 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 (truncated with ellipsis if needed) filenameLineY := reg.Y // 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 th.Shaper.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)), MinWidth: 0, MaxWidth: 1000, MaxLines: 1, }, "...") var ellipsisWidth int for { g, ok := th.Shaper.NextGlyph() if !ok { break } 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, th.Shaper, displayFilename, r.theme.FontSize, reg.X+Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) // Line 2: Icons — positioned 20DP below filename baseline. iconsLineY := filenameLineY + Dp(20) // Left side: Cut, Copy, Paste icons (always visible) leftX := reg.X + Dp(8) iconY := iconsLineY iconSize := Dp(16) // 16dp icons r.drawPng(gtx, r.icons["cut"], leftX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) leftX += Dp(28) r.drawPng(gtx, r.icons["copy"], leftX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) leftX += Dp(28) r.drawPng(gtx, r.icons["paste"], leftX, iconY, iconSize, iconSize, 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(28) r.drawPng(gtx, r.icons["conflict"], rightX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) } rightX -= Dp(28) r.drawPng(gtx, r.icons["search"], rightX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) c.Pop() } func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) { reg := bb.Region() // Draw light gray background 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))}, Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))}, }.Push(gtx.Ops) paint.ColorOp{Color: bgColor}.Add(gtx.Ops) 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. 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}) // 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}) // 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}) } func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { reg := btn.Region() 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) } func boolToString(b bool) string { if b { return "On" } return "Off" } // drawTruncatedText layouts text, measures widths, truncates if needed, and draws in a single pass. 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, MaxWidth: availableWidth, MaxLines: 1, }, str) // Measure widths and find truncation point var widths []int var cumWidth fixed.Int26_6 fullStr := str for { g, ok := shp.NextGlyph() if !ok { break } cumWidth += g.Advance w := int(cumWidth >> 6) 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-- { if widths[i] <= spaceForText { fullStr = str[:i] + "..." break } } } // Re-layout truncated text and draw glyphs directly shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), MinWidth: 0, MaxWidth: availableWidth, MaxLines: 1, }, fullStr) 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(). 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, MaxWidth: 1000, MaxLines: 1, }, str) r.drawLineText(gtx, shp, x, y, col) } // 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] for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() { line = append(line, g) if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 { r.drawLine(gtx, shp, line, xPx, yPx, col) line = line[:0] } } if len(line) > 0 { r.drawLine(gtx, shp, line, xPx, yPx, col) } call := m.Stop() call.Add(gtx.Ops) } // 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) } t.Pop() } // 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 source rectangle (full image) src := image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy()) _ = src // unused, but kept for clarity // 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 paint.NewImageOp(img).Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) } // 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 }