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()))
|
shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
|
||||||
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
||||||
|
|
||||||
// Initial screen size
|
// Track window dimensions in pixels (from ConfigEvent) and DP (for logic layer)
|
||||||
screenWidth := unit.Dp(390)
|
var pixelW, pixelH int
|
||||||
screenHeight := unit.Dp(844)
|
var dpW, dpH ui.Dp
|
||||||
|
var scale float32 = 1.0 // updated on first FrameEvent
|
||||||
|
|
||||||
// Create logic instance
|
// Create logic instance with initial DP size
|
||||||
logic := editor.NewLogic(screenWidth, screenHeight)
|
logic := editor.NewLogic(dpW, dpH)
|
||||||
|
|
||||||
// Shared state protected by mutex
|
// Shared state protected by mutex
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
@ -57,18 +58,37 @@ func run(w *app.Window) error {
|
||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
return e.Err
|
return e.Err
|
||||||
case app.ConfigEvent:
|
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{
|
logic.ConfigChan() <- editor.ConfigEvent{
|
||||||
Width: unit.Dp(e.Config.Size.X),
|
Width: dpW,
|
||||||
Height: unit.Dp(e.Config.Size.Y),
|
Height: dpH,
|
||||||
}
|
}
|
||||||
case app.FrameEvent:
|
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
|
// Acquire mutex, read frame, draw, release mutex
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
currentElems := elems
|
currentElems := elems
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
gtx := app.NewContext(&ops, e)
|
|
||||||
renderer.Draw(gtx, currentElems)
|
renderer.Draw(gtx, currentElems)
|
||||||
e.Frame(&ops)
|
e.Frame(&ops)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,44 @@ Consistent with Gioui and standard 2D graphics:
|
||||||
|
|
||||||
### 2.2 Units
|
### 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 |
|
| Unit | Go Type | Use |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Dp** (device-independent pixels) | `unit.Dp` (`float32`) | Element positions, sizes, spacing |
|
| **Dp** (device-independent pixels) | `ui.Dp` (alias of `unit.Dp`) | Element positions, sizes, spacing in the logic/layout layer |
|
||||||
| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference) |
|
| **Px** (physical pixels) | `ui.Px` (alias of `int`) | Gio interop only (`gtx.Constraints`, `gtx.Dp()`, etc.) |
|
||||||
| **Px** (raw device pixels) | `int` | Never used in the logic layer; the renderer converts Dp→Px using `Metric.PxPerDp` |
|
| **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
|
### 2.3 Element Interface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -571,41 +571,61 @@ visualGap = (line2Baseline - line1Baseline) - (ascent1 + ascent2)
|
||||||
With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch)
|
With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch)
|
||||||
With `LineHeightScale = 1.2`: `visualGap ≈ 0.2 * lineHeight` (20% padding)
|
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
|
main.go Logic/Layout layer Renderer
|
||||||
bottomBarHeightPx := int(24 * scale)
|
───────── ───────────────── ─────────
|
||||||
marginPx := int(10 * scale)
|
app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px)
|
||||||
drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx
|
→ pixels → regions in Dp → converts Dp→Px
|
||||||
drawY := unit.Dp(float64(drawYPx) / float64(scale))
|
→ 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
|
```go
|
||||||
regXPx := int(float32(reg.X) * scale)
|
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) {
|
||||||
windowW := gtx.Constraints.Max.X
|
reg := bb.Region() // Region in Dp
|
||||||
rightXPx := regXPx + int(float32(reg.W)*scale)
|
|
||||||
if rightXPx > windowW {
|
// Convert region to pixels for positioning
|
||||||
rightXPx = windowW
|
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
|
barW := rightXPx - regXPx
|
||||||
// Now use regXPx and barW for all X positions
|
|
||||||
cursorXPx := regXPx + int(8*scale)
|
// Position at bottom of visible window (in pixels)
|
||||||
byteXPx := regXPx + barW/2 - int(60*scale)
|
drawYPx := Px(constraints.Max.Y) - r.toPx(Dp(24)) - r.toPx(Dp(10))
|
||||||
wordWrapXPx := regXPx + barW - int(80*scale)
|
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**:
|
### Common Pitfalls
|
||||||
- `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
|
- **Don't use `gtx.Constraints` in render functions**: They're modified by clips. Use the captured `WindowConstraints` snapshot.
|
||||||
- Always convert DP to pixels using `gtx.Metric.PxPerDp` when mixing with pixel values
|
- **Don't mix Dp and Px**: The compiler will catch it. Use explicit conversions.
|
||||||
- Always clamp right edge to `gtx.Constraints.Max.X`
|
- **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
|
## 10. Summary
|
||||||
|
|
||||||
|
|
@ -616,8 +636,9 @@ wordWrapXPx := regXPx + barW - int(80*scale)
|
||||||
- **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2)
|
- **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2)
|
||||||
- **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight`
|
- **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight`
|
||||||
- **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)`
|
- **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)`
|
||||||
- **gtx.Constraints.Max.X/Y** are in **physical pixels**, not DP. Convert using `gtx.Metric.PxPerDp`.
|
- **Logic layer works exclusively in Dp**. Renderer converts Dp→Px at Gio boundaries.
|
||||||
- **When drawing at the bottom of the window**, use `gtx.Constraints.Max.Y - elementHeight - margin` to clamp to visible area.
|
- **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
|
- **Avoid** using `material.Label()` when you need precise glyph positions
|
||||||
- **Reuse** glyph data for measurement, hit testing, and rendering
|
- **Reuse** glyph data for measurement, hit testing, and rendering
|
||||||
- **Follow** Gio's `paintGlyph` as the reference implementation
|
- **Follow** Gio's `paintGlyph` as the reference implementation
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
package editor
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gioui.org/unit"
|
|
||||||
|
|
||||||
"pad/internal/ui"
|
"pad/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigEvent represents a window configuration change (resize, orientation).
|
// ConfigEvent represents a window configuration change (resize, orientation).
|
||||||
|
// Width and Height are in device-independent pixels (Dp).
|
||||||
type ConfigEvent struct {
|
type ConfigEvent struct {
|
||||||
Width unit.Dp
|
Width ui.Dp
|
||||||
Height unit.Dp
|
Height ui.Dp
|
||||||
}
|
}
|
||||||
|
|
||||||
// InputEvent represents a user input event routed to an element.
|
// InputEvent represents a user input event routed to an element.
|
||||||
|
|
@ -34,7 +33,8 @@ type Logic struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogic creates a new Logic instance.
|
// 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{
|
return &Logic{
|
||||||
state: NewState(screenWidth, screenHeight),
|
state: NewState(screenWidth, screenHeight),
|
||||||
configChan: make(chan ConfigEvent),
|
configChan: make(chan ConfigEvent),
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
package editor
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gioui.org/unit"
|
|
||||||
|
|
||||||
"pad/internal/ui"
|
"pad/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// State holds all application state owned by the logic goroutine.
|
// State holds all application state owned by the logic goroutine.
|
||||||
|
// All dimensions are in device-independent pixels (Dp).
|
||||||
type State struct {
|
type State struct {
|
||||||
ScreenWidth unit.Dp
|
ScreenWidth ui.Dp
|
||||||
ScreenHeight unit.Dp
|
ScreenHeight ui.Dp
|
||||||
Elems []ui.Element
|
Elems []ui.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState creates a new State with initial editor layout.
|
// 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{
|
return &State{
|
||||||
ScreenWidth: screenWidth,
|
ScreenWidth: screenWidth,
|
||||||
ScreenHeight: screenHeight,
|
ScreenHeight: screenHeight,
|
||||||
|
|
@ -23,15 +23,14 @@ func NewState(screenWidth, screenHeight unit.Dp) *State {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditorLayout computes regions for the editor page.
|
// EditorLayout computes regions for the editor page.
|
||||||
// It takes screen dimensions and returns []Element.
|
// It takes screen dimensions in Dp and returns []Element with regions in Dp.
|
||||||
func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element {
|
// 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 to avoid clipping on macOS round corners
|
||||||
margin := unit.Dp(10)
|
margin := ui.Dp(10)
|
||||||
|
|
||||||
// Compute StatusBar region
|
// Compute StatusBar region
|
||||||
// Line 1: filename (24 DP)
|
statusBarHeight := ui.Dp(48)
|
||||||
// Line 2: icons (24 DP)
|
|
||||||
statusBarHeight := unit.Dp(48)
|
|
||||||
statusBarRegion := ui.Region{
|
statusBarRegion := ui.Region{
|
||||||
X: margin,
|
X: margin,
|
||||||
Y: margin,
|
Y: margin,
|
||||||
|
|
@ -40,7 +39,7 @@ func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute BottomBar region (fixed height, at bottom of screen)
|
// Compute BottomBar region (fixed height, at bottom of screen)
|
||||||
bottomBarHeight := unit.Dp(24)
|
bottomBarHeight := ui.Dp(24)
|
||||||
bottomBarY := screenHeight - margin - bottomBarHeight
|
bottomBarY := screenHeight - margin - bottomBarHeight
|
||||||
bottomBarRegion := ui.Region{
|
bottomBarRegion := ui.Region{
|
||||||
X: margin,
|
X: margin,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ package ui
|
||||||
import "gioui.org/unit"
|
import "gioui.org/unit"
|
||||||
|
|
||||||
// Region defines a screen area in device-independent pixels (Dp).
|
// Region defines a screen area in device-independent pixels (Dp).
|
||||||
|
// All element positions and sizes use Dp for device independence.
|
||||||
type Region struct {
|
type Region struct {
|
||||||
X, Y unit.Dp
|
X, Y Dp
|
||||||
W, H unit.Dp
|
W, H Dp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element is the base interface for all UI elements.
|
// Element is the base interface for all UI elements.
|
||||||
|
|
@ -43,9 +44,10 @@ type InputEvent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigEvent represents a window configuration change (resize, orientation).
|
// ConfigEvent represents a window configuration change (resize, orientation).
|
||||||
|
// Width and Height are in device-independent pixels (Dp).
|
||||||
type ConfigEvent struct {
|
type ConfigEvent struct {
|
||||||
Width unit.Dp
|
Width Dp
|
||||||
Height unit.Dp
|
Height Dp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label displays static text.
|
// Label displays static text.
|
||||||
|
|
@ -83,10 +85,10 @@ type TextField struct {
|
||||||
Placeholder string
|
Placeholder string
|
||||||
Focused bool
|
Focused bool
|
||||||
Multiline bool
|
Multiline bool
|
||||||
ScrollOffset unit.Dp
|
ScrollOffset Dp
|
||||||
VisibleLines []Line
|
VisibleLines []Line
|
||||||
WordWrap bool
|
WordWrap bool
|
||||||
WrapWidth unit.Dp
|
WrapWidth Dp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tf TextField) Region() Region { return tf.region }
|
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 }
|
func (s Spacer) ID() string { return s.id }
|
||||||
|
|
||||||
// NewSpacer creates a visible Spacer element.
|
// NewSpacer creates a visible Spacer element.
|
||||||
func NewSpacer(height unit.Dp) Spacer {
|
func NewSpacer(height Dp) Spacer {
|
||||||
return Spacer{
|
return Spacer{
|
||||||
region: Region{H: height},
|
region: Region{H: height},
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -350,5 +352,5 @@ type Color struct {
|
||||||
|
|
||||||
// Theme holds styling defaults for the UI.
|
// Theme holds styling defaults for the UI.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
FontSize unit.Sp
|
FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,16 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import "gioui.org/unit"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Standard dimensions in Dp
|
// Standard dimensions in Dp
|
||||||
statusBarLineHeight = unit.Dp(24)
|
statusBarLineHeight = Dp(24)
|
||||||
statusBarFilenameLine = unit.Dp(24)
|
statusBarFilenameLine = Dp(24)
|
||||||
statusBarIconsLine = unit.Dp(24)
|
statusBarIconsLine = Dp(24)
|
||||||
bottomBarHeight = unit.Dp(24)
|
bottomBarHeight = Dp(24)
|
||||||
iconSize = unit.Dp(24)
|
iconSize = Dp(24)
|
||||||
iconGap = unit.Dp(36)
|
iconGap = Dp(36)
|
||||||
padding = unit.Dp(8)
|
padding = Dp(8)
|
||||||
buttonPadding = unit.Dp(8)
|
buttonPadding = Dp(8)
|
||||||
)
|
)
|
||||||
|
|
||||||
// EditorLayout computes regions for the editor page.
|
// NOTE: EditorLayout is defined in internal/editor/state.go.
|
||||||
// It takes screen dimensions and returns []Element.
|
// This file contains layout constants only.
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
|
||||||
|
|
@ -20,38 +19,65 @@ import (
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
theme Theme
|
theme Theme
|
||||||
shp *text.Shaper
|
shp *text.Shaper
|
||||||
|
scale float32 // pixels per DP, from gtx.Metric.PxPerDp
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Renderer.
|
// New creates a new Renderer.
|
||||||
func New(th Theme, shp *text.Shaper) *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).
|
// 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) {
|
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 {
|
for _, e := range elems {
|
||||||
fmt.Printf("[DEBUG Draw] elem type=%T visible=%v\n", e, e.Visible())
|
|
||||||
if !e.Visible() {
|
if !e.Visible() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch v := e.(type) {
|
switch v := e.(type) {
|
||||||
case Label:
|
case Label:
|
||||||
r.drawLabel(gtx, v)
|
r.drawLabel(gtx, v, initialConstraints)
|
||||||
case ListView:
|
case ListView:
|
||||||
r.drawListView(gtx, v)
|
r.drawListView(gtx, v, initialConstraints)
|
||||||
case StatusBar:
|
case StatusBar:
|
||||||
r.drawStatusBar(gtx, v)
|
r.drawStatusBar(gtx, v, initialConstraints)
|
||||||
case BottomBar:
|
case BottomBar:
|
||||||
r.drawBottomBar(gtx, v)
|
r.drawBottomBar(gtx, v, initialConstraints)
|
||||||
case Button:
|
case Button:
|
||||||
r.drawButton(gtx, v)
|
r.drawButton(gtx, v, initialConstraints)
|
||||||
default:
|
default:
|
||||||
// unknown element type, skip
|
// 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()
|
reg := l.Region()
|
||||||
th := material.NewTheme()
|
th := material.NewTheme()
|
||||||
th.Shaper = r.shp
|
th.Shaper = r.shp
|
||||||
|
|
@ -60,34 +86,34 @@ func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
|
||||||
size = r.theme.FontSize
|
size = r.theme.FontSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position label at region origin
|
// Convert region to pixels for Gio interop
|
||||||
gtx.Constraints.Min.X = int(reg.X)
|
gtx.Constraints.Min.X = int(r.toPx(reg.X))
|
||||||
gtx.Constraints.Min.Y = int(reg.Y)
|
gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
|
||||||
material.Label(th, size, l.Text).Layout(gtx)
|
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()
|
reg := lv.Region()
|
||||||
th := material.NewTheme()
|
th := material.NewTheme()
|
||||||
th.Shaper = r.shp
|
th.Shaper = r.shp
|
||||||
|
|
||||||
// Position list view at region origin
|
// Convert region to pixels for Gio interop
|
||||||
gtx.Constraints.Min.X = int(reg.X)
|
gtx.Constraints.Min.X = int(r.toPx(reg.X))
|
||||||
gtx.Constraints.Min.Y = int(reg.Y)
|
gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
|
||||||
for _, item := range lv.Items {
|
for _, item := range lv.Items {
|
||||||
material.Label(th, r.theme.FontSize, item.Text).Layout(gtx)
|
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()
|
reg := sb.Region()
|
||||||
th := material.NewTheme()
|
th := material.NewTheme()
|
||||||
th.Shaper = r.shp
|
th.Shaper = r.shp
|
||||||
|
|
||||||
// Clip to status bar region
|
// Clip to status bar region (convert Dp to pixels)
|
||||||
c := clip.Rect{
|
c := clip.Rect{
|
||||||
Min: image.Point{X: int(reg.X), Y: int(reg.Y)},
|
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
|
||||||
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)},
|
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
|
||||||
}.Push(gtx.Ops)
|
}.Push(gtx.Ops)
|
||||||
|
|
||||||
// Line 1: Filename (truncated with ellipsis if needed)
|
// 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
|
// Truncate filename only if it doesn't fit
|
||||||
displayFilename := sb.Filename
|
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
|
// Layout ellipsis once and get its width
|
||||||
th.Shaper.LayoutString(text.Parameters{
|
th.Shaper.LayoutString(text.Parameters{
|
||||||
|
|
@ -116,101 +142,89 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) {
|
||||||
spaceForText := availableWidth - ellipsisWidth
|
spaceForText := availableWidth - ellipsisWidth
|
||||||
|
|
||||||
// Layout filename, measure widths, and draw in a single pass
|
// 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.
|
// Line 2: Icons — positioned 20DP below filename baseline.
|
||||||
// This matches Gio's default line spacing (ascent+descent with LineHeightScale=1.0).
|
iconsLineY := filenameLineY + Dp(20)
|
||||||
iconsLineY := filenameLineY + unit.Dp(20)
|
|
||||||
|
|
||||||
// Left side: Cut, Copy, Paste icons (always visible)
|
// Left side: Cut, Copy, Paste icons (always visible)
|
||||||
leftX := reg.X + unit.Dp(8)
|
leftX := reg.X + Dp(8)
|
||||||
iconY := iconsLineY
|
iconY := iconsLineY
|
||||||
|
|
||||||
r.drawText(gtx, th.Shaper, "✂", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
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})
|
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})
|
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)
|
// 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 {
|
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})
|
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})
|
r.drawText(gtx, th.Shaper, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
||||||
|
|
||||||
c.Pop()
|
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()
|
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))
|
// Use captured constraints for positioning — they are in pixels and
|
||||||
fmt.Printf("[DEBUG drawBottomBar] constraints=(%d, %d, %d, %d)\n",
|
// represent the actual window bounds. Never use gtx.Constraints here
|
||||||
gtx.Constraints.Min.X, gtx.Constraints.Min.Y,
|
// because clips modify them.
|
||||||
gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
|
windowW := constraints.Max.X
|
||||||
|
windowH := constraints.Max.Y
|
||||||
|
|
||||||
// Clamp BottomBar Y to visible window area.
|
// Convert region to pixels for positioning
|
||||||
// The region Y may be outside the visible area (e.g. 3308 DP when window is 1688px).
|
regXPx := r.toPx(reg.X)
|
||||||
// gtx.Constraints.Max.Y is in physical pixels. Convert to DP using gtx.Metric.PxPerDp.
|
regWPx := r.toPx(reg.W)
|
||||||
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))
|
|
||||||
|
|
||||||
// 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
|
// Clamp right edge to window width
|
||||||
rightXPx := regXPx + int(float32(reg.W)*scale)
|
rightXPx := regXPx + regWPx
|
||||||
if rightXPx > windowW {
|
if rightXPx > Px(windowW) {
|
||||||
rightXPx = windowW
|
rightXPx = Px(windowW)
|
||||||
}
|
}
|
||||||
barW := rightXPx - regXPx
|
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
|
// Left: Cursor position
|
||||||
cursorXPx := regXPx + int(8*scale)
|
cursorXPx := regXPx + r.toPx(Dp(8))
|
||||||
cursorXDp := unit.Dp(float64(cursorXPx) / float64(scale))
|
cursorXDp := r.toDp(cursorXPx)
|
||||||
fmt.Printf("[DEBUG drawBottomBar] cursor=(%d, %d) text=%q\n", cursorXPx, drawYPx, bb.CursorPos)
|
|
||||||
r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
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
|
// Center: Byte position
|
||||||
byteXPx := regXPx + barW/2 - int(60*scale)
|
byteXPx := regXPx + barW/2 - r.toPx(Dp(60))
|
||||||
byteXDp := unit.Dp(float64(byteXPx) / float64(scale))
|
byteXDp := r.toDp(byteXPx)
|
||||||
fmt.Printf("[DEBUG drawBottomBar] byte=(%d, %d) text=%q\n", byteXPx, drawYPx, bb.BytePos)
|
|
||||||
r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
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
|
// Right: Word wrap button
|
||||||
wordWrapXPx := regXPx + barW - int(80*scale)
|
wordWrapXPx := regXPx + barW - r.toPx(Dp(80))
|
||||||
wordWrapXDp := unit.Dp(float64(wordWrapXPx) / float64(scale))
|
wordWrapXDp := r.toDp(wordWrapXPx)
|
||||||
fmt.Printf("[DEBUG drawBottomBar] wordwrap=(%d, %d) text=%q\n", wordWrapXPx, drawYPx, "W:"+boolToString(bb.WordWrap))
|
|
||||||
r.drawText(gtx, r.shp, "W:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
|
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()
|
reg := btn.Region()
|
||||||
th := material.NewTheme()
|
th := material.NewTheme()
|
||||||
th.Shaper = r.shp
|
th.Shaper = r.shp
|
||||||
|
|
||||||
// Position button at region origin
|
// Convert region to pixels for Gio interop
|
||||||
gtx.Constraints.Min.X = int(reg.X)
|
gtx.Constraints.Min.X = int(r.toPx(reg.X))
|
||||||
gtx.Constraints.Min.Y = int(reg.Y)
|
gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
|
||||||
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx)
|
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 {
|
func boolToString(b bool) string {
|
||||||
if b {
|
if b {
|
||||||
return "On"
|
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.
|
// 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.
|
// Layout text with width constraints so the shaper doesn't wrap every character.
|
||||||
shp.LayoutString(text.Parameters{
|
shp.LayoutString(text.Parameters{
|
||||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
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:
|
// drawText draws text using the same approach as Gio's textView:
|
||||||
// layout text, iterate glyphs, buffer into lines, and draw using shaper.Shape().
|
// 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.
|
// Layout text with width constraints so the shaper doesn't wrap every character.
|
||||||
shp.LayoutString(text.Parameters{
|
shp.LayoutString(text.Parameters{
|
||||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
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().
|
// 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
|
// Match Gio's textView: record into a macro for clipping
|
||||||
m := op.Record(gtx.Ops)
|
m := op.Record(gtx.Ops)
|
||||||
var glyphs [32]text.Glyph
|
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() {
|
for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
|
||||||
line = append(line, g)
|
line = append(line, g)
|
||||||
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
|
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]
|
line = line[:0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(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 := m.Stop()
|
||||||
call.Add(gtx.Ops)
|
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().
|
// 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).
|
// 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 {
|
if len(line) == 0 {
|
||||||
return
|
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:
|
// at the desired document position. Matches Gio's paintGlyph:
|
||||||
// lineOff = (glyph.X, glyph.Y) - viewport.Min
|
// lineOff = (glyph.X, glyph.Y) - viewport.Min
|
||||||
// op.Affine(f32.Affine2D{}.Offset(lineOff))
|
// op.Affine(f32.Affine2D{}.Offset(lineOff))
|
||||||
offX := float32(gtx.Dp(x)) + float32(first.X)/64.0
|
offX := float32(x) + float32(first.X)/64.0
|
||||||
offY := float32(gtx.Dp(y)) + float32(first.Y)
|
offY := float32(y) + float32(first.Y)
|
||||||
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
|
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
|
||||||
|
|
||||||
// Draw vector glyphs
|
// Draw vector glyphs
|
||||||
|
|
@ -327,26 +345,9 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl
|
||||||
t.Pop()
|
t.Pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// charWidths returns the cumulative width after each rune in str.
|
// WindowConstraints tracks the initial window constraints captured at the start of Draw.
|
||||||
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
|
// All elements are positioned relative to these bounds to ensure consistency.
|
||||||
// Match Gio's textView: PxPerEm = font size in device pixels
|
type WindowConstraints struct {
|
||||||
// Must set MinWidth/MaxWidth/MaxLines or shaper wraps every character.
|
Min image.Point
|
||||||
shp.LayoutString(text.Parameters{
|
Max image.Point
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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