diff --git a/internal/editor/logic.go b/internal/editor/logic.go
index 74ab48a..b35d0c2 100644
--- a/internal/editor/logic.go
+++ b/internal/editor/logic.go
@@ -105,8 +105,10 @@ func (l *Logic) Run() {
update.apply(l.state)
l.frameChan <- l.state.layout()
case y := <-l.lastLineYChan:
- l.state.LastLineY = ui.Dp(y)
- l.frameChan <- l.state.layout()
+ if ui.Dp(y) != l.state.LastLineY {
+ l.state.LastLineY = ui.Dp(y)
+ l.frameChan <- l.state.layout()
+ }
case events := <-l.inputChan:
for _, evt := range events {
evt.Handler(evt.Data)
diff --git a/internal/editor/state.go b/internal/editor/state.go
index 92bc766..9122eca 100644
--- a/internal/editor/state.go
+++ b/internal/editor/state.go
@@ -4,6 +4,10 @@ import (
"pad/internal/ui"
)
+func init() {
+ ui.OpenFile = OpenFile
+}
+
// SampleText is a static lorem-ipsum text used for display-only testing.
// Roughly 3 KB, filling a few pages of the editor.
const SampleText = `
@@ -36,20 +40,48 @@ func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
}
+// Page identifies which page the app is showing.
+type Page int
+
+const (
+ BrowserPage Page = iota
+ EditorPage
+)
+
+// SortMode controls how the browser list is sorted.
+type SortMode int
+
+const (
+ SortByDateNewest SortMode = iota // default: newest modified first
+ SortByNameAsc
+)
+
// State holds all application state owned by the logic goroutine.
type State struct {
- PixelWidth int // raw pixel width from Gio ConfigEvent
- PixelHeight int // raw pixel height from Gio ConfigEvent
+ PixelWidth int // raw pixel width from Gio ConfigEvent
+ PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
+ page Page // current page (Browser or Editor)
WordWrap bool
- ScrollOffset ui.Dp // vertical scroll position in Dp
- LastLineY ui.Dp // last line baseline offset from text origin, from renderer
- MaxScroll ui.Dp // max scroll offset (content height - viewport height)
+ ScrollOffset ui.Dp // vertical scroll position in Dp
+ LastLineY ui.Dp // last line baseline offset from text origin, from renderer
+ MaxScroll ui.Dp // max scroll offset (content height - viewport height)
Elems []ui.Element
+ // Browser state
+ BrowserScrollOffset int // index of first visible browser entry
+ SortMode SortMode // sort mode for browser list
+ SortOrderLabel string // label text for sort order toggle
+ // Editor state
+ ActiveFilename string // filename shown in editor status bar
}
func NewState() *State {
- return &State{scale: 1.0}
+ return &State{
+ scale: 1.0,
+ page: EditorPage,
+ SortMode: SortByDateNewest,
+ SortOrderLabel: "Date",
+ }
}
func (s *State) SetScale(scale float32) {
@@ -65,7 +97,12 @@ func (s *State) Scale() float32 {
func (s *State) layout() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
- s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
+ switch s.page {
+ case BrowserPage:
+ s.Elems = BrowserLayout(dpW, dpH, s.SortMode)
+ case EditorPage:
+ s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
+ }
return s.Elems
}
@@ -88,6 +125,74 @@ func HandleScroll(data any) {
}
}
+// HandleBrowserScroll updates the browser list scroll offset.
+// The delta is in pixels; convert to Dp, then to row index offset.
+func HandleBrowserScroll(data any) {
+ delta := data.(int) // pixels
+ deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
+ rowHeight := ui.Dp(48)
+ rowDelta := int(deltaDp / rowHeight)
+ // Ensure at least ±1 for non-zero scroll events
+ if rowDelta == 0 && delta != 0 {
+ if delta > 0 {
+ rowDelta = 1
+ } else {
+ rowDelta = -1
+ }
+ }
+ TheState.BrowserScrollOffset += rowDelta
+ if TheState.BrowserScrollOffset < 0 {
+ TheState.BrowserScrollOffset = 0
+ }
+ maxOffset := len(browserEntries) - visibleBrowserRows(TheState.PixelHeight, TheState.scale)
+ if TheState.BrowserScrollOffset > maxOffset {
+ TheState.BrowserScrollOffset = maxOffset
+ }
+}
+
+// visibleBrowserRows estimates how many browser rows fit in the viewport.
+func visibleBrowserRows(pixelHeight int, scale float32) int {
+ margin := 10
+ headerHeight := 24
+ searchHeight := 36
+ gap := 3 // margin/2 * 2 in Dp
+ contentHeight := ui.ToDp(ui.Px(pixelHeight), float32(scale)) - ui.Dp(margin*2+headerHeight+searchHeight+gap)
+ rowHeight := ui.Dp(48)
+ rows := int(contentHeight / rowHeight)
+ if rows < 1 {
+ rows = 1
+ }
+ return rows
+}
+
+// GoToBrowser switches the app to the browser page.
+func GoToBrowser(data any) {
+ TheState.page = BrowserPage
+}
+
+// GoToEditor switches the app to the editor page.
+func GoToEditor(data any) {
+ TheState.page = EditorPage
+}
+
+// OpenFile sets the active filename and switches to the editor page.
+// data is the filename string from the browser list.
+func OpenFile(data any) {
+ TheState.ActiveFilename = data.(string)
+ TheState.page = EditorPage
+}
+
+// ToggleSortOrder cycles the browser sort mode between date and name.
+func ToggleSortOrder(data any) {
+ TheState.SortMode = (TheState.SortMode + 1) % 2
+ switch TheState.SortMode {
+ case SortByDateNewest:
+ TheState.SortOrderLabel = "Date"
+ case SortByNameAsc:
+ TheState.SortOrderLabel = "Name"
+ }
+}
+
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10)
@@ -99,16 +204,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
H: ui.Dp(52),
}
statusBarW := statusBarRegion.W
+ filename := TheState.ActiveFilename
+ if filename == "" {
+ filename = "untitled.txt"
+ }
statusBar := ui.NewContainer(
statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
// Row 1: filename
- ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
- // Row 2: cut, copy, paste icons
- ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
- ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
- ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
+ ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
+ // Row 2: back, cut, copy, paste icons
+ ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0,
+ []ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
+ ui.NewIcon("cut", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
+ ui.NewIcon("copy", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
+ ui.NewIcon("paste", ui.Region{X: ui.Dp(144), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
},
)
@@ -155,6 +266,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
TheState.MaxScroll = maxScroll
editor := ui.NewTextField(
+ "editor_text",
SampleText,
editorRegion,
editorRegion.W,
@@ -164,3 +276,117 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
return []ui.Element{statusBar, editor, bottomBar}
}
+
+// browserEntries is a static list of sample files for the browser page.
+// It is longer than the viewport so scrolling can be tested.
+var browserEntries = []ui.ListItem{
+ {Text: "notes-2025-01-15.txt", Subtext: "2025-01-15 • 4.2 KB"},
+ {Text: "meeting-2025-01-14.txt", Subtext: "2025-01-14 • 1.8 KB"},
+ {Text: "todo-2025-01-14.txt", Subtext: "2025-01-14 • 892 B"},
+ {Text: "ideas-2025-01-13.txt", Subtext: "2025-01-13 • 2.1 KB"},
+ {Text: "diary-2025-01-12.txt", Subtext: "2025-01-12 • 3.5 KB"},
+ {Text: "bookmarks-2025-01-11.txt", Subtext: "2025-01-11 • 6.7 KB"},
+ {Text: "inbox-2025-01-10.txt", Subtext: "2025-01-10 • 12 KB"},
+ {Text: "archive-2025-01-09.txt", Subtext: "2025-01-09 • 28 KB"},
+ {Text: "draft-article.txt", Subtext: "2025-01-08 • 5.3 KB"},
+ {Text: "recipe-cole-sl.txt", Subtext: "2025-01-07 • 1.1 KB"},
+ {Text: "project-plan.txt", Subtext: "2025-01-06 • 8.4 KB"},
+ {Text: "weekly-review.txt", Subtext: "2025-01-05 • 2.9 KB"},
+ {Text: "changelog-v2.txt", Subtext: "2025-01-04 • 15 KB"},
+ {Text: "README.txt", Subtext: "2025-01-03 • 512 B"},
+ {Text: "scratch-pad.txt", Subtext: "2025-01-02 • 736 B"},
+ {Text: "old-notes.txt", Subtext: "2025-01-01 • 3.1 KB"},
+ {Text: "backup-jan.txt", Subtext: "2024-12-31 • 42 KB"},
+ {Text: "annual-review.txt", Subtext: "2024-12-30 • 9.8 KB"},
+ {Text: "december-log.txt", Subtext: "2024-12-29 • 7.2 KB"},
+ {Text: "random-thoughts.txt", Subtext: "2024-12-28 • 1.5 KB"},
+ {Text: "shopping-list.txt", Subtext: "2024-12-27 • 248 B"},
+ {Text: "travel-ideas.txt", Subtext: "2024-12-26 • 2.3 KB"},
+ {Text: "music-queue.txt", Subtext: "2024-12-25 • 4.7 KB"},
+ {Text: "movie-wishlist.txt", Subtext: "2024-12-24 • 890 B"},
+ {Text: "book-read-list.txt", Subtext: "2024-12-23 • 3.6 KB"},
+ {Text: "podcast-notes.txt", Subtext: "2024-12-22 • 5.1 KB"},
+ {Text: "quotes.txt", Subtext: "2024-12-21 • 6.2 KB"},
+ {Text: "vocab.txt", Subtext: "2024-12-20 • 1.9 KB"},
+ {Text: "word-of-day.txt", Subtext: "2024-12-19 • 384 B"},
+ {Text: "memories.txt", Subtext: "2024-12-18 • 11 KB"},
+}
+
+// BrowserLayout computes the element tree for the browser (file listing) page.
+func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Element {
+ margin := ui.Dp(10)
+ contentWidth := screenWidth - margin*2
+
+ // --- Header: directory name + sort toggle ---
+ headerHeight := ui.Dp(24)
+ headerRegion := ui.Region{
+ X: margin, Y: margin,
+ W: contentWidth, H: headerHeight,
+ }
+ header := ui.NewLabel("My Documents", 16, headerRegion, ui.AlignStart, "", nil)
+
+ // --- Sort toggle ---
+ var sortLabel string
+ switch sortMode {
+ case SortByDateNewest:
+ sortLabel = "Date ↓"
+ case SortByNameAsc:
+ sortLabel = "Name ↑"
+ }
+ sortRegion := ui.Region{
+ X: contentWidth - ui.Dp(60), Y: ui.Dp(2), W: ui.Dp(60), H: headerHeight,
+ }
+ sortToggle := ui.NewLabel(sortLabel, 12, sortRegion, ui.AlignEnd, "sort",
+ []ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}})
+
+ // --- Search bar ---
+ searchHeight := ui.Dp(36)
+ searchY := headerRegion.Y + headerRegion.H + margin/2
+ searchRegion := ui.Region{
+ X: margin, Y: searchY,
+ W: contentWidth, H: searchHeight,
+ }
+ searchBar := ui.NewTextField(
+ "search_bar", // id
+ "", // empty query
+ searchRegion,
+ searchRegion.W,
+ ui.Dp(0),
+ nil,
+ )
+ // Override placeholder for search bar — TextField doesn't have a dedicated
+ // search variant yet, so we use a Label as a visual placeholder for now.
+ searchPlaceholder := ui.NewLabel("Search…", 14,
+ ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(2), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(4)},
+ ui.AlignStart, "", nil)
+
+ // --- ListView ---
+ listY := searchY + searchHeight + margin/2
+ listHeight := screenHeight - listY - margin // fill remaining height
+ listRegion := ui.Region{
+ X: margin, Y: listY,
+ W: contentWidth, H: listHeight,
+ }
+ visibleEntries := browserEntries[TheState.BrowserScrollOffset:]
+ list := ui.NewListView(
+ "browser_list",
+ visibleEntries,
+ listRegion,
+ TheState.BrowserScrollOffset,
+ -1, // Selected: none
+ []ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}},
+ )
+ // Set row filenames so ListView.Draw can wire up click handlers
+ list.RowFilenames = make([]string, len(visibleEntries))
+ for i, entry := range visibleEntries {
+ list.RowFilenames[i] = entry.Text
+ }
+
+ return []ui.Element{
+ header,
+ sortToggle,
+ searchBar,
+ searchPlaceholder,
+ list,
+ }
+}
diff --git a/internal/ui/element.go b/internal/ui/element.go
index 4f42f73..efbe344 100644
--- a/internal/ui/element.go
+++ b/internal/ui/element.go
@@ -1,7 +1,11 @@
package ui
import (
+ "fmt"
+ "image"
+
"gioui.org/layout"
+ "gioui.org/op/clip"
"gioui.org/unit"
)
@@ -107,6 +111,15 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
if img == nil {
return
}
+ // Register click area if this icon has interactions
+ if cr, ok := r.clicks[i.id]; ok && cr.click != nil {
+ clickClip := clip.Rect{
+ Min: image.Point{X: int(r.toPx(i.region.X)), Y: int(r.toPx(i.region.Y))},
+ Max: image.Point{X: int(r.toPx(i.region.X+i.region.W)), Y: int(r.toPx(i.region.Y+i.region.H))},
+ }.Push(gtx.Ops)
+ cr.click.Add(gtx.Ops)
+ clickClip.Pop()
+ }
// Auto-scale: if Size is 0, use region dimensions so the icon fills its region
w, h := Dp(0), Dp(0)
if i.Size == 0 {
@@ -117,12 +130,14 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
func (i Icon) ID() string { return i.id }
// NewIcon creates a visible Icon element.
-func NewIcon(name string, region Region, size Dp) Icon {
+func NewIcon(name string, region Region, size Dp, interactions []Interaction) Icon {
return Icon{
- region: region,
- visible: true,
- Name: name,
- Size: size,
+ id: name,
+ region: region,
+ visible: true,
+ interactions: interactions,
+ Name: name,
+ Size: size,
}
}
@@ -154,8 +169,9 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
}
// NewTextField creates a visible multiline TextField.
-func NewTextField(value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField {
+func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField {
return TextField{
+ id: id,
region: region,
visible: true,
interactions: interactions,
@@ -182,12 +198,99 @@ type ListView struct {
Items []ListItem
ScrollOffset int
Selected int
+ RowFilenames []string // filenames for click navigation
}
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
-func (lv ListView) Interactions() []Interaction { return lv.interactions }
+func (lv ListView) Interactions() []Interaction {
+ // ListView registers its scroll in Draw, not via registerInteraction.
+ // Filter out Scroll so registerInteraction only handles Tap.
+ var filtered []Interaction
+ for _, interaction := range lv.interactions {
+ if interaction.Gesture != Scroll {
+ filtered = append(filtered, interaction)
+ }
+ }
+ return filtered
+}
+
+// Draw renders each list item as a row of text.
+func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
+ // Register scroll gesture first, so click gestures registered later are checked first
+ if len(lv.interactions) > 0 {
+ var scrollHandler func(any)
+ for _, interaction := range lv.interactions {
+ if interaction.Gesture == Scroll {
+ scrollHandler = interaction.Handler
+ break
+ }
+ }
+ if scrollHandler != nil {
+ r.RegisterScroll(gtx, lv.id, lv.region, scrollHandler)
+ }
+ }
+ rowHeight := Dp(48)
+ for i, item := range lv.Items {
+ // Only draw visible rows (virtualized)
+ y := lv.region.Y + Dp(i)*rowHeight
+ if y+rowHeight < lv.region.Y || y > lv.region.Y+lv.region.H {
+ continue
+ }
+ // Draw background for selected item
+ if item.Selected || (lv.Selected == i) {
+ r.drawBg(gtx, Region{
+ X: lv.region.X, Y: lv.region.Y + Dp(i)*rowHeight,
+ W: lv.region.W, H: rowHeight,
+ }, Color{R: 200, G: 220, B: 255, A: 255})
+ }
+ // Register click area for this row
+ rowGlobalIndex := lv.ScrollOffset + i
+ rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
+ var filename string
+ if rowGlobalIndex < len(lv.RowFilenames) {
+ filename = lv.RowFilenames[rowGlobalIndex]
+ }
+ r.RegisterClick(gtx, rowID, Region{
+ X: lv.region.X, Y: y,
+ W: lv.region.W, H: rowHeight,
+ }, func(data any) {
+ OpenFile(filename)
+ })
+ // Draw main text
+ textRegion := Region{
+ X: lv.region.X + Dp(8),
+ Y: lv.region.Y + Dp(i)*rowHeight + Dp(4),
+ W: lv.region.W - Dp(32),
+ H: rowHeight - Dp(8),
+ }
+ r.drawText(gtx, item.Text, 14, textRegion, AlignStart, Color{R: 0, G: 0, B: 0, A: 255}, "")
+ // Draw subtext
+ if item.Subtext != "" {
+ subRegion := Region{
+ X: lv.region.X + Dp(8),
+ Y: lv.region.Y + Dp(i)*rowHeight + rowHeight - Dp(22),
+ W: lv.region.W - Dp(32),
+ H: Dp(16),
+ }
+ r.drawText(gtx, item.Subtext, 12, subRegion, AlignStart, Color{R: 128, G: 128, B: 128, A: 255}, "")
+ }
+ }
+}
+
+// NewListView creates a visible ListView element.
+func NewListView(id string, items []ListItem, region Region, scrollOffset, selected int, interactions []Interaction) ListView {
+ return ListView{
+ id: id,
+ region: region,
+ visible: true,
+ interactions: interactions,
+ Items: items,
+ ScrollOffset: scrollOffset,
+ Selected: selected,
+ }
+}
// ListItem is a single entry in a ListView.
type ListItem struct {
@@ -211,6 +314,24 @@ func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
+// Draw renders letters vertically along the right edge.
+func (ai AlphaIndex) Draw(gtx layout.Context, r *Renderer) {
+ letterHeight := ai.region.H / Dp(len(ai.Letters))
+ for i, letter := range ai.Letters {
+ region := Region{
+ X: ai.region.X,
+ Y: ai.region.Y + Dp(i)*letterHeight,
+ W: ai.region.W,
+ H: letterHeight,
+ }
+ col := Color{R: 100, G: 100, B: 100, A: 255}
+ if letter == ai.ActiveLetter {
+ col = Color{R: 0, G: 100, B: 200, A: 255}
+ }
+ r.drawText(gtx, letter, 10, region, AlignCenter, col, "")
+ }
+}
+
// NewAlphaIndex creates a visible AlphaIndex element.
func NewAlphaIndex(region Region, letters []string) AlphaIndex {
return AlphaIndex{
@@ -381,6 +502,10 @@ func NewSpacer(height Dp) Spacer {
// --- Utility types ---
+// OpenFile is called by the browser list when a file row is tapped.
+// Set by the editor package after initialization.
+var OpenFile func(any)
+
// Color is an RGBA color.
type Color struct {
R, G, B, A uint8
diff --git a/internal/ui/icons/back.png b/internal/ui/icons/back.png
new file mode 100644
index 0000000..8d51f70
Binary files /dev/null and b/internal/ui/icons/back.png differ
diff --git a/internal/ui/icons/back.svg b/internal/ui/icons/back.svg
new file mode 100644
index 0000000..51be2ba
--- /dev/null
+++ b/internal/ui/icons/back.svg
@@ -0,0 +1,3 @@
+
diff --git a/internal/ui/icons/gen_icons.go b/internal/ui/icons/gen_icons.go
index 81ca97d..6491f44 100644
--- a/internal/ui/icons/gen_icons.go
+++ b/internal/ui/icons/gen_icons.go
@@ -28,7 +28,7 @@ var iconTemplate = template.Must(template.New("").Parse(`// Code generated by ge
package icons
import (
- _ "embed"
+ "embed"
)
//go:embed {{.Embeds}}
diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go
index 4c8c643..c64b918 100644
--- a/internal/ui/icons/icons.go
+++ b/internal/ui/icons/icons.go
@@ -6,9 +6,15 @@ import (
"embed"
)
-//go:embed copy.svg cut.svg paste.svg
+//go:embed back.svg copy.svg cut.svg paste.svg
var svgFS embed.FS
+// backSVG contains the back icon SVG source.
+var backSVG = `
+`
+
// copySVG contains the copy icon SVG source.
var copySVG = `