diff --git a/cmd/pad/main.go b/cmd/pad/main.go index e54abdf..0186f7a 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "os" "gioui.org/app" "gioui.org/op" @@ -19,6 +20,7 @@ func main() { if err := run(w); err != nil { log.Fatal(err) } + os.Exit(0) }() app.Main() } @@ -29,29 +31,41 @@ func run(w *app.Window) error { shaper := text.NewShaper() renderer := ui.New(ui.Theme{FontSize: 14}, shaper) - elems := starterElements() + // Channel for window configuration events (resize, orientation) + configChan := make(chan ui.ConfigEvent) + + // Initial screen size + screenWidth := unit.Dp(390) + screenHeight := unit.Dp(844) + + // Compute initial elements + elems := ui.EditorLayout(screenWidth, screenHeight) for { switch e := w.Event().(type) { case app.DestroyEvent: return e.Err + case app.ConfigEvent: + // Send config event to logic goroutine (here, the main goroutine) + configChan <- ui.ConfigEvent{ + Width: unit.Dp(e.Config.Size.X), + Height: unit.Dp(e.Config.Size.Y), + } case app.FrameEvent: + // Process any pending config events + select { + case cfg := <-configChan: + // Update screen size and recompute layout + screenWidth = cfg.Width + screenHeight = cfg.Height + elems = ui.EditorLayout(screenWidth, screenHeight) + default: + // No config event, use existing elements + } + gtx := app.NewContext(&ops, e) renderer.Draw(gtx, elems) e.Frame(&ops) } } } - -// starterElements returns a hardcoded set of elements for initial testing. -func starterElements() []ui.Element { - return []ui.Element{ - ui.NewLabel("Pad", 20, ui.Region{X: 0, Y: 0, W: 390, H: 48}), - ui.NewListView(ui.Region{X: 0, Y: 48, W: 390, H: 700}, - []ui.ListItem{ - {Text: "notes.txt"}, - {Text: "ideas.txt"}, - {Text: "todo.txt"}, - }), - } -} diff --git a/internal/ui/element.go b/internal/ui/element.go index 7f50c07..b3c35a3 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -23,22 +23,57 @@ const ( AlignEnd ) +// InputType specifies the type of user input event. +type InputType int + +const ( + Tap InputType = iota + DoubleTap + LongPress + Scroll + KeyDown + KeyUp +) + +// InputEvent represents a user input event routed to an element. +type InputEvent struct { + ElementID string + Type InputType + Data any +} + +// ConfigEvent represents a window configuration change (resize, orientation). +type ConfigEvent struct { + Width unit.Dp + Height unit.Dp +} + // Label displays static text. type Label struct { - id string - region Region - visible bool - Text string - Align TextAlign + id string + region Region + visible bool + Text string + Align TextAlign FontSize unit.Sp // 0 = theme default - Color Color // 0 = theme default - Bold bool + Color Color // 0 = theme default + Bold bool } func (l Label) Region() Region { return l.region } func (l Label) Visible() bool { return l.visible } func (l Label) ID() string { return l.id } +// NewLabel creates a visible Label element. +func NewLabel(text string, fontSize unit.Sp, region Region) Label { + return Label{ + region: region, + visible: true, + Text: text, + FontSize: fontSize, + } +} + // TextField accepts text input or displays multiline text. type TextField struct { id string @@ -49,12 +84,21 @@ type TextField struct { Focused bool Multiline bool ScrollOffset unit.Dp + VisibleLines []Line + WordWrap bool + WrapWidth unit.Dp } func (tf TextField) Region() Region { return tf.region } func (tf TextField) Visible() bool { return tf.visible } func (tf TextField) ID() string { return tf.id } +// Line represents a single line of text in a multiline TextField. +type Line struct { + Text string + LineNumber int // 1-indexed, for display +} + // ListView displays a scrollable list of items. type ListView struct { id string @@ -76,6 +120,229 @@ type ListItem struct { Selected bool } +// AlphaIndex displays an alphabetical index for quick navigation. +type AlphaIndex struct { + id string + region Region + visible bool + Letters []string + ActiveLetter string +} + +func (ai AlphaIndex) Region() Region { return ai.region } +func (ai AlphaIndex) Visible() bool { return ai.visible } +func (ai AlphaIndex) ID() string { return ai.id } + +// NewAlphaIndex creates a visible AlphaIndex element. +func NewAlphaIndex(region Region, letters []string) AlphaIndex { + return AlphaIndex{ + region: region, + visible: true, + Letters: letters, + } +} + +// Button is an interactive button element. +type Button struct { + id string + region Region + visible bool + Text string + Enabled bool + Primary bool +} + +func (b Button) Region() Region { return b.region } +func (b Button) Visible() bool { return b.visible } +func (b Button) ID() string { return b.id } + +// NewButton creates a visible Button element. +func NewButton(text string, enabled bool, primary bool, region Region) Button { + return Button{ + region: region, + visible: true, + Text: text, + Enabled: enabled, + Primary: primary, + } +} + +// SearchBar displays an in-editor search interface. +type SearchBar struct { + id string + region Region + visible bool + Query string + Match int + Total int + Forward bool +} + +func (sb SearchBar) Region() Region { return sb.region } +func (sb SearchBar) Visible() bool { return sb.visible } +func (sb SearchBar) ID() string { return sb.id } + +// NewSearchBar creates a visible SearchBar element. +func NewSearchBar(region Region, query string, match, total int, forward bool) SearchBar { + return SearchBar{ + region: region, + visible: true, + Query: query, + Match: match, + Total: total, + Forward: forward, + } +} + +// Cursor displays the text cursor and optional selection highlight. +type Cursor struct { + id string + region Region + visible bool + Line int + Column int + Blinking bool + Selection *Selection +} + +func (c Cursor) Region() Region { return c.region } +func (c Cursor) Visible() bool { return c.visible } +func (c Cursor) ID() string { return c.id } + +// Selection represents a text selection range. +type Selection struct { + StartLine, StartCol int + EndLine, EndCol int +} + +// MergeHunk displays a conflict resolution hunk. +type MergeHunk struct { + id string + region Region + visible bool + HunkNumber int + TotalHunks int + LineRange string + ContextLines []string + OurLines []string + TheirLines []string + Resolution HunkResolution +} + +func (mh MergeHunk) Region() Region { return mh.region } +func (mh MergeHunk) Visible() bool { return mh.visible } +func (mh MergeHunk) ID() string { return mh.id } + +// HunkResolution represents the resolution state of a merge hunk. +type HunkResolution int + +const ( + Unresolved HunkResolution = iota + KeepOurs + KeepTheirs + MergeBoth +) + +// StatusBar displays the top bar with filename, action icons, and conflict indicator. +type StatusBar struct { + id string + region Region + visible bool + Filename string + FilenameExp bool + CutCopy bool + Copy bool + Paste bool + ConflictIcon bool + Search bool +} + +func (sb StatusBar) Region() Region { return sb.region } +func (sb StatusBar) Visible() bool { return sb.visible } +func (sb StatusBar) ID() string { return sb.id } + +// NewStatusBar creates a visible StatusBar element. +func NewStatusBar(region Region, filename string, filenameExp bool, cutCopy, copy, paste, conflictIcon, search bool) StatusBar { + return StatusBar{ + region: region, + visible: true, + Filename: filename, + FilenameExp: filenameExp, + CutCopy: cutCopy, + Copy: copy, + Paste: paste, + ConflictIcon: conflictIcon, + Search: search, + } +} + +// BottomBar displays the bottom bar with cursor position, byte position, and word wrap toggle. +type BottomBar struct { + id string + region Region + visible bool + CursorPos string + BytePos string + WordWrap bool +} + +func (bb BottomBar) Region() Region { return bb.region } +func (bb BottomBar) Visible() bool { return bb.visible } +func (bb BottomBar) ID() string { return bb.id } + +// NewBottomBar creates a visible BottomBar element. +func NewBottomBar(region Region, cursorPos, bytePos string, wordWrap bool) BottomBar { + return BottomBar{ + region: region, + visible: true, + CursorPos: cursorPos, + BytePos: bytePos, + WordWrap: wordWrap, + } +} + +// Toast displays a temporary notification. +type Toast struct { + id string + region Region + visible bool + Text string + Timeout int // milliseconds +} + +func (t Toast) Region() Region { return t.region } +func (t Toast) Visible() bool { return t.visible } +func (t Toast) ID() string { return t.id } + +// NewToast creates a visible Toast element. +func NewToast(region Region, text string, timeout int) Toast { + return Toast{ + region: region, + visible: true, + Text: text, + Timeout: timeout, + } +} + +// Spacer adds vertical or horizontal space. +type Spacer struct { + id string + region Region + visible bool +} + +func (s Spacer) Region() Region { return s.region } +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 { + return Spacer{ + region: Region{H: height}, + visible: true, + } +} + // Color is an RGBA color. type Color struct { R, G, B, A uint8 @@ -85,22 +352,3 @@ type Color struct { type Theme struct { FontSize unit.Sp } - -// NewLabel creates a visible Label element. -func NewLabel(text string, fontSize unit.Sp, region Region) Label { - return Label{ - region: region, - visible: true, - Text: text, - FontSize: fontSize, - } -} - -// NewListView creates a visible ListView element. -func NewListView(region Region, items []ListItem) ListView { - return ListView{ - region: region, - visible: true, - Items: items, - } -} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..0013757 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,61 @@ +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) +) + +// 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} +} diff --git a/internal/ui/render.go b/internal/ui/render.go index c6cbaac..898ff63 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -6,6 +6,7 @@ import ( "gioui.org/layout" "gioui.org/op/clip" "gioui.org/text" + "gioui.org/unit" "gioui.org/widget/material" ) @@ -31,6 +32,12 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) { r.drawLabel(gtx, v) case ListView: r.drawListView(gtx, v) + case StatusBar: + r.drawStatusBar(gtx, v) + case BottomBar: + r.drawBottomBar(gtx, v) + case Button: + r.drawButton(gtx, v) default: // unknown element type, skip } @@ -68,3 +75,99 @@ func (r *Renderer) drawListView(gtx layout.Context, lv ListView) { } c.Pop() } + +func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) { + reg := sb.Region() + th := material.NewTheme() + th.Shaper = r.shp + + // Line 1: Filename (truncated with ellipsis if needed) + filenameLineY := reg.Y + filenameLineH := unit.Dp(24) + filenameW := reg.W - unit.Dp(16) // 8 DP padding on each side + + // Truncate filename if needed + displayFilename := sb.Filename + // Simple truncation: if filename is too long, truncate it + if len(displayFilename) > 30 { + displayFilename = displayFilename[:27] + "..." + } + + clipFilename := clip.Rect{ + Min: image.Point{X: int(reg.X + unit.Dp(8)), Y: int(filenameLineY)}, + Max: image.Point{X: int(reg.X + filenameW), Y: int(filenameLineY + filenameLineH)}, + }.Push(gtx.Ops) + material.Label(th, r.theme.FontSize, displayFilename).Layout(gtx) + clipFilename.Pop() + + // Line 2: Icons + iconsLineY := filenameLineY + filenameLineH + + // Left side: Cut, Copy, Paste icons + leftX := reg.X + unit.Dp(8) + iconY := iconsLineY + + if sb.CutCopy { + r.drawIconButton(gtx, th, "✂", leftX, iconY) + leftX += unit.Dp(36) + } + if sb.Copy { + r.drawIconButton(gtx, th, "⎘", leftX, iconY) + leftX += unit.Dp(36) + } + if sb.Paste { + r.drawIconButton(gtx, th, "⎘", leftX, iconY) + leftX += unit.Dp(36) + } + + // Right side: Conflict, Search icons + rightX := reg.X + reg.W - unit.Dp(8) + if sb.Search { + rightX -= unit.Dp(36) + r.drawIconButton(gtx, th, "🔍", rightX, iconY) + } + if sb.ConflictIcon { + rightX -= unit.Dp(36) + r.drawIconButton(gtx, th, "⚠", rightX, iconY) + } +} + +func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar) { + th := material.NewTheme() + th.Shaper = r.shp + + // Left: Cursor position + material.Label(th, r.theme.FontSize, bb.CursorPos).Layout(gtx) + + // Center: Byte position + material.Label(th, r.theme.FontSize, bb.BytePos).Layout(gtx) + + // Right: Word wrap button + material.Label(th, r.theme.FontSize, "[Word Wrap: "+boolToString(bb.WordWrap)+"]").Layout(gtx) +} + +func (r *Renderer) drawButton(gtx layout.Context, btn Button) { + th := material.NewTheme() + th.Shaper = r.shp + + // Draw button text + 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) { + // Draw icon button + iconRegion := Region{X: x, Y: y, W: unit.Dp(24), H: unit.Dp(24)} + c := clip.Rect{ + Min: image.Point{X: int(iconRegion.X), Y: int(iconRegion.Y)}, + Max: image.Point{X: int(iconRegion.X + iconRegion.W), Y: int(iconRegion.Y + iconRegion.H)}, + }.Push(gtx.Ops) + material.Label(th, r.theme.FontSize, icon).Layout(gtx) + c.Pop() +} + +func boolToString(b bool) string { + if b { + return "On" + } + return "Off" +} diff --git a/pad b/pad new file mode 100755 index 0000000..b784a88 Binary files /dev/null and b/pad differ