- Takes image.Image, position (x,y) in Dp, size (width,height) in Dp - Tint color with alpha < 255 applies color overlay using clip.Rect - Converts Dp to pixels using Renderer.toPx() - Uses paint.NewImageOp for image rendering
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package ui
|
|
|
|
import (
|
|
"image"
|
|
"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"
|
|
"gioui.org/widget/material"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// New creates a new Renderer.
|
|
func New(th Theme, shp *text.Shaper) *Renderer {
|
|
return &Renderer{theme: th, shp: shp}
|
|
}
|
|
|
|
// 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
|
|
|
|
r.drawText(gtx, th.Shaper, "✂", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
|
leftX += Dp(36)
|
|
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
|
leftX += Dp(36)
|
|
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, 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, th.Shaper, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
|
}
|
|
rightX -= Dp(36)
|
|
r.drawText(gtx, th.Shaper, "🔍", r.theme.FontSize, rightX, iconY, 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
|
|
}
|