feat: implement atomic writes for filesystem backend
- Add WriteFile method to mock filesystem (non-atomic path, creates files) - Implement WriteFileAtomic in mock with temp file + rename pattern using .tmp/ directory, matching real filesystem semantics - Update WriteFileTask.Execute() to use WriteFileAtomic - Update FlushAll() to use WriteFileAtomic - Fix mock to create files on write (matching os.WriteFile behavior) - Update tests to match new semantics (create-if-not-exists)
This commit is contained in:
parent
d73cb0be2c
commit
93a5f879f5
11
cmd/pad/impl_android.go
Normal file
11
cmd/pad/impl_android.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//+build !darwin !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "gioui.org/app/permission/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
startpath="/storage/emulated/0/Notes"
|
||||
)
|
||||
7
cmd/pad/impl_other.go
Normal file
7
cmd/pad/impl_other.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//+build !android
|
||||
|
||||
package main
|
||||
|
||||
var (
|
||||
startpath="."
|
||||
)
|
||||
|
|
@ -38,7 +38,7 @@ func run(w *app.Window) error {
|
|||
shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
|
||||
|
||||
// Determine root directory for real filesystem
|
||||
rootDir := flag.String("root", ".", "root directory for the filesystem")
|
||||
rootDir := flag.String("root", startpath, "root directory for the filesystem")
|
||||
flag.Parse()
|
||||
|
||||
abs, err := filepath.Abs(*rootDir)
|
||||
|
|
|
|||
|
|
@ -212,11 +212,11 @@ Key design points:
|
|||
### Phase 2: File IO Integration
|
||||
- [x] Implement `SaveFile` handler: Debounced auto-save via `WriteFileTask` to worker pool. — **DONE** (see §3)
|
||||
- [x] Implement `LoadFile` handler: Dispatch `ReadFileTask` to worker pool. — **DONE**
|
||||
- [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **UPDATED**: Added `<dirty>` indicator when `WriteFailed()`. status bar also needs "Saving..." indicator.
|
||||
- [x] Status bar integration: Show "Saving..." indicator, "Modified" status. — **DONE**
|
||||
|
||||
### Phase 3: Text Editing Operations
|
||||
- [ ] Implement Cut/Copy/Paste interactions. — **NOT STARTED** (icon elements exist but handlers are `nil`)
|
||||
- [ ] Implement multi-line text navigation (Home/End, PageUp/PageDown). — **NOT STARTED**
|
||||
- [x] Implement multi-line text navigation (Home/End, PageUp/PageDown). — **DONE**
|
||||
- [ ] Implement text selection display and mouse interaction. — **NOT STARTED** (`SelectionStart`/`SelectionEnd` fields exist in `EditorState` but are unused)
|
||||
- [x] Implement soft-wrap toggle. — **DONE** (`ToggleWordWrap` in `state.go`, wired to bottom bar "Wrap" label tap)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"pad/internal/io/pool"
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
// TestWriteFailureTracking verifies that a write failure updates WriteFailed status
|
||||
|
|
@ -45,7 +46,11 @@ func TestAutoSave_RetryFails(t *testing.T) {
|
|||
l.state.Editor.Buffer = "hello"
|
||||
|
||||
// Mock FS to fail on Write
|
||||
l.mockFS.SetWriteError(true)
|
||||
mfs, ok := l.mockFS.(*mock.FileSystem)
|
||||
if !ok {
|
||||
t.Fatal("mockFS is not a *mock.FileSystem")
|
||||
}
|
||||
mfs.SetWriteError(true)
|
||||
|
||||
// 1. Manually dispatch a write task
|
||||
task := pool.NewWriteFileTask(filename, []byte("hello"), l.mockFS)
|
||||
|
|
|
|||
54
internal/editor/filename_test.go
Normal file
54
internal/editor/filename_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
func TestEditorLayout_Filename(t *testing.T) {
|
||||
// Setup: Reset state
|
||||
TheState = NewState()
|
||||
|
||||
// Set a filename
|
||||
expectedFilename := "test.txt"
|
||||
TheState.Editor.Filename = expectedFilename
|
||||
|
||||
// Run layout
|
||||
screenWidth := ui.Dp(400)
|
||||
screenHeight := ui.Dp(800)
|
||||
elements := EditorLayout(screenWidth, screenHeight, false)
|
||||
|
||||
// Find top bar (assuming it's the first container element)
|
||||
topBar := elements[0].(ui.Container)
|
||||
|
||||
// Find filename label (assuming it's the first label in the top bar)
|
||||
filenameLabel := topBar.Children[0].(ui.Label)
|
||||
|
||||
if filenameLabel.Text != expectedFilename {
|
||||
t.Errorf("expected filename %q, got %q", expectedFilename, filenameLabel.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorLayout_DefaultFilename(t *testing.T) {
|
||||
// Setup: Reset state
|
||||
TheState = NewState()
|
||||
|
||||
// Filename is empty
|
||||
TheState.Editor.Filename = ""
|
||||
|
||||
// Run layout
|
||||
screenWidth := ui.Dp(400)
|
||||
screenHeight := ui.Dp(800)
|
||||
elements := EditorLayout(screenWidth, screenHeight, false)
|
||||
|
||||
// Find top bar (assuming it's the first container element)
|
||||
topBar := elements[0].(ui.Container)
|
||||
|
||||
// Find filename label (assuming it's the first label in the top bar)
|
||||
filenameLabel := topBar.Children[0].(ui.Label)
|
||||
|
||||
expectedDefault := "untitled.txt"
|
||||
if filenameLabel.Text != expectedDefault {
|
||||
t.Errorf("expected default filename %q, got %q", expectedDefault, filenameLabel.Text)
|
||||
}
|
||||
}
|
||||
|
|
@ -316,7 +316,7 @@ func (l *Logic) FlushAll() {
|
|||
// Trigger a synchronous write for the active dirty file
|
||||
content := l.state.Editor.Buffer
|
||||
// In a real app, this would be a blocking call to the FS
|
||||
l.mockFS.WriteFile(filename, []byte(content))
|
||||
l.mockFS.WriteFileAtomic(filename, []byte(content))
|
||||
l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ type EditorState struct {
|
|||
retryAttempts map[string]int // Added: tracks retry attempts
|
||||
}
|
||||
|
||||
// IsSaving returns true if a save is pending.
|
||||
func (e *EditorState) IsSaving() bool {
|
||||
return e.saveTimer != nil
|
||||
}
|
||||
|
||||
// IsDirty, WriteFailed, and RetryAttempts are computed:
|
||||
|
||||
func (e *EditorState) IsDirty() bool {
|
||||
|
|
@ -275,11 +280,105 @@ func HandleKeyDown(data any) {
|
|||
HandleDelete()
|
||||
case key.NameReturn:
|
||||
HandleInsert("\n")
|
||||
case key.NameHome:
|
||||
HandleHome()
|
||||
case key.NameEnd:
|
||||
HandleEnd()
|
||||
case key.NamePageUp:
|
||||
HandlePageUpDown(true)
|
||||
case key.NamePageDown:
|
||||
HandlePageUpDown(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleVerticalCursorMove updates the cursor position to the previous or next visual line.
|
||||
// HandleHome moves the cursor to the start of the current visual line.
|
||||
func HandleHome() {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
if len(layout.ByteOffsets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pos := TheState.Editor.CursorPosition
|
||||
// Find current glyph index.
|
||||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||||
return layout.ByteOffsets[i] >= pos
|
||||
})
|
||||
if idx == len(layout.ByteOffsets) {
|
||||
idx = len(layout.ByteOffsets) - 1
|
||||
}
|
||||
|
||||
currentY := layout.Y[idx]
|
||||
// Find first glyph on currentY.
|
||||
targetIdx := idx
|
||||
for i := idx; i >= 0; i-- {
|
||||
if layout.Y[i] == currentY {
|
||||
targetIdx = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx]
|
||||
}
|
||||
|
||||
// HandleEnd moves the cursor to the end of the current visual line.
|
||||
func HandleEnd() {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
if len(layout.ByteOffsets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pos := TheState.Editor.CursorPosition
|
||||
// Find current glyph index.
|
||||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||||
return layout.ByteOffsets[i] >= pos
|
||||
})
|
||||
if idx == len(layout.ByteOffsets) {
|
||||
idx = len(layout.ByteOffsets) - 1
|
||||
}
|
||||
|
||||
currentY := layout.Y[idx]
|
||||
// Find last glyph on currentY.
|
||||
targetIdx := idx
|
||||
for i := idx; i < len(layout.Y); i++ {
|
||||
if layout.Y[i] == currentY {
|
||||
targetIdx = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Position after the last character of the line.
|
||||
// If it's a newline, it's the newline itself.
|
||||
start := layout.ByteOffsets[targetIdx]
|
||||
r, size := utf8.DecodeRuneInString(TheState.Editor.Buffer[start:])
|
||||
if r == '\n' {
|
||||
TheState.Editor.CursorPosition = start
|
||||
} else {
|
||||
TheState.Editor.CursorPosition = start + size
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePageUpDown scrolls and moves the cursor.
|
||||
func HandlePageUpDown(up bool) {
|
||||
// For now, simple scrolling. Cursor movement could be added later.
|
||||
pageSize := TheState.MaxScroll / 4 // Or some fraction
|
||||
if pageSize < EditorLineHeight() {
|
||||
pageSize = EditorLineHeight()
|
||||
}
|
||||
|
||||
if up {
|
||||
TheState.ScrollOffset -= pageSize
|
||||
if TheState.ScrollOffset < 0 {
|
||||
TheState.ScrollOffset = 0
|
||||
}
|
||||
} else {
|
||||
TheState.ScrollOffset += pageSize
|
||||
if TheState.ScrollOffset > TheState.MaxScroll {
|
||||
TheState.ScrollOffset = TheState.MaxScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
func HandleVerticalCursorMove(up bool) {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
if len(layout.ByteOffsets) == 0 {
|
||||
|
|
@ -416,7 +515,10 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
H: ui.Dp(52),
|
||||
}
|
||||
statusBarW := statusBarRegion.W
|
||||
filename := "untitled.txt" // Needs to be managed in EditorState
|
||||
filename := TheState.Editor.Filename
|
||||
if filename == "" {
|
||||
filename = "untitled.txt"
|
||||
}
|
||||
statusBar := ui.NewContainer(
|
||||
statusBarRegion,
|
||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||
|
|
@ -445,11 +547,20 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
if wordWrap {
|
||||
wrapText = "Wrap: On"
|
||||
}
|
||||
statusText := "Saved"
|
||||
if TheState.Editor.IsSaving() {
|
||||
statusText = "Saving..."
|
||||
} else if TheState.Editor.WriteFailed() {
|
||||
statusText = "Error"
|
||||
} else if TheState.Editor.IsDirty() {
|
||||
statusText = "Modified"
|
||||
}
|
||||
|
||||
bottomBar := ui.NewContainer(
|
||||
bottomBarRegion,
|
||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||
[]ui.Element{
|
||||
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
||||
ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
||||
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
|
||||
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
|
||||
{Gesture: ui.Tap, Handler: ToggleWordWrap},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type FileSystem interface {
|
|||
FileExists(path string) bool
|
||||
ReadFile(path string) ([]byte, error)
|
||||
WriteFile(path string, content []byte) error
|
||||
WriteFileAtomic(path string, content []byte) error
|
||||
DeleteFile(path string) error
|
||||
CreateDir(path string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ package mock
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
|
@ -182,15 +181,10 @@ func (fs *FileSystem) ReadFile(path string) ([]byte, error) {
|
|||
return content, nil
|
||||
}
|
||||
|
||||
// WriteFile replaces the content of a file at the given path.
|
||||
// WriteFile writes or replaces the content of a file at the given path.
|
||||
// This is the non-atomic path — use WriteFileAtomic for safety.
|
||||
func (fs *FileSystem) WriteFile(path string, content []byte) error {
|
||||
fs.mu.Lock()
|
||||
log.Printf("FileSystem: WriteFile to %s, writeError=%v", path, fs.writeError)
|
||||
|
||||
if fs.writeError {
|
||||
fs.mu.Unlock()
|
||||
return fmt.Errorf("simulated write error")
|
||||
}
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
|
|
@ -198,19 +192,108 @@ func (fs *FileSystem) WriteFile(path string, content []byte) error {
|
|||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
f, ok := fs.files[path]
|
||||
if !ok {
|
||||
if fs.writeError {
|
||||
fs.mu.Unlock()
|
||||
return fmt.Errorf("file not found: %s", path)
|
||||
return fmt.Errorf("simulated write error")
|
||||
}
|
||||
|
||||
f.Content = content
|
||||
f.Size = int64(len(content))
|
||||
f.ModTime = time.Now()
|
||||
// Determine if this is a create or modify for notification purposes
|
||||
isNew := !fs.fileExists(path)
|
||||
|
||||
name := filepath.Base(path)
|
||||
now := time.Now()
|
||||
fs.files[path] = &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Content: content,
|
||||
ModTime: now,
|
||||
Size: int64(len(content)),
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Modified"})
|
||||
events = append(events, notifyEvent{path, ifElse(isNew, "Created", "Modified")})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
fs.sendNotifications(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists checks if a file (not directory) exists at the given path.
|
||||
// Caller must hold fs.mu.
|
||||
func (fs *FileSystem) fileExists(path string) bool {
|
||||
f, ok := fs.files[path]
|
||||
return ok && !f.IsDir
|
||||
}
|
||||
|
||||
func ifElse(cond bool, a, b string) string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// WriteFileAtomic replaces the content of a file atomically via a temp file + rename.
|
||||
// Temp files live in a top-level .tmp/ directory.
|
||||
func (fs *FileSystem) WriteFileAtomic(path string, content []byte) error {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
if fs.writeError {
|
||||
fs.mu.Unlock()
|
||||
return fmt.Errorf("simulated write error")
|
||||
}
|
||||
|
||||
// Determine if this is a create or modify for notification purposes
|
||||
isNew := !fs.fileExists(path)
|
||||
|
||||
// Temp file path: .tmp/<basename>
|
||||
tempPath := ".tmp/" + filepath.Base(path)
|
||||
|
||||
// Ensure .tmp/ directory exists
|
||||
if _, ok := fs.files[".tmp"]; !ok {
|
||||
fs.files[".tmp"] = &File{
|
||||
Path: ".tmp",
|
||||
Name: ".tmp",
|
||||
ModTime: time.Now(),
|
||||
IsDir: true,
|
||||
}
|
||||
}
|
||||
|
||||
name := filepath.Base(path)
|
||||
now := time.Now()
|
||||
|
||||
// 1. Write to temp location
|
||||
fs.files[tempPath] = &File{
|
||||
Path: tempPath,
|
||||
Name: name + ".tmp",
|
||||
Content: content,
|
||||
ModTime: now,
|
||||
Size: int64(len(content)),
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
// 2. Atomic rename: insert target + delete temp in one locked operation
|
||||
fs.files[path] = &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Content: content,
|
||||
ModTime: now,
|
||||
Size: int64(len(content)),
|
||||
IsDir: false,
|
||||
}
|
||||
delete(fs.files, tempPath)
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, ifElse(isNew, "Created", "Modified")})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -71,12 +71,20 @@ func TestFileSystem_WriteFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_WriteFile_NotFound(t *testing.T) {
|
||||
func TestFileSystem_WriteFile_CreatesFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
err := fs.WriteFile("/nonexistent.txt", []byte("data"))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent file, got nil")
|
||||
err := fs.WriteFile("/newfile.txt", []byte("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile should create file, got error: %v", err)
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile("/newfile.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("File should exist after write: %v", err)
|
||||
}
|
||||
if string(content) != "data" {
|
||||
t.Errorf("Content = %q, want %q", string(content), "data")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,16 @@ func (fs *RealFileSystem) ReadFile(path string) ([]byte, error) {
|
|||
}
|
||||
|
||||
func (fs *RealFileSystem) WriteFile(path string, content []byte) error {
|
||||
// Simple write for backward compatibility if needed,
|
||||
// but defer to Atomic implementation.
|
||||
return fs.WriteFileAtomic(path, content)
|
||||
}
|
||||
|
||||
func (fs *RealFileSystem) WriteFileAtomic(path string, content []byte) error {
|
||||
// Atomic write implementation
|
||||
fullPath := filepath.Join(fs.Root, path)
|
||||
tmpPath := filepath.Join(fs.Root, ".tmp", filepath.Base(path))
|
||||
// Temp file in the same directory as the target to ensure same filesystem rename
|
||||
tmpPath := filepath.Join(filepath.Dir(fullPath), "."+filepath.Base(path)+".tmp")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ func NewWriteFileTask(path string, data []byte, fs FileSystem) *WriteFileTask {
|
|||
}
|
||||
|
||||
func (t *WriteFileTask) Execute() Result {
|
||||
err := t.FS.WriteFile(t.Path, t.Data)
|
||||
err := t.FS.WriteFileAtomic(t.Path, t.Data)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
|
|
|
|||
|
|
@ -146,14 +146,23 @@ func TestWriteFileTask_Execute(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWriteFileTask_Execute_NotFound(t *testing.T) {
|
||||
func TestWriteFileTask_Execute_CreatesFile(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewWriteFileTask("/nonexistent.txt", []byte("data"), fs)
|
||||
task := NewWriteFileTask("/newfile.txt", []byte("data"), fs)
|
||||
result := task.Execute()
|
||||
|
||||
if result.IsSuccess() {
|
||||
t.Fatal("Expected failure for nonexistent file")
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success (write creates file), got error: %v", result.Error)
|
||||
}
|
||||
|
||||
// Verify the file was actually created
|
||||
content, err := fs.ReadFile("/newfile.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("File should exist after write: %v", err)
|
||||
}
|
||||
if string(content) != "data" {
|
||||
t.Errorf("Content = %q, want %q", string(content), "data")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ type Container struct {
|
|||
region Region
|
||||
visible bool
|
||||
background Color
|
||||
children []Element
|
||||
Children []Element // Changed to Exported
|
||||
}
|
||||
|
||||
func (c Container) Type() string { return "container" }
|
||||
|
|
@ -61,8 +61,8 @@ func (c Container) Draw(gtx layout.Context, r *Renderer) {
|
|||
// String returns a string representation of the Container and all children.
|
||||
func (c Container) String() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Container[%s] region=%+v bg=%#v children=%d", c.id, c.region, c.background, len(c.children)))
|
||||
for i, child := range c.children {
|
||||
sb.WriteString(fmt.Sprintf("Container[%s] region=%+v bg=%#v children=%d", c.id, c.region, c.background, len(c.Children)))
|
||||
for i, child := range c.Children {
|
||||
if str, ok := any(child).(fmt.Stringer); ok {
|
||||
sb.WriteString(fmt.Sprintf("\n [%d] %s", i, str.String()))
|
||||
} else {
|
||||
|
|
@ -78,7 +78,7 @@ func NewContainer(region Region, bg Color, children []Element) Container {
|
|||
region: region,
|
||||
visible: true,
|
||||
background: bg,
|
||||
children: children,
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
|||
}.Push(gtx.Ops)
|
||||
e.Draw(gtx, r) // draw container background
|
||||
offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
|
||||
for _, child := range container.children {
|
||||
for _, child := range container.Children {
|
||||
r.drawElement(gtx, child)
|
||||
}
|
||||
offset.Pop()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user