Introduce distinct Dp/Px types to prevent coordinate mixing at compile time
- Add ui.Dp and ui.Px as distinct named types - Logic layer works exclusively in Dp (positions, sizes, regions) - Renderer converts Dp→Px at Gio interop boundaries - Capture gtx.Constraints once at start of Draw() for consistent positioning - main.go converts app.ConfigEvent pixels to Dp before passing to logic layer - Update documentation with new coordinate system architecture
This commit is contained in:
parent
f563a515e6
commit
b8c6878f46
|
|
@ -34,12 +34,13 @@ func run(w *app.Window) error {
|
|||
shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
|
||||
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
||||
|
||||
// Initial screen size
|
||||
screenWidth := unit.Dp(390)
|
||||
screenHeight := unit.Dp(844)
|
||||
// Track window dimensions in pixels (from ConfigEvent) and DP (for logic layer)
|
||||
var pixelW, pixelH int
|
||||
var dpW, dpH ui.Dp
|
||||
var scale float32 = 1.0 // updated on first FrameEvent
|
||||
|
||||
// Create logic instance
|
||||
logic := editor.NewLogic(screenWidth, screenHeight)
|
||||
// Create logic instance with initial DP size
|
||||
logic := editor.NewLogic(dpW, dpH)
|
||||
|
||||
// Shared state protected by mutex
|
||||
var mu sync.Mutex
|
||||
|
|
@ -57,18 +58,37 @@ func run(w *app.Window) error {
|
|||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.ConfigEvent:
|
||||
// Send config event to logic goroutine
|
||||
// Store pixel dimensions from ConfigEvent
|
||||
pixelW = e.Config.Size.X
|
||||
pixelH = e.Config.Size.Y
|
||||
// Convert to DP using current scale (default 1.0 until first FrameEvent)
|
||||
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: unit.Dp(e.Config.Size.X),
|
||||
Height: unit.Dp(e.Config.Size.Y),
|
||||
Width: dpW,
|
||||
Height: dpH,
|
||||
}
|
||||
case app.FrameEvent:
|
||||
// Update scale from the frame context
|
||||
gtx := app.NewContext(&ops, e)
|
||||
scale = gtx.Metric.PxPerDp
|
||||
|
||||
// Reconvert pixel dimensions to DP with actual scale
|
||||
dpW = ui.ToDp(ui.Px(pixelW), scale)
|
||||
dpH = ui.ToDp(ui.Px(pixelH), scale)
|
||||
|
||||
// Send updated config to logic goroutine
|
||||
logic.ConfigChan() <- editor.ConfigEvent{
|
||||
Width: dpW,
|
||||
Height: dpH,
|
||||
}
|
||||
|
||||
// Acquire mutex, read frame, draw, release mutex
|
||||
mu.Lock()
|
||||
currentElems := elems
|
||||
mu.Unlock()
|
||||
|
||||
gtx := app.NewContext(&ops, e)
|
||||
renderer.Draw(gtx, currentElems)
|
||||
e.Frame(&ops)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,15 +23,44 @@ Consistent with Gioui and standard 2D graphics:
|
|||
|
||||
### 2.2 Units
|
||||
|
||||
Consistent with Gioui's `unit` package:
|
||||
The project uses distinct Go types to prevent accidental mixing of coordinate units at compile time:
|
||||
|
||||
| Unit | Go Type | Use |
|
||||
|---|---|---|
|
||||
| **Dp** (device-independent pixels) | `unit.Dp` (`float32`) | Element positions, sizes, spacing |
|
||||
| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference) |
|
||||
| **Px** (raw device pixels) | `int` | Never used in the logic layer; the renderer converts Dp→Px using `Metric.PxPerDp` |
|
||||
| **Dp** (device-independent pixels) | `ui.Dp` (alias of `unit.Dp`) | Element positions, sizes, spacing in the logic/layout layer |
|
||||
| **Px** (physical pixels) | `ui.Px` (alias of `int`) | Gio interop only (`gtx.Constraints`, `gtx.Dp()`, etc.) |
|
||||
| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference; Gio's shaper requires `unit.Sp`) |
|
||||
|
||||
The logic layer works exclusively in `Dp` (positions/sizes) and `Sp` (fonts). The renderer converts to raw pixels using the `Metric` provided by Gioui's transaction context.
|
||||
### Type Safety
|
||||
|
||||
`ui.Dp` and `ui.Px` are distinct named types. The compiler prevents accidental mixing:
|
||||
|
||||
```go
|
||||
var pos ui.Dp = ui.Dp(100)
|
||||
var winW ui.Px = ui.Px(780)
|
||||
|
||||
// Compile error: invalid operation: pos + winW (mismatched types ui.Dp and ui.Px)
|
||||
// Must use explicit conversion:
|
||||
converted := ui.ToPx(pos, scale) // Dp → Px
|
||||
```
|
||||
|
||||
### Conversion Functions
|
||||
|
||||
```go
|
||||
// Convert Dp to Px using the scale factor (pixels per DP)
|
||||
func ToPx(dp Dp, scale float32) Px
|
||||
|
||||
// Convert Px to Dp using the scale factor
|
||||
func ToDp(px Px, scale float32) float32
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Logic/Layout layer**: Works exclusively in `Dp`. Element regions, positions, and sizes are all in `Dp`.
|
||||
- **Renderer**: Converts `Dp` → `Px` at Gio interop boundaries using `gtx.Metric.PxPerDp`.
|
||||
- **main.go**: Converts `app.ConfigEvent` pixel dimensions to `Dp` before passing to the logic layer.
|
||||
|
||||
The renderer captures `gtx.Constraints` once at the start of `Draw()` (before clips modify them) and passes this snapshot to all render functions for consistent positioning.
|
||||
|
||||
### 2.3 Element Interface
|
||||
|
||||
|
|
|
|||
|
|
@ -571,41 +571,61 @@ visualGap = (line2Baseline - line1Baseline) - (ascent1 + ascent2)
|
|||
With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch)
|
||||
With `LineHeightScale = 1.2`: `visualGap ≈ 0.2 * lineHeight` (20% padding)
|
||||
|
||||
## 9.5 Drawing Elements Inside the Visible Window
|
||||
## 9.5 Coordinate System and Type Safety
|
||||
|
||||
When drawing elements like the BottomBar, the region coordinates computed from `screenHeight` may be outside the visible window area. This happens because `app.ConfigEvent` reports the **physical pixel size** of the window, which can be much larger than the requested DP size (e.g., macOS reports 1560×3376 pixels when we requested 390×844 DP).
|
||||
The project uses distinct Go types (`ui.Dp` and `ui.Px`) to prevent accidental mixing of coordinate units at compile time.
|
||||
|
||||
**Y positioning**: Use `gtx.Constraints.Max.Y` (in physical pixels) to clamp the draw position to the visible area:
|
||||
### Architecture
|
||||
|
||||
```go
|
||||
scale := gtx.Metric.PxPerDp
|
||||
bottomBarHeightPx := int(24 * scale)
|
||||
marginPx := int(10 * scale)
|
||||
drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx
|
||||
drawY := unit.Dp(float64(drawYPx) / float64(scale))
|
||||
```
|
||||
main.go Logic/Layout layer Renderer
|
||||
───────── ───────────────── ─────────
|
||||
app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px)
|
||||
→ pixels → regions in Dp → converts Dp→Px
|
||||
→ convert to Dp at boundaries
|
||||
```
|
||||
|
||||
**X positioning**: `gtx.Constraints` are modified by previous widgets (e.g., `Min.X` changes after StatusBar draws). Use the element's own region for X positioning, clamped to `gtx.Constraints.Max.X`:
|
||||
### Key Rules
|
||||
|
||||
1. **Logic layer works exclusively in Dp**: `EditorLayout` accepts `ui.Dp` dimensions and returns regions in `ui.Dp`. No pixel conversions in the layout layer.
|
||||
|
||||
2. **Renderer converts at boundaries**: The renderer captures `gtx.Constraints` once at the start of `Draw()` (before clips modify them), then passes this snapshot to all render functions.
|
||||
|
||||
3. **Explicit conversions only**: Use `ui.ToPx(dp, scale)` and `ui.ToDp(px, scale)` for conversions. Never write `int(reg.X * scale)` — the type system prevents this.
|
||||
|
||||
### BottomBar Positioning Example
|
||||
|
||||
```go
|
||||
regXPx := int(float32(reg.X) * scale)
|
||||
windowW := gtx.Constraints.Max.X
|
||||
rightXPx := regXPx + int(float32(reg.W)*scale)
|
||||
if rightXPx > windowW {
|
||||
rightXPx = windowW
|
||||
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) {
|
||||
reg := bb.Region() // Region in Dp
|
||||
|
||||
// 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(constraints.Max.X) {
|
||||
rightXPx = Px(constraints.Max.X)
|
||||
}
|
||||
barW := rightXPx - regXPx
|
||||
// Now use regXPx and barW for all X positions
|
||||
cursorXPx := regXPx + int(8*scale)
|
||||
byteXPx := regXPx + barW/2 - int(60*scale)
|
||||
wordWrapXPx := regXPx + barW - int(80*scale)
|
||||
|
||||
// Position at bottom of visible window (in pixels)
|
||||
drawYPx := Px(constraints.Max.Y) - r.toPx(Dp(24)) - r.toPx(Dp(10))
|
||||
drawY := r.toDp(drawYPx)
|
||||
|
||||
// All X positions use pixel arithmetic, converted back to Dp for text rendering
|
||||
cursorXDp := r.toDp(regXPx + r.toPx(Dp(8)))
|
||||
byteXDp := r.toDp(regXPx + barW/2 - r.toPx(Dp(60)))
|
||||
wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(80)))
|
||||
}
|
||||
```
|
||||
|
||||
**Key insights**:
|
||||
- `gtx.Constraints.Max.Y` is in **physical pixels** — use for Y positioning
|
||||
- `gtx.Constraints.Min.X` is **modified by previous widgets** — don't use for X positioning
|
||||
- Always convert DP to pixels using `gtx.Metric.PxPerDp` when mixing with pixel values
|
||||
- Always clamp right edge to `gtx.Constraints.Max.X`
|
||||
### Common Pitfalls
|
||||
|
||||
- **Don't use `gtx.Constraints` in render functions**: They're modified by clips. Use the captured `WindowConstraints` snapshot.
|
||||
- **Don't mix Dp and Px**: The compiler will catch it. Use explicit conversions.
|
||||
- **Don't pass pixels as Dp to EditorLayout**: `main.go` must convert `app.ConfigEvent` pixels to Dp before passing to the logic layer.
|
||||
|
||||
## 10. Summary
|
||||
|
||||
|
|
@ -616,8 +636,9 @@ wordWrapXPx := regXPx + barW - int(80*scale)
|
|||
- **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2)
|
||||
- **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight`
|
||||
- **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)`
|
||||
- **gtx.Constraints.Max.X/Y** are in **physical pixels**, not DP. Convert using `gtx.Metric.PxPerDp`.
|
||||
- **When drawing at the bottom of the window**, use `gtx.Constraints.Max.Y - elementHeight - margin` to clamp to visible area.
|
||||
- **Logic layer works exclusively in Dp**. Renderer converts Dp→Px at Gio boundaries.
|
||||
- **Capture `gtx.Constraints` once at start of `Draw()`** — clips modify them.
|
||||
- **Use explicit conversions** (`ui.ToPx`, `ui.ToDp`) — never mix Dp and Px directly.
|
||||
- **Avoid** using `material.Label()` when you need precise glyph positions
|
||||
- **Reuse** glyph data for measurement, hit testing, and rendering
|
||||
- **Follow** Gio's `paintGlyph` as the reference implementation
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"gioui.org/unit"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a window configuration change (resize, orientation).
|
||||
// Width and Height are in device-independent pixels (Dp).
|
||||
type ConfigEvent struct {
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
Width ui.Dp
|
||||
Height ui.Dp
|
||||
}
|
||||
|
||||
// InputEvent represents a user input event routed to an element.
|
||||
|
|
@ -34,7 +33,8 @@ type Logic struct {
|
|||
}
|
||||
|
||||
// NewLogic creates a new Logic instance.
|
||||
func NewLogic(screenWidth, screenHeight unit.Dp) *Logic {
|
||||
// screenWidth and screenHeight are in device-independent pixels (Dp).
|
||||
func NewLogic(screenWidth, screenHeight ui.Dp) *Logic {
|
||||
return &Logic{
|
||||
state: NewState(screenWidth, screenHeight),
|
||||
configChan: make(chan ConfigEvent),
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"gioui.org/unit"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// State holds all application state owned by the logic goroutine.
|
||||
// All dimensions are in device-independent pixels (Dp).
|
||||
type State struct {
|
||||
ScreenWidth unit.Dp
|
||||
ScreenHeight unit.Dp
|
||||
ScreenWidth ui.Dp
|
||||
ScreenHeight ui.Dp
|
||||
Elems []ui.Element
|
||||
}
|
||||
|
||||
// NewState creates a new State with initial editor layout.
|
||||
func NewState(screenWidth, screenHeight unit.Dp) *State {
|
||||
// screenWidth and screenHeight are in device-independent pixels (Dp).
|
||||
func NewState(screenWidth, screenHeight ui.Dp) *State {
|
||||
return &State{
|
||||
ScreenWidth: screenWidth,
|
||||
ScreenHeight: screenHeight,
|
||||
|
|
@ -23,15 +23,14 @@ func NewState(screenWidth, screenHeight unit.Dp) *State {
|
|||
}
|
||||
|
||||
// EditorLayout computes regions for the editor page.
|
||||
// It takes screen dimensions and returns []Element.
|
||||
func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element {
|
||||
// It takes screen dimensions in Dp and returns []Element with regions in Dp.
|
||||
// This function works exclusively in Dp — no pixel conversions.
|
||||
func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
|
||||
// Margin to avoid clipping on macOS round corners
|
||||
margin := unit.Dp(10)
|
||||
margin := ui.Dp(10)
|
||||
|
||||
// Compute StatusBar region
|
||||
// Line 1: filename (24 DP)
|
||||
// Line 2: icons (24 DP)
|
||||
statusBarHeight := unit.Dp(48)
|
||||
statusBarHeight := ui.Dp(48)
|
||||
statusBarRegion := ui.Region{
|
||||
X: margin,
|
||||
Y: margin,
|
||||
|
|
@ -40,7 +39,7 @@ func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element {
|
|||
}
|
||||
|
||||
// Compute BottomBar region (fixed height, at bottom of screen)
|
||||
bottomBarHeight := unit.Dp(24)
|
||||
bottomBarHeight := ui.Dp(24)
|
||||
bottomBarY := screenHeight - margin - bottomBarHeight
|
||||
bottomBarRegion := ui.Region{
|
||||
X: margin,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ package ui
|
|||
import "gioui.org/unit"
|
||||
|
||||
// Region defines a screen area in device-independent pixels (Dp).
|
||||
// All element positions and sizes use Dp for device independence.
|
||||
type Region struct {
|
||||
X, Y unit.Dp
|
||||
W, H unit.Dp
|
||||
X, Y Dp
|
||||
W, H Dp
|
||||
}
|
||||
|
||||
// Element is the base interface for all UI elements.
|
||||
|
|
@ -43,9 +44,10 @@ type InputEvent struct {
|
|||
}
|
||||
|
||||
// ConfigEvent represents a window configuration change (resize, orientation).
|
||||
// Width and Height are in device-independent pixels (Dp).
|
||||
type ConfigEvent struct {
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
Width Dp
|
||||
Height Dp
|
||||
}
|
||||
|
||||
// Label displays static text.
|
||||
|
|
@ -83,10 +85,10 @@ type TextField struct {
|
|||
Placeholder string
|
||||
Focused bool
|
||||
Multiline bool
|
||||
ScrollOffset unit.Dp
|
||||
ScrollOffset Dp
|
||||
VisibleLines []Line
|
||||
WordWrap bool
|
||||
WrapWidth unit.Dp
|
||||
WrapWidth Dp
|
||||
}
|
||||
|
||||
func (tf TextField) Region() Region { return tf.region }
|
||||
|
|
@ -336,7 +338,7 @@ func (s Spacer) Visible() bool { return s.visible }
|
|||
func (s Spacer) ID() string { return s.id }
|
||||
|
||||
// NewSpacer creates a visible Spacer element.
|
||||
func NewSpacer(height unit.Dp) Spacer {
|
||||
func NewSpacer(height Dp) Spacer {
|
||||
return Spacer{
|
||||
region: Region{H: height},
|
||||
visible: true,
|
||||
|
|
@ -350,5 +352,5 @@ type Color struct {
|
|||
|
||||
// Theme holds styling defaults for the UI.
|
||||
type Theme struct {
|
||||
FontSize unit.Sp
|
||||
FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,16 @@
|
|||
package ui
|
||||
|
||||
import "gioui.org/unit"
|
||||
|
||||
const (
|
||||
// Standard dimensions in Dp
|
||||
statusBarLineHeight = unit.Dp(24)
|
||||
statusBarFilenameLine = unit.Dp(24)
|
||||
statusBarIconsLine = unit.Dp(24)
|
||||
bottomBarHeight = unit.Dp(24)
|
||||
iconSize = unit.Dp(24)
|
||||
iconGap = unit.Dp(36)
|
||||
padding = unit.Dp(8)
|
||||
buttonPadding = unit.Dp(8)
|
||||
statusBarLineHeight = Dp(24)
|
||||
statusBarFilenameLine = Dp(24)
|
||||
statusBarIconsLine = Dp(24)
|
||||
bottomBarHeight = Dp(24)
|
||||
iconSize = Dp(24)
|
||||
iconGap = Dp(36)
|
||||
padding = Dp(8)
|
||||
buttonPadding = Dp(8)
|
||||
)
|
||||
|
||||
// EditorLayout computes regions for the editor page.
|
||||
// It takes screen dimensions and returns []Element.
|
||||
func EditorLayout(screenWidth, screenHeight unit.Dp) []Element {
|
||||
// Compute StatusBar region
|
||||
// Line 1: filename (24 DP)
|
||||
// Line 2: icons (24 DP)
|
||||
statusBarHeight := statusBarFilenameLine + statusBarIconsLine
|
||||
statusBarRegion := Region{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
W: screenWidth,
|
||||
H: statusBarHeight,
|
||||
}
|
||||
|
||||
// Compute BottomBar region (fixed height, at bottom of screen)
|
||||
bottomBarY := screenHeight - bottomBarHeight
|
||||
bottomBarRegion := Region{
|
||||
X: 0,
|
||||
Y: bottomBarY,
|
||||
W: screenWidth,
|
||||
H: bottomBarHeight,
|
||||
}
|
||||
|
||||
// Create StatusBar with mocked filename
|
||||
statusBar := NewStatusBar(
|
||||
statusBarRegion,
|
||||
"very_long_filename_that_does_not_fit_on_a_single_line.txt",
|
||||
false, // FilenameExp
|
||||
true, // CutCopy
|
||||
false, // Copy
|
||||
true, // Paste
|
||||
false, // ConflictIcon
|
||||
true, // Search
|
||||
)
|
||||
|
||||
// Create BottomBar with mocked data
|
||||
bottomBar := NewBottomBar(
|
||||
bottomBarRegion,
|
||||
"Ln 47, Col 12",
|
||||
"1024 / 50000",
|
||||
true, // WordWrap
|
||||
)
|
||||
|
||||
return []Element{statusBar, bottomBar}
|
||||
}
|
||||
// NOTE: EditorLayout is defined in internal/editor/state.go.
|
||||
// This file contains layout constants only.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
|
|
@ -20,38 +19,65 @@ import (
|
|||
type Renderer struct {
|
||||
theme Theme
|
||||
shp *text.Shaper
|
||||
scale float32 // pixels per DP, from gtx.Metric.PxPerDp
|
||||
}
|
||||
|
||||
// New creates a new Renderer.
|
||||
func New(th Theme, shp *text.Shaper) *Renderer {
|
||||
return &Renderer{theme: th, shp: shp}
|
||||
return &Renderer{theme: th, shp: shp, scale: 1.0}
|
||||
}
|
||||
|
||||
// setScale updates the renderer's scale factor from a layout.Context.
|
||||
func (r *Renderer) setScale(gtx layout.Context) {
|
||||
r.scale = gtx.Metric.PxPerDp
|
||||
}
|
||||
|
||||
// toPx converts Dp to physical pixels using the renderer's scale.
|
||||
func (r *Renderer) toPx(dp Dp) Px {
|
||||
return ToPx(dp, r.scale)
|
||||
}
|
||||
|
||||
// toDp converts physical pixels to Dp using the renderer's scale.
|
||||
func (r *Renderer) toDp(px Px) Dp {
|
||||
return ToDp(px, r.scale)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Update scale from the frame context
|
||||
r.setScale(gtx)
|
||||
|
||||
// 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{
|
||||
Min: gtx.Constraints.Min,
|
||||
Max: gtx.Constraints.Max,
|
||||
}
|
||||
|
||||
for _, e := range elems {
|
||||
fmt.Printf("[DEBUG Draw] elem type=%T visible=%v\n", e, e.Visible())
|
||||
if !e.Visible() {
|
||||
continue
|
||||
}
|
||||
switch v := e.(type) {
|
||||
case Label:
|
||||
r.drawLabel(gtx, v)
|
||||
r.drawLabel(gtx, v, initialConstraints)
|
||||
case ListView:
|
||||
r.drawListView(gtx, v)
|
||||
r.drawListView(gtx, v, initialConstraints)
|
||||
case StatusBar:
|
||||
r.drawStatusBar(gtx, v)
|
||||
r.drawStatusBar(gtx, v, initialConstraints)
|
||||
case BottomBar:
|
||||
r.drawBottomBar(gtx, v)
|
||||
r.drawBottomBar(gtx, v, initialConstraints)
|
||||
case Button:
|
||||
r.drawButton(gtx, v)
|
||||
r.drawButton(gtx, v, initialConstraints)
|
||||
default:
|
||||
// unknown element type, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
|
||||
func (r *Renderer) drawLabel(gtx layout.Context, l Label, _ WindowConstraints) {
|
||||
reg := l.Region()
|
||||
th := material.NewTheme()
|
||||
th.Shaper = r.shp
|
||||
|
|
@ -60,34 +86,34 @@ func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
|
|||
size = r.theme.FontSize
|
||||
}
|
||||
|
||||
// Position label at region origin
|
||||
gtx.Constraints.Min.X = int(reg.X)
|
||||
gtx.Constraints.Min.Y = int(reg.Y)
|
||||
// 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) {
|
||||
func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstraints) {
|
||||
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)
|
||||
// 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) {
|
||||
func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) {
|
||||
reg := sb.Region()
|
||||
th := material.NewTheme()
|
||||
th.Shaper = r.shp
|
||||
|
||||
// Clip to status bar region
|
||||
// Clip to status bar region (convert Dp to pixels)
|
||||
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)},
|
||||
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)
|
||||
|
|
@ -95,7 +121,7 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) {
|
|||
|
||||
// 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
|
||||
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{
|
||||
|
|
@ -116,101 +142,89 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) {
|
|||
spaceForText := availableWidth - ellipsisWidth
|
||||
|
||||
// Layout filename, measure widths, and draw in a single pass
|
||||
r.drawTruncatedText(gtx, th.Shaper, displayFilename, r.theme.FontSize, reg.X+unit.Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
||||
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.
|
||||
// This matches Gio's default line spacing (ascent+descent with LineHeightScale=1.0).
|
||||
iconsLineY := filenameLineY + unit.Dp(20)
|
||||
iconsLineY := filenameLineY + Dp(20)
|
||||
|
||||
// Left side: Cut, Copy, Paste icons (always visible)
|
||||
leftX := reg.X + unit.Dp(8)
|
||||
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 += unit.Dp(36)
|
||||
leftX += Dp(36)
|
||||
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
||||
leftX += unit.Dp(36)
|
||||
leftX += Dp(36)
|
||||
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
||||
leftX += unit.Dp(12)
|
||||
leftX += Dp(12)
|
||||
|
||||
// Right side: Conflict (conditional), Search (always visible)
|
||||
rightX := reg.X + reg.W - unit.Dp(8)
|
||||
rightX := reg.X + reg.W - Dp(8)
|
||||
if sb.ConflictIcon {
|
||||
rightX -= unit.Dp(36)
|
||||
rightX -= Dp(36)
|
||||
r.drawText(gtx, th.Shaper, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
||||
}
|
||||
rightX -= unit.Dp(36)
|
||||
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) {
|
||||
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) {
|
||||
reg := bb.Region()
|
||||
|
||||
fmt.Printf("[DEBUG drawBottomBar] reg=(%d, %d, %d, %d)\n", gtx.Dp(reg.X), gtx.Dp(reg.Y), gtx.Dp(reg.W), gtx.Dp(reg.H))
|
||||
fmt.Printf("[DEBUG drawBottomBar] constraints=(%d, %d, %d, %d)\n",
|
||||
gtx.Constraints.Min.X, gtx.Constraints.Min.Y,
|
||||
gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
|
||||
// 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
|
||||
|
||||
// Clamp BottomBar Y to visible window area.
|
||||
// The region Y may be outside the visible area (e.g. 3308 DP when window is 1688px).
|
||||
// gtx.Constraints.Max.Y is in physical pixels. Convert to DP using gtx.Metric.PxPerDp.
|
||||
scale := gtx.Metric.PxPerDp
|
||||
bottomBarHeightPx := int(24 * scale)
|
||||
marginPx := int(10 * scale)
|
||||
drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx
|
||||
drawY := unit.Dp(float64(drawYPx) / float64(scale))
|
||||
fmt.Printf("[DEBUG drawBottomBar] drawY=%d (clamped from %d)\n", gtx.Dp(drawY), gtx.Dp(reg.Y))
|
||||
// Convert region to pixels for positioning
|
||||
regXPx := r.toPx(reg.X)
|
||||
regWPx := r.toPx(reg.W)
|
||||
|
||||
// Use the BottomBar region for X positioning, clamped to visible window width.
|
||||
// The region width (1520 DP = 3040px) exceeds the window width (780px).
|
||||
regXPx := int(float32(reg.X) * scale)
|
||||
windowW := gtx.Constraints.Max.X
|
||||
// Clamp right edge to window width
|
||||
rightXPx := regXPx + int(float32(reg.W)*scale)
|
||||
if rightXPx > windowW {
|
||||
rightXPx = windowW
|
||||
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 + int(8*scale)
|
||||
cursorXDp := unit.Dp(float64(cursorXPx) / float64(scale))
|
||||
fmt.Printf("[DEBUG drawBottomBar] cursor=(%d, %d) text=%q\n", cursorXPx, drawYPx, bb.CursorPos)
|
||||
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 - int(60*scale)
|
||||
byteXDp := unit.Dp(float64(byteXPx) / float64(scale))
|
||||
fmt.Printf("[DEBUG drawBottomBar] byte=(%d, %d) text=%q\n", byteXPx, drawYPx, bb.BytePos)
|
||||
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 - int(80*scale)
|
||||
wordWrapXDp := unit.Dp(float64(wordWrapXPx) / float64(scale))
|
||||
fmt.Printf("[DEBUG drawBottomBar] wordwrap=(%d, %d) text=%q\n", wordWrapXPx, drawYPx, "W:"+boolToString(bb.WordWrap))
|
||||
wordWrapXPx := regXPx + barW - r.toPx(Dp(80))
|
||||
wordWrapXDp := r.toDp(wordWrapXPx)
|
||||
r.drawText(gtx, r.shp, "W:"+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) {
|
||||
func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) {
|
||||
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)
|
||||
// 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 (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"
|
||||
|
|
@ -219,7 +233,7 @@ func boolToString(b bool) string {
|
|||
}
|
||||
|
||||
// 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 unit.Dp, availableWidth, spaceForText int, col color.NRGBA) {
|
||||
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)),
|
||||
|
|
@ -265,7 +279,7 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s
|
|||
|
||||
// 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 unit.Dp, col color.NRGBA) {
|
||||
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)),
|
||||
|
|
@ -277,7 +291,11 @@ 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 unit.Dp, col color.NRGBA) {
|
||||
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
|
||||
|
|
@ -285,12 +303,12 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit.
|
|||
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, x, y, col)
|
||||
r.drawLine(gtx, shp, line, xPx, yPx, col)
|
||||
line = line[:0]
|
||||
}
|
||||
}
|
||||
if len(line) > 0 {
|
||||
r.drawLine(gtx, shp, line, x, y, col)
|
||||
r.drawLine(gtx, shp, line, xPx, yPx, col)
|
||||
}
|
||||
call := m.Stop()
|
||||
call.Add(gtx.Ops)
|
||||
|
|
@ -298,7 +316,7 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit.
|
|||
|
||||
// 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 unit.Dp, col color.NRGBA) {
|
||||
func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) {
|
||||
if len(line) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -308,8 +326,8 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl
|
|||
// at the desired document position. Matches Gio's paintGlyph:
|
||||
// lineOff = (glyph.X, glyph.Y) - viewport.Min
|
||||
// op.Affine(f32.Affine2D{}.Offset(lineOff))
|
||||
offX := float32(gtx.Dp(x)) + float32(first.X)/64.0
|
||||
offY := float32(gtx.Dp(y)) + float32(first.Y)
|
||||
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
|
||||
|
|
@ -327,26 +345,9 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl
|
|||
t.Pop()
|
||||
}
|
||||
|
||||
// charWidths returns the cumulative width after each rune in str.
|
||||
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
|
||||
// Match Gio's textView: PxPerEm = font size in device pixels
|
||||
// Must set MinWidth/MaxWidth/MaxLines or shaper wraps every character.
|
||||
shp.LayoutString(text.Parameters{
|
||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
||||
MinWidth: 0,
|
||||
MaxWidth: 1000,
|
||||
MaxLines: 1,
|
||||
}, str)
|
||||
var widths []int
|
||||
var cumWidth fixed.Int26_6 = 0
|
||||
for {
|
||||
g, ok := shp.NextGlyph()
|
||||
if !ok {
|
||||
break
|
||||
// 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
|
||||
}
|
||||
cumWidth += g.Advance
|
||||
widths = append(widths, int(cumWidth>>6))
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
|
|
|
|||
65
internal/ui/unit.go
Normal file
65
internal/ui/unit.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package ui
|
||||
|
||||
import "gioui.org/unit"
|
||||
|
||||
// Dp represents device-independent pixels. Use for all element positions,
|
||||
// sizes, and spacing in the logic/layout layer.
|
||||
type Dp unit.Dp
|
||||
|
||||
// Px represents physical device pixels. Use only when interfacing with
|
||||
// Gio's layout.Context (gtx.Constraints, gtx.Dp(), etc.).
|
||||
type Px int
|
||||
|
||||
// ToDp converts physical pixels to device-independent pixels using the
|
||||
// given scale factor (pixels per DP). Typically from gtx.Metric.PxPerDp.
|
||||
func ToDp(px Px, scale float32) Dp {
|
||||
return Dp(float32(px) / scale)
|
||||
}
|
||||
|
||||
// ToPx converts device-independent pixels to physical pixels using the
|
||||
// given scale factor (pixels per DP).
|
||||
func ToPx(dp Dp, scale float32) Px {
|
||||
return Px(float32(dp) * scale)
|
||||
}
|
||||
|
||||
// PxPerDp returns the scale factor: how many physical pixels per DP.
|
||||
// Use this to convert between Dp and Px.
|
||||
func PxPerDp(scale float32) float32 { return scale }
|
||||
|
||||
// --- Gio interop helpers ---
|
||||
|
||||
// DpToPx converts a gioui unit.Dp to our Px using the scale factor.
|
||||
func DpToPx(d unit.Dp, scale float32) Px {
|
||||
return Px(float32(d) * scale)
|
||||
}
|
||||
|
||||
// PxToDp converts our Px to a gioui unit.Dp using the scale factor.
|
||||
func PxToDp(p Px, scale float32) unit.Dp {
|
||||
return unit.Dp(float32(p) / scale)
|
||||
}
|
||||
|
||||
// RegionPx is a region in physical pixels. Used for Gio interop only.
|
||||
type RegionPx struct {
|
||||
X, Y Px
|
||||
W, H Px
|
||||
}
|
||||
|
||||
// ToDp converts a RegionPx to a Region (Dp) using the scale factor.
|
||||
func (r RegionPx) ToDp(scale float32) Region {
|
||||
return Region{
|
||||
X: ToDp(r.X, scale),
|
||||
Y: ToDp(r.Y, scale),
|
||||
W: ToDp(r.W, scale),
|
||||
H: ToDp(r.H, scale),
|
||||
}
|
||||
}
|
||||
|
||||
// FromDp converts a Region (Dp) to a RegionPx using the scale factor.
|
||||
func FromDp(r Region, scale float32) RegionPx {
|
||||
return RegionPx{
|
||||
X: ToPx(r.X, scale),
|
||||
Y: ToPx(r.Y, scale),
|
||||
W: ToPx(r.W, scale),
|
||||
H: ToPx(r.H, scale),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user