diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 94a6c18..4826f82 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -33,16 +33,17 @@ func run(w *app.Window) error { shaper := text.NewShaper(text.WithCollection(gofont.Collection())) - // Initial DP size from app.Size() — used before we know the scale factor + // Initial DP size from app.Size() initialDPW := ui.Dp(390) initialDPH := ui.Dp(844) - // Create logic instance with initial DP size and scale=1.0 + // Create logic instance logic := editor.NewLogic(initialDPW, initialDPH) + // Create renderer — reads scale from State via ScaleProvider renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State()) - // Track pixel dimensions from ConfigEvent (for resize handling) + // Track pixel dimensions from ConfigEvent var pixelW, pixelH int // Shared state protected by mutex @@ -54,7 +55,6 @@ func run(w *app.Window) error { // Start frame receiver goroutine go frameReceiver(w, &mu, &elems, logic.FrameChan()) - scale := float32(1.0) // Main event loop for { @@ -62,13 +62,12 @@ func run(w *app.Window) error { case app.DestroyEvent: return e.Err case app.ConfigEvent: - // Store pixel dimensions from ConfigEvent pixelW = e.Config.Size.X pixelH = e.Config.Size.Y - // Convert to DP using current scale + // Convert pixels to DP using current scale + scale := logic.Scale() dpW := ui.ToDp(ui.Px(pixelW), scale) dpH := ui.ToDp(ui.Px(pixelH), scale) - // Send config event to logic goroutine (in DP) logic.ConfigChan() <- editor.ConfigEvent{ Width: dpW, Height: dpH, @@ -76,19 +75,16 @@ func run(w *app.Window) error { case app.FrameEvent: gtx := app.NewContext(&ops, e) - // Get scale from State — this is updated by ScaleEvent + // Update scale if changed newScale := gtx.Metric.PxPerDp - - if newScale != scale { - // Send updated scale to logic goroutine + curScale := logic.Scale() + if newScale != curScale { logic.ConfigChan() <- editor.ScaleEvent{newScale} - scale = newScale } // Acquire mutex, read frame, draw, release mutex mu.Lock() currentElems := elems - renderer.Draw(gtx, currentElems) e.Frame(&ops) mu.Unlock() diff --git a/internal/editor/state.go b/internal/editor/state.go index bb8e8a1..0285b18 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -14,8 +14,6 @@ type State struct { } // NewState creates a new State with initial editor layout. -// screenWidth and screenHeight are in device-independent pixels (Dp). -// scale defaults to 1.0 until updated by ScaleEvent. func NewState(screenWidth, screenHeight ui.Dp) *State { return &State{ ScreenWidth: screenWidth, @@ -36,50 +34,46 @@ func (s *State) Scale() float32 { return s.scale } -// EditorLayout computes regions for the editor page. -// It takes screen dimensions in Dp and returns []Element with regions in Dp. -// This function works exclusively in Dp — no pixel conversions. +// EditorLayout computes the element tree for the editor page. +// It takes screen dimensions in Dp and returns []Element. func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element { - // Margin to avoid clipping on macOS round corners margin := ui.Dp(10) - // Compute StatusBar region - statusBarHeight := ui.Dp(80) + // --- Top container: status bar --- + statusBarHeight := ui.Dp(52) statusBarRegion := ui.Region{ - X: margin, - Y: margin, + X: margin, Y: margin, W: screenWidth - margin*2, H: statusBarHeight, } - // Compute BottomBar region (fixed height, at bottom of screen) + statusBar := ui.NewContainer( + statusBarRegion, + ui.Color{R: 230, G: 230, B: 230, A: 255}, + []ui.Element{ + ui.NewIcon("cut", ui.Region{X: ui.Dp(8), Y: ui.Dp(2), W: ui.Dp(16), H: ui.Dp(16)}, ui.Dp(16)), + ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(32), Y: ui.Dp(2)}), + ui.NewLabel("Ready", 12, ui.Region{X: ui.Dp(8), Y: ui.Dp(28)}), + }, + ) + + // --- Bottom container: bottom bar --- bottomBarHeight := ui.Dp(24) bottomBarY := screenHeight - margin - bottomBarHeight bottomBarRegion := ui.Region{ - X: margin, - Y: bottomBarY, + X: margin, Y: bottomBarY, W: screenWidth - margin*2, H: bottomBarHeight, } - // Create StatusBar with mocked filename - statusBar := ui.NewStatusBar( - statusBarRegion, - "very_long_filename_that_does_not_fit_on_a_single_line_and_it_just_keeps_on_going_and_going_and_going_and_going_and_going.txt", - false, // FilenameExp - true, // CutCopy - false, // Copy - true, // Paste - false, // ConflictIcon - true, // Search - ) - - // Create BottomBar with mocked data - bottomBar := ui.NewBottomBar( + bottomBar := ui.NewContainer( bottomBarRegion, - "Ln 47, Col 12", - "1024 / 50000", - true, // WordWrap + ui.Color{R: 230, G: 230, B: 230, A: 255}, + []ui.Element{ + ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(8), Y: ui.Dp(2)}), + ui.NewLabel("1024 / 50000", 12, ui.Region{X: ui.Dp(150), Y: ui.Dp(2)}), + ui.NewLabel("Wrap: On", 12, ui.Region{X: screenWidth - margin - ui.Dp(60), Y: ui.Dp(2)}), + }, ) return []ui.Element{statusBar, bottomBar} diff --git a/internal/ui/element.go b/internal/ui/element.go index 9449ce6..9f7fe34 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -1,6 +1,10 @@ package ui -import "gioui.org/unit" +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" +) // Region defines a screen area in device-independent pixels (Dp). // All element positions and sizes use Dp for device independence. @@ -10,46 +14,48 @@ type Region struct { } // Element is the base interface for all UI elements. +// Elements know how to draw themselves when given a Renderer. type Element interface { Region() Region Visible() bool + Draw(gtx layout.Context, r *Renderer) } -// TextAlign specifies horizontal text alignment. -type TextAlign int - -const ( - AlignStart TextAlign = iota - AlignCenter - AlignEnd -) - -// InputType specifies the type of user input event. -type InputType int - -const ( - Tap InputType = iota - DoubleTap - LongPress - Scroll - KeyDown - KeyUp -) - -// InputEvent represents a user input event routed to an element. -type InputEvent struct { - ElementID string - Type InputType - Data any +// Container holds child elements and draws them within its bounds. +// Children's regions are screen-space; clipping handles containment. +type Container struct { + id string + region Region + visible bool + background Color + children []Element } -// ConfigEvent represents a window configuration change (resize, orientation). -// Width and Height are in device-independent pixels (Dp). -type ConfigEvent struct { - Width Dp - Height Dp +func (c Container) Region() Region { return c.region } +func (c Container) Visible() bool { return c.visible } +func (c Container) Draw(gtx layout.Context, r *Renderer) { + // Draw background + if c.background != (Color{}) { + r.drawBg(gtx, c.region, c.background) + } + // Draw children + for _, child := range c.children { + child.Draw(gtx, r) + } } +// NewContainer creates a Container with the given region, background, and children. +func NewContainer(region Region, bg Color, children []Element) Container { + return Container{ + region: region, + visible: true, + background: bg, + children: children, + } +} + +// --- Leaf element types (each implements Element and knows how to Draw itself) --- + // Label displays static text. type Label struct { id string @@ -62,9 +68,12 @@ type Label struct { Bold bool } -func (l Label) Region() Region { return l.region } -func (l Label) Visible() bool { return l.visible } -func (l Label) ID() string { return l.id } +func (l Label) Region() Region { return l.region } +func (l Label) Visible() bool { return l.visible } +func (l Label) Draw(gtx layout.Context, r *Renderer) { + r.drawText(gtx, l.Text, l.FontSize, l.region, l.Color) +} +func (l Label) ID() string { return l.id } // NewLabel creates a visible Label element. func NewLabel(text string, fontSize unit.Sp, region Region) Label { @@ -76,6 +85,40 @@ func NewLabel(text string, fontSize unit.Sp, region Region) Label { } } +// Icon displays a named icon image. +type Icon struct { + id string + region Region + visible bool + Name string // icon name, e.g. "cut" + Size Dp // 0 = default icon size +} + +func (i Icon) Region() Region { return i.region } +func (i Icon) Visible() bool { return i.visible } +func (i Icon) Draw(gtx layout.Context, r *Renderer) { + img := r.icon(i.Name) + if img == nil { + return + } + size := i.Size + if size == 0 { + size = Dp(16) + } + r.drawPng(gtx, img, i.region, size, i.Size) +} +func (i Icon) ID() string { return i.id } + +// NewIcon creates a visible Icon element. +func NewIcon(name string, region Region, size Dp) Icon { + return Icon{ + region: region, + visible: true, + Name: name, + Size: size, + } +} + // TextField accepts text input or displays multiline text. type TextField struct { id string @@ -154,9 +197,16 @@ type Button struct { Primary bool } -func (b Button) Region() Region { return b.region } -func (b Button) Visible() bool { return b.visible } -func (b Button) ID() string { return b.id } +func (b Button) Region() Region { return b.region } +func (b Button) Visible() bool { return b.visible } +func (b Button) Draw(gtx layout.Context, r *Renderer) { + th := material.NewTheme() + th.Shaper = r.shp + gtx.Constraints.Min.X = int(r.toPx(b.region.X)) + gtx.Constraints.Min.Y = int(r.toPx(b.region.Y)) + material.Label(th, r.theme.FontSize, b.Text).Layout(gtx) +} +func (b Button) ID() string { return b.id } // NewButton creates a visible Button element. func NewButton(text string, enabled bool, primary bool, region Region) Button { @@ -245,77 +295,6 @@ const ( MergeBoth ) -// 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 - 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 -} - -func (sb StatusBar) Region() Region { return sb.region } -func (sb StatusBar) Visible() bool { return sb.visible } -func (sb StatusBar) ID() string { return sb.id } - -// NewStatusBar creates a visible StatusBar element. -func NewStatusBar(region Region, filename string, filenameExp bool, cutCopy, copy, paste, conflictIcon, search bool) StatusBar { - return StatusBar{ - region: region, - visible: true, - Filename: filename, - 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 } -func (bb BottomBar) Visible() bool { return bb.visible } -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, - CursorAlign: BarLeft, - BytePos: bytePos, - BytePosAlign: BarCenter, - WordWrap: wordWrap, - WrapAlign: BarRight, - } -} - // Toast displays a temporary notification. type Toast struct { id string @@ -358,6 +337,8 @@ func NewSpacer(height Dp) Spacer { } } +// --- Utility types --- + // Color is an RGBA color. type Color struct { R, G, B, A uint8 @@ -367,3 +348,38 @@ type Color struct { type Theme struct { FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes } + +// TextAlign specifies horizontal text alignment. +type TextAlign int + +const ( + AlignStart TextAlign = iota + AlignCenter + AlignEnd +) + +// InputType specifies the type of user input event. +type InputType int + +const ( + Tap InputType = iota + DoubleTap + LongPress + Scroll + KeyDown + KeyUp +) + +// InputEvent represents a user input event routed to an element. +type InputEvent struct { + ElementID string + Type InputType + Data any +} + +// ConfigEvent represents a window configuration change (resize, orientation). +// Width and Height are in device-independent pixels (Dp). +type ConfigEvent struct { + Width Dp + Height Dp +} diff --git a/internal/ui/render.go b/internal/ui/render.go index 9b639c5..b194e3c 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -7,7 +7,6 @@ import ( "image/color" _ "image/png" - "gioui.org/f32" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -15,12 +14,16 @@ import ( "gioui.org/text" "gioui.org/unit" "gioui.org/widget/material" - "golang.org/x/image/math/fixed" ) //go:embed icons/*.png var iconFS embed.FS +// ScaleProvider provides access to the current scale factor. +type ScaleProvider interface { + Scale() float32 +} + // Renderer consumes a slice of elements and draws them. type Renderer struct { theme Theme @@ -29,11 +32,6 @@ type Renderer struct { icons map[string]image.Image } -// ScaleProvider provides access to the current scale factor. -type ScaleProvider interface { - Scale() float32 -} - // New creates a new 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)} @@ -54,6 +52,11 @@ func (r *Renderer) loadIcons() { r.icons["cut"] = img } +// icon returns a loaded icon image by name, or nil if not found. +func (r *Renderer) icon(name string) image.Image { + return r.icons[name] +} + // toPx converts Dp to physical pixels using State's scale. func (r *Renderer) toPx(dp Dp) Px { return ToPx(dp, r.scale.Scale()) @@ -65,374 +68,78 @@ func (r *Renderer) toDp(px Px) Dp { } // Draw iterates elements and draws each in slice order (back-to-front). -// It captures the initial constraints once at the start, then passes them -// to each render function for consistent positioning. func (r *Renderer) Draw(gtx layout.Context, elems []Element) { - // Capture initial constraints once — before any clips modify them. - // These are in physical pixels and serve as the window bounds for all positioning. - initialConstraints := WindowConstraints{ + // Clip to window bounds + clipRect := clip.Rect{ Min: gtx.Constraints.Min, Max: gtx.Constraints.Max, - } + }.Push(gtx.Ops) 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 + r.drawElement(gtx, e) + } + + clipRect.Pop() +} + +// drawElement clips to the element's bounds, draws background if any, +// then dispatches to the element's Draw method. +func (r *Renderer) drawElement(gtx layout.Context, e Element) { + reg := e.Region() + + // Clip to element bounds + clipRect := 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) + + // Draw children if this is a container + if container, ok := e.(Container); ok { + for _, child := range container.children { + child.Draw(gtx, r) } } + + // Dispatch to element's Draw method + e.Draw(gtx, r) + + clipRect.Pop() } -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() - - // ── Draw background (outside clip) ── - bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255} +// drawBg draws a solid-color background for the given region. +func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) { 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.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) bgClip.Pop() - - // ── 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: Left icons + filename - filenameLineY := reg.Y + Dp(2) - leftX := reg.X + marginDp - - // 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 - } - } - - // 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 - iconsLineY := reg.Y + Dp(26) - - // 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, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - leftX += Dp(36) - 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, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - } - rightX -= Dp(36) - r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconsLineY, 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 - windowW := constraints.Max.X - - regXPx := r.toPx(reg.X) - regWPx := r.toPx(reg.W) - - rightXPx := regXPx + regWPx - if rightXPx > Px(windowW) { - rightXPx = Px(windowW) - } - barW := rightXPx - regXPx - - bottomBarHeightPx := r.toPx(Dp(24)) - marginPx := r.toPx(Dp(10)) - drawYPx := Px(constraints.Max.Y) - bottomBarHeightPx - marginPx - drawY := r.toDp(drawYPx) - - col := color.NRGBA{R: 0, G: 0, B: 0, A: 255} - - // Item 1: cursor position (left-aligned) — shape, position, draw - cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize) - cursorXDp := r.toDp(regXPx + r.toPx(Dp(8))) - r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, col) - - // Item 2: byte position (centered) — shape, position, draw - bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize) - byteXDp := r.toDp(regXPx + barW/2 - Px(bytePosShaped.Width/2)) - r.DrawShapedText(gtx, bytePosShaped, byteXDp, drawY, col) - - // Item 3: wrap (right-aligned) — shape, position, draw - wrapText := "Wrap:" + boolToString(bb.WordWrap) - wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize) - wrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width)) - r.DrawShapedText(gtx, wrapShaped, wrapXDp, drawY, col) -} - -func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { - reg := btn.Region() - th := material.NewTheme() - th.Shaper = r.shp - - 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. -func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, availableWidth, spaceForText int, col color.NRGBA) { - shp.LayoutString(text.Parameters{ - PxPerEm: fixed.I(gtx.Sp(size)), - MinWidth: 0, - MaxWidth: availableWidth, - MaxLines: 1, - }, str) - - 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) - } - - if len(widths) > 0 && widths[len(widths)-1] > availableWidth { - for i := len(widths) - 1; i >= 0; i-- { - if widths[i] <= spaceForText { - fullStr = str[:i] + "..." - break - } - } - } - - 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 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) { - shp.LayoutString(text.Parameters{ - PxPerEm: fixed.I(gtx.Sp(size)), - MinWidth: 0, - MaxWidth: 1000, - MaxLines: 1, - }, str) - r.drawLineText(gtx, shp, x, y, col) +func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, col Color) { + th := material.NewTheme() + th.Shaper = r.shp + gtx.Constraints.Min.X = int(r.toPx(reg.X)) + gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) + material.Label(th, size, str).Layout(gtx) } -// 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) { - xPx := r.toPx(x) - yPx := r.toPx(y) - - 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(). -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] - 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) - - 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() - - if call := shp.Bitmaps(line); call != (op.CallOp{}) { - call.Add(gtx.Ops) - } - - t.Pop() -} - -// WindowConstraints tracks the initial window constraints captured at the start of Draw. -type WindowConstraints struct { - Min image.Point - Max image.Point -} - -// drawPng draws a PNG image at the given position and size. -func (r *Renderer) drawPng(gtx layout.Context, img image.Image, x, y Dp, width, height Dp, col color.NRGBA) { +// drawPng draws a PNG image at the given region and size. +func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) { if img == nil { return } - xPx := int(r.toPx(x)) - yPx := int(r.toPx(y)) + xPx := int(r.toPx(reg.X)) + yPx := int(r.toPx(reg.Y)) wPx := int(r.toPx(width)) hPx := int(r.toPx(height)) - - dst := image.Rect(xPx, yPx, xPx+wPx, yPx+hPx) - - if col.A < 255 { - bgClip := clip.Rect(dst).Push(gtx.Ops) - paint.ColorOp{Color: col}.Add(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - bgClip.Pop() - } + _ = image.Rect(xPx, yPx, xPx+wPx, yPx+hPx) stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops) paint.NewImageOp(img).Add(gtx.Ops) diff --git a/internal/ui/shaper.go b/internal/ui/shaper.go deleted file mode 100644 index 5d5da17..0000000 --- a/internal/ui/shaper.go +++ /dev/null @@ -1,93 +0,0 @@ -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 the result from ShapeText: width for positioning, -// plus the path data ready to draw. Must be drawn before the next ShapeText call. -type ShapedText struct { - Width int // total width in pixels - PathSpec clip.PathSpec // glyph path data - Bitmaps op.CallOp // bitmap glyphs (may be empty) - FirstX fixed.Int26_6 // first glyph X offset - FirstY int32 // first glyph Y offset -} - -// ShapeText layouts text and returns width + path data for drawing. -// The returned ShapedText must be drawn before the next ShapeText call. -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 - var firstX fixed.Int26_6 - var firstY int32 - for { - g, ok := r.shp.NextGlyph() - if !ok { - break - } - if len(glyphs) == 0 { - firstX = g.X - firstY = g.Y - } - cumWidth += g.Advance - glyphs = append(glyphs, g) - } - - // capture path spec - var spec clip.PathSpec - var bitmaps op.CallOp - - spec = r.shp.Shape(glyphs) - bitmaps = r.shp.Bitmaps(glyphs) - - return ShapedText{ - Width: int(cumWidth >> 6), - PathSpec: spec, - Bitmaps: bitmaps, - FirstX: firstX, - FirstY: firstY, - } -} - -// DrawShapedText draws a ShapedText at the given position. -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) - - 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) - - outline := clip.Outline{Path: st.PathSpec}.Op().Push(gtx.Ops) - paint.ColorOp{Color: col}.Add(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - outline.Pop() - - if st.Bitmaps != (op.CallOp{}) { - st.Bitmaps.Add(gtx.Ops) - } - - t.Pop() - call := m.Stop() - call.Add(gtx.Ops) -}