264 lines
7.0 KiB
Go
264 lines
7.0 KiB
Go
package ui
|
|
|
|
import (
|
|
"image"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/text"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget/material"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
// Renderer consumes a slice of elements and draws them.
|
|
type Renderer struct {
|
|
theme Theme
|
|
shp *text.Shaper
|
|
}
|
|
|
|
// New creates a new Renderer.
|
|
func New(th Theme, shp *text.Shaper) *Renderer {
|
|
return &Renderer{theme: th, shp: shp}
|
|
}
|
|
|
|
// Draw iterates elements and draws each in slice order (back-to-front).
|
|
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
|
|
for _, e := range elems {
|
|
if !e.Visible() {
|
|
continue
|
|
}
|
|
switch v := e.(type) {
|
|
case Label:
|
|
r.drawLabel(gtx, v)
|
|
case ListView:
|
|
r.drawListView(gtx, v)
|
|
case StatusBar:
|
|
r.drawStatusBar(gtx, v)
|
|
case BottomBar:
|
|
r.drawBottomBar(gtx, v)
|
|
case Button:
|
|
r.drawButton(gtx, v)
|
|
default:
|
|
// unknown element type, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
|
|
reg := l.Region()
|
|
th := material.NewTheme()
|
|
th.Shaper = r.shp
|
|
size := l.FontSize
|
|
if size == 0 {
|
|
size = r.theme.FontSize
|
|
}
|
|
|
|
// Position label at region origin
|
|
gtx.Constraints.Min.X = int(reg.X)
|
|
gtx.Constraints.Min.Y = int(reg.Y)
|
|
material.Label(th, size, l.Text).Layout(gtx)
|
|
}
|
|
|
|
func (r *Renderer) drawListView(gtx layout.Context, lv ListView) {
|
|
reg := lv.Region()
|
|
th := material.NewTheme()
|
|
th.Shaper = r.shp
|
|
|
|
// Position list view at region origin
|
|
gtx.Constraints.Min.X = int(reg.X)
|
|
gtx.Constraints.Min.Y = int(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) {
|
|
reg := sb.Region()
|
|
th := material.NewTheme()
|
|
th.Shaper = r.shp
|
|
|
|
// Clip to status bar region
|
|
c := clip.Rect{
|
|
Min: image.Point{X: int(reg.X), Y: int(reg.Y)},
|
|
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)},
|
|
}.Push(gtx.Ops)
|
|
|
|
// Line 1: Filename (truncated with ellipsis if needed)
|
|
filenameLineY := reg.Y
|
|
filenameLineH := unit.Dp(24)
|
|
|
|
// Truncate filename only if it doesn't fit
|
|
displayFilename := sb.Filename
|
|
availableWidth := int(reg.W - unit.Dp(8) - unit.Dp(40)) // left margin + right icons in DP
|
|
ellipsisWidths := charWidths(gtx, th.Shaper, "...", r.theme.FontSize)
|
|
ellipsisWidth := ellipsisWidths[len(ellipsisWidths)-1]
|
|
// Measure how wide "..." is, then find how many chars fit in availableWidth - ellipsisWidth
|
|
spaceForText := availableWidth - ellipsisWidth
|
|
widths := charWidths(gtx, th.Shaper, displayFilename, r.theme.FontSize)
|
|
if len(widths) > 0 && widths[len(widths)-1] > spaceForText {
|
|
// Find the largest index where the width fits
|
|
for i, w := range widths {
|
|
if w <= spaceForText {
|
|
if i+1 < len(displayFilename) {
|
|
displayFilename = displayFilename[:i+1] + "..."
|
|
} else {
|
|
displayFilename = displayFilename[:i] + "..."
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Position filename at (reg.X + 8, filenameLineY)
|
|
call1 := op.Offset(image.Point{X: int(reg.X + unit.Dp(8)), Y: int(filenameLineY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, displayFilename).Layout(gtx)
|
|
call1.Pop()
|
|
|
|
// Line 2: Icons
|
|
iconsLineY := filenameLineY + filenameLineH
|
|
|
|
// Left side: Cut, Copy, Paste icons
|
|
leftX := reg.X + unit.Dp(8)
|
|
iconY := iconsLineY
|
|
|
|
if sb.CutCopy {
|
|
call := op.Offset(image.Point{X: int(leftX), Y: int(iconY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "✂").Layout(gtx)
|
|
call.Pop()
|
|
leftX += unit.Dp(36)
|
|
}
|
|
if sb.Copy {
|
|
call := op.Offset(image.Point{X: int(leftX), Y: int(iconY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "⎘").Layout(gtx)
|
|
call.Pop()
|
|
leftX += unit.Dp(36)
|
|
}
|
|
if sb.Paste {
|
|
call := op.Offset(image.Point{X: int(leftX), Y: int(iconY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "⎘").Layout(gtx)
|
|
call.Pop()
|
|
leftX += unit.Dp(36)
|
|
}
|
|
|
|
// Right side: Conflict, Search icons
|
|
rightX := reg.X + reg.W - unit.Dp(8)
|
|
if sb.Search {
|
|
rightX -= unit.Dp(36)
|
|
call := op.Offset(image.Point{X: int(rightX), Y: int(iconY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "🔍").Layout(gtx)
|
|
call.Pop()
|
|
}
|
|
if sb.ConflictIcon {
|
|
rightX -= unit.Dp(36)
|
|
call := op.Offset(image.Point{X: int(rightX), Y: int(iconY)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "⚠").Layout(gtx)
|
|
call.Pop()
|
|
}
|
|
|
|
c.Pop()
|
|
}
|
|
|
|
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar) {
|
|
reg := bb.Region()
|
|
th := material.NewTheme()
|
|
th.Shaper = r.shp
|
|
|
|
// Left: Cursor position
|
|
cursorX := reg.X + unit.Dp(8)
|
|
call1 := op.Offset(image.Point{X: int(cursorX), Y: int(reg.Y)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, bb.CursorPos).Layout(gtx)
|
|
call1.Pop()
|
|
|
|
// Center: Byte position
|
|
byteX := reg.X + reg.W/2 - unit.Dp(60) // approximate center
|
|
call2 := op.Offset(image.Point{X: int(byteX), Y: int(reg.Y)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, bb.BytePos).Layout(gtx)
|
|
call2.Pop()
|
|
|
|
// Right: Word wrap button
|
|
wordWrapX := reg.X + reg.W - unit.Dp(80)
|
|
call3 := op.Offset(image.Point{X: int(wordWrapX), Y: int(reg.Y)}).Push(gtx.Ops)
|
|
material.Label(th, r.theme.FontSize, "W:"+boolToString(bb.WordWrap)).Layout(gtx)
|
|
call3.Pop()
|
|
}
|
|
|
|
func (r *Renderer) drawButton(gtx layout.Context, btn Button) {
|
|
reg := btn.Region()
|
|
th := material.NewTheme()
|
|
th.Shaper = r.shp
|
|
|
|
// Position button at region origin
|
|
gtx.Constraints.Min.X = int(reg.X)
|
|
gtx.Constraints.Min.Y = int(reg.Y)
|
|
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx)
|
|
}
|
|
|
|
func (r *Renderer) drawIconButton(gtx layout.Context, th *material.Theme, icon string, x, y unit.Dp) {
|
|
// Position icon button at (x, y)
|
|
gtx.Constraints.Min.X = int(x)
|
|
gtx.Constraints.Min.Y = int(y)
|
|
material.Label(th, r.theme.FontSize, icon).Layout(gtx)
|
|
}
|
|
|
|
func boolToString(b bool) string {
|
|
if b {
|
|
return "On"
|
|
}
|
|
return "Off"
|
|
}
|
|
|
|
// textWidth measures the total width of a string using the shaper.
|
|
func textWidth(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) int {
|
|
pixelsPerDp := gtx.Dp(unit.Dp(1))
|
|
shp.LayoutString(text.Parameters{PxPerEm: fixed.Int26_6(1024 * pixelsPerDp)}, str)
|
|
var minX, maxX fixed.Int26_6
|
|
first := true
|
|
for {
|
|
g, ok := shp.NextGlyph()
|
|
if !ok {
|
|
break
|
|
}
|
|
if first {
|
|
minX = g.X
|
|
maxX = g.X + g.Advance
|
|
first = false
|
|
} else {
|
|
if g.X < minX {
|
|
minX = g.X
|
|
}
|
|
if g.X+g.Advance > maxX {
|
|
maxX = g.X + g.Advance
|
|
}
|
|
}
|
|
}
|
|
return int(maxX - minX)
|
|
}
|
|
|
|
// charWidths returns the cumulative width after each rune in str.
|
|
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
|
|
pixelsPerDp := gtx.Dp(unit.Dp(1))
|
|
shp.LayoutString(text.Parameters{PxPerEm: fixed.Int26_6(1024 * pixelsPerDp)}, str)
|
|
var runeAdvances []fixed.Int26_6
|
|
var runeIdx int
|
|
for {
|
|
g, ok := shp.NextGlyph()
|
|
if !ok {
|
|
break
|
|
}
|
|
if runeIdx < len(str) {
|
|
runeAdvances = append(runeAdvances, g.Advance)
|
|
runeIdx++
|
|
}
|
|
}
|
|
// Accumulate advances and convert from Int26_6 to int (divide by 64)
|
|
var widths []int
|
|
var cumWidth fixed.Int26_6
|
|
for _, adv := range runeAdvances {
|
|
cumWidth += adv
|
|
widths = append(widths, int(cumWidth>>6))
|
|
}
|
|
return widths
|
|
}
|