Density (pure hi-DPI) was already scale-free: all bookkeeping is in
density-dp and the scale enters only at the px<->dp boundary. But Android
also has a second axis, the user font-size setting (PxPerSp = fontScale *
PxPerDp), and the shaper draws baselines in sp. At a non-default font
scale the rendered line pitch is 16.8*fontScale dp while every logic-side
consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1)
viewportfuls of lines and scroll clamping would stop short of the bottom.
- ScaleEvent.FontScale + Frame.FontScale closed loop (main reads
gtx.Metric, logic tracks it in State.fontScale).
- EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied
line height, now used by every consumer (window start, sub-line
remainder, tap mapping, scroll clamp, page size, cursor vertical move,
menu position, chunk-prefetch fallbacks).
- Renderer: GlyphLayout.LineHeight, caret, selection handles, and
highlight all use the scaled ascent/line-height from gtx.Metric.
- font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with
the glyph layout fabricated at the scaled pitch (independent ground
truth), plus EffectiveLineHeight unit test.
- On-device: tap markers landed on exactly the tapped line at font_scale
1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured
57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position
window start k = floor(s/lh_eff) verified against the visible top line.
- Docs: architecture.md 6.2 font-scale axis, README two-scale note +
profiler 2s flush staleness note, development plan v10 Phase 11.
Implement the Android-native touch selection model, verified on-device:
- long-press selects the word under the finger (blank -> caret + paste-only
menu); double-tap selects the word; drag handles resize the selection,
drag the highlighted body to move it; floating menu offers copy/cut/paste
(selection) or paste (bare caret), closing on any item tap.
- Renderer reports finger positions (app-local Dp) as tap/double-tap/
long-press/selection-drag events; the logic goroutine owns all geometry
(EditorRegion, menu rect, hit-testing, handles) and the renderer only
draws the frame snapshot.
- Long press: 400 ms still-press on the editor, cancelled by movement
(non-grabbing raw pointer probe) or by a scroll/handle grab. The main
loop keeps invalidating while a press is pending (Gio renders on demand;
a stationary finger produces no frames).
- Clipboard crosses the goroutine boundary via buffered channels
(clipboardSetChan/pasteReqChan logic->main, pasteChan main->logic);
main executes the Gio ops and, on Android, invalidates after ReadCmd
because a queued transfer.DataEvent schedules no frame of its own.
Renderer fixes found while validating on-device:
- clickReg was stored by value in a map; range yielded copies so per-frame
press bookkeeping (long-press state) was silently discarded. Now pointers.
- On Android a tap's press+release arrive in the same frame and
gesture.Click/Drag return one event per Update call; without draining
each gesture's queue every frame the release was lost on an idle window
and every menu tap was swallowed (needed a second tap to 'rescue' it).
Click and drag loops now drain to exhaustion (scroll already does).
- pointer.Filter queries must name Kinds: a zero-kinds filter matches
nothing (the press-probe query was dead).
- Menu.Draw offsets items by the menu origin; the clippable drawElement
branch registers SelDrag (handles now draw for the TextField).
Tests: internal/editor/touch_selection_test.go (word range, long-press,
double-tap, tap/menu guards, handle drags, menu actions, selection edits)
and internal/test/e2e/touch_selection_e2e_test.go; full suite green under
-race. Docs: spec.md §2.2 + §7, architecture.md §6.3a, development_plan.md
Phases 8-9.
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
Drop the log.Printf/fmt.Printf traces that fired on every config/input/
click/scroll/open/worker-result during IME and tap debugging. These were
noise (and some were commented out). Error, limit, and recovery logs are
kept; the default-off profiler and gated IME debug remain untouched.
TextField.Draw re-pushed key.SnippetCmd and key.SelectionCmd every frame
while focused. Re-pushing an unchanged snippet resets the IME's composition
and caret, so commits arriving faster than a frame interleaved with those
resets and desynced the cursor (garbled/duplicated text).
Mirror widget.Editor's updateSnippet/selection gating: track the last-pushed
snippet and caret in the main-owned Renderer (keyed by field ID, reset on
focus (re)gain), and only emit the ops when they actually change. A fresh
push is forced whenever the field (re)gains focus so the IME always starts
from a known state. Also drop a leftover per-frame 'Focused:' debug print.
On-device: rapid back-to-back commits (0.08-0.1s cadence, faster than a
frame) now land cleanly - type HELLO, append+backspace, and a
browser->editor navigation round-trip all produce exact content.
go test -race ./... green.
Finish Phase 1 items 1, 2, 4, on top of the range-handling done in 9b78219:
- TextField.Draw now emits, when focused:
* key.InputHintOp{HintText} (item 4: enable text keyboard/autocorrect)
* key.SnippetCmd (the visible window as the snippet, Range {0,len})
(item 2: swipe/autocorrect source)
* key.SelectionCmd (caret, window-relative rune index)
(item 1: IME selection sync)
The snippet is the visible window (not the whole file), so the IME treats
the window as the document and reports EditEvent.Range window-relative.
- HandleReplaceRange now resolves the window-relative range against
IMEWindowText and offsets by IMEWindowStartByte to address the buffer
(string and chunked paths). Falls back to the whole buffer when layout
has not set the window (tests).
- EditorState gains IMEWindowStartByte / IMEWindowText, set during layout.
- Add runeCount helper (utf8 leading-byte scan) in the ui package.
- Fix a data race in three editor e2e/integration tests: they called
OpenFile from the test goroutine while the logic goroutine ran layout;
now wrapped in withState so the state write happens on the owner.
Tests: ime_range_test.go gains windowed-path coverage (string+chunked).
go build, go test, go vet, and go test -race are all green.
Frame now carries view state (scale/focus/query) to the main goroutine,
which reads only the frame-receiver-stored snapshot. Renderer owns Gio
widget editors (registered by ID) and the draw scale. Autosave timer
sends a token to the logic goroutine instead of touching state;
Shutdown waits for the owner to exit before FlushAll.
Tests access state only through owner-side Inspect/WithState helpers
(harness + in-package). Fixed double-Harness.Run race in two e2e tests,
made TestWorkerPool_PriorityPreemption deterministic, and raised the
lazy-loading test timeout that was too short under -race.
go test -race ./... is now green.
- JNI: open_file_in_termux via ACTION_SEND intent (text/plain + file:// uri),
global context ref kept from registerFragment
- impl_android.go: OpenFile(path) attaches current thread if needed
- NewLogic takes openfunc; State.open + ui.OpenFile now func(string)
- ChunkedBuffer.VisibleByteRange: word-wrap path using
GlyphLayout.VisualLineStarts (+byteOffset, lineHeight, visual index param)
- GlyphLayout gains LineHeight; drawWrappedText records VisualLineStarts
- WordWrap default true; State.ByteOffset tracks first visible line
- types: VisualLineIndex
- scroll_fix_test.go (new)
- debug prints left in place (WIP; cleanup in later phase)
- 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)
- Fix cursor vertical alignment and ensure it respects ScrollOffset.
- Fix scrolling in editor page by correctly registering scroll interactions in the renderer.
- Ensure cursor is clipped to the editor text area to prevent drawing over status bars.
- Replace gtx.Event(nil) with key.Filter{Focus: focusedID} and
key.FocusFilter{Target: focusedID} so Gio correctly routes key
and edit events to the focused editor text field.
- Convert EditorState.CursorPosition (byte offset) to line/column
coordinates for accurate cursor rendering.
- Add debug logging in HandleKeyDown and HandleCursorMove.
- Remove currentBrowserState global and SetBrowserState from browser package
- Browser now receives sortHandler as a parameter instead of using global state
- Fix sort mode toggle: reload pages after clearing cache (was showing 'Loading...')
- Add String() methods to all UI element types for debugging
- Add e2e tests for sort mode functionality (5 tests)
The editor now owns all state; the browser is a pure function receiving
state and handlers through parameters.
- Changed BrowserScrollOffset and ListView.ScrollOffset from int (row index)
to ui.Dp (pixel offset) for smooth per-pixel scrolling
- Updated HandleBrowserScroll to use raw Dp delta instead of row-based
scrolling with minimum ±1 row jumps
- Fixed search not triggering new frames by detecting query changes in
main.go FrameEvent handler
- Fixed click misalignment by indexing RowFilenames with local index i
instead of rowGlobalIndex
- Fixed bottom clamping using actual BrowserListHeight and corrected
formula: maxScroll = totalRows * rowHeight - BrowserListHeight
- Add clippableElement interface for elements that need their own clip region
- ListView implements NeedsClip() so drawElement pushes a clip before Draw
- RegisterScroll no longer has its own clip; relies on element clip context
- Aligns with Gio's clip-based gesture registration model
- Fix scroll events firing outside the visible list area on browser page
Also:
- Update touch.md documentation to reflect clip-based interaction model
- Remove debug logging from ListView.Draw
- Remove TODO-click-fixes.md (all items resolved)
- Added Page state (BrowserPage/EditorPage) and SortMode
- BrowserLayout with 30 static entries, sort toggle, search placeholder
- ListView rows clickable via per-row RegisterClick
- Scroll gesture registered at START of ListView.Draw (before clicks)
so click gestures registered later are checked first during hit test
- Unique IDs for interactive elements: editor_text, browser_list, search_bar
- ListView.Interactions() filters out Scroll (registered in Draw, not registerInteraction)
- RegisterScroll added to Renderer, called from element Draw methods
- lastLineYChan frame loop fixed: only re-layout if value changed
- Back icon added, editor status bar shows active filename
- Renderer computes word wrap via single LayoutString pass with
WrapHeuristically policy; glyphs drawn inline at FlagLineBreak
- Fixed line height (fontSize * 1.2), independent of glyph metrics
- Two-finger trackpad scroll via gesture.Scroll with vertical axis
- Display line feedback: renderer reports last glyph Y after each
Draw; logic uses it to clamp scroll offset so last line stops
at bottom of viewport with lineHeight/2 padding
- ScrollRange fix: {Min: -(1<<30), Max: 1<<30} ensures scroll
delta is consumed (empty range consumes nothing via clampSplit)
- Line counting fix: only FlagLineBreak increments count; buffer
flushes (32-glyph cap) draw but don't count
- Fix render.go: replace broken material.Label with shaper-based text
rendering (LayoutString + Shape + glyph iteration) per Gio's paintGlyph
- Fix render.go: only apply clip rects at container boundaries; leaf
elements have zero-size regions that were clipping all text out
- Fix render.go: call r.drawElement recursively for container children
so nested containers work and clips are applied correctly
- Fix render.go: add drawLineText/drawLine helpers matching Gio's
paintGlyph offset calculation (x + first.X, y + first.Y)
- Fix element.go: Button.Draw now uses r.drawText instead of duplicate
broken material.Label code; Label defaults to black when color is unset
- Fix state.go: container children use relative coordinates instead of
mixed screen-space/container-relative positions
- Fix main.go: ConfigEvent carries raw pixel dimensions only; ScaleEvent
carries scale only; layout is computed once per frame using both,
eliminating the infinite Invalidate() loop
- Add Draw(gtx, r *Renderer) to Element interface
- Add Container type that implements Element and holds []Element children
- Leaf types (Label, Icon, Button) implement Draw themselves
- Single recursive drawElement function in Renderer — no switch/case
- Remove StatusBar/BottomBar — replaced by Container
- Remove shaper.go — no longer needed with Gio material labels
- Simplify main.go — scale via ScaleProvider interface
- 5 files changed, shaper.go deleted
- Remove Renderer.scale field and SetScale method
- Add ScaleProvider interface to avoid editor/ui import cycle
- Add Scale() method to State, rename Scale field to scale
- Renderer reads scale through r.scale.Scale()
- Fix icons.go blank import preventing embed.FS usage
- Update main.go to pass State as ScaleProvider to Renderer
- New shaper.go: ShapeText() returns ShapedText with measured width +
stored PathSpec + BitmapsCall, DrawShapedText() draws with zero shaper calls
- drawStatusBar: ShapeText for filename + right icons, positions from widths
- drawBottomBar: ShapeText for cursor/bytepos/wrap, positions by alignment
- No separate measureText helper: ShapeText.Width provides measurements
- StatusBar uses IconLeft/IconRow string slices for icon configuration
- BottomBar uses BarAlignment enum for item positioning
- drawPng uses top-left corner, drawText uses baseline
- Icon bottom (Y=42) now aligns with baseline at Y=42
- Icon top is at Y=26 (42-16), occupies Y=26 to Y=42
- Read icons/cut.png (not cut.png) to match embed directive
- Offset icon Y by iconSize so bottom aligns with text baseline
- drawText uses baseline positioning, drawPng uses top-left corner
- drawText positions glyphs by baseline, drawPng uses top-left corner
- Subtract iconSize from iconY so icon bottom aligns with text baseline
- Icon now renders within status bar instead of overlapping top
- Takes image.Image, position (x,y) in Dp, size (width,height) in Dp
- Tint color with alpha < 255 applies color overlay using clip.Rect
- Converts Dp to pixels using Renderer.toPx()
- Uses paint.NewImageOp for image rendering
- Takes image.Image, position (x,y) in Dp, size (width,height) in Dp
- Tint color with alpha < 255 applies color overlay using clip.Rect
- Converts Dp to pixels using Renderer.toPx()
- Uses paint.NewImageOp for image rendering
- StateReader interface in ui package (Scale(), ScreenWidth(), ScreenHeight())
- State implements StateReader
- Renderer reads scale/window size via interface, never imports editor
- State fields are private (screenWidth, screenHeight, scale) with accessor methods
- No SetScale/SetWindowSize calls from main.go — Renderer reads from State directly
- ConfigEvent: ONLY from app.ConfigEvent, sets width/height in State
- FrameEvent: gets scale from gtx.Metric.PxPerDp, sends ScaleEvent only if changed
- ScaleEvent: updates State.Scale
- Renderer reads windowW/windowH from State (SetWindowSize), not gtx.Constraints
- Renderer reads scale from State (SetScale), not gtx.Metric.PxPerDp
- main.go: ConfigEvent calls SetWindowSize, FrameEvent calls SetScale
- State.Scale stores the scale factor (default 1.0)
- ScaleEvent applies SetScale() which updates State.Scale
- Logic.Scale() accessor returns current scale
- Renderer.SetScale() updates renderer's scale from State
- main.go: uses logic.Scale() to get scale, calls renderer.SetScale()
- No more references to gtx.Metric.PxPerDp in render.go or main.go
- Logic layer has separate ConfigChan and ScaleChan
- ConfigEvent: width/height in Dp, set on ConfigEvent
- ScaleEvent: scale factor, set on FrameEvent when scale changes
- Logic has ScreenSize() accessor for layout/rendering pipeline
- Logic has SetScale() to update scale and recompute layout
- main.go: ConfigEvent stores pixels, converts to DP using current scale
- main.go: FrameEvent gets actual scale, sends ScaleEvent if changed
The op.Record macro was leaving a clip on the stack that prevented
subsequent elements from drawing. Use clip.Rect{}.Push()/clip.Pop()
instead for immediate push/pop.
- 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