Gboard sends Enter as a key event, not a commit, so the IME's model lags the buffer by exactly one edit and its next commit is anchored at the pre-edit caret. The drift guard snapped such commits to the new caret, duplicating the just-typed word (2026-09-19 phone incident: 'a<enter>a' became 'A' + 'AaAa'). - state.go: track one pending non-IME content edit (imeStaleEditByte, armed by noteNonIMEEdit on Enter/backspace/delete/cut/paste, consumed by the next IME commit or key edit, cleared by a tap). The guard accepts a small (<= 2 rune) TEXT commit ending at or before the edit position as-is (IME STALE-OK): everything before the edit is byte-identical in both models. Empty-text commits and commits ending past the edit still snap to the caret. - render.go + state.go + main.go: arm the pre-commit flush hold with the drained frame's own EditSeq. The hold was armed with the last drawn frame's seq while the frame's TextField.EditSeq was never set (always 0), so every frame matched and the hold released only via its 8-frame timeout - silently swallowing the resync pushes (and all IME sync) that heal the desync. EditorLayout now ships EditSeq with the frame's TextField; FlushIME releases on the next higher seq. - e2e (ime_key_edit_test.go): reproduce the incident (same-batch and next-batch variants) and pin the guard edges (deletions and past-the-edit commits still snap). - ime.md: document the key-event desync invariant and the hold.
553 lines
20 KiB
Go
553 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gioui.org/app"
|
|
"gioui.org/font/gofont"
|
|
"gioui.org/io/clipboard"
|
|
"gioui.org/io/key"
|
|
"gioui.org/io/transfer"
|
|
"gioui.org/op"
|
|
"gioui.org/text"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/io/pool/real"
|
|
"pad/internal/perf"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
func main() {
|
|
go func() {
|
|
w := new(app.Window)
|
|
w.Option(app.Title("Pad"))
|
|
w.Option(app.Size(unit.Dp(390), unit.Dp(844)))
|
|
if err := run(w); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
os.Exit(0)
|
|
}()
|
|
app.Main()
|
|
}
|
|
|
|
func run(w *app.Window) error {
|
|
var ops op.Ops
|
|
shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
|
|
|
|
// Determine root directory for real filesystem
|
|
rootDir := flag.String("root", startpath, "root directory for the filesystem")
|
|
flag.Parse()
|
|
|
|
// Initialize the real filesystem rooted at the system root "/"
|
|
// so that the browser can navigate the entire system.
|
|
fs := real.NewRealFileSystem("/")
|
|
|
|
// Get the absolute path of the startup directory
|
|
startAbs, err := filepath.Abs(*rootDir)
|
|
if err != nil {
|
|
startAbs = *rootDir
|
|
}
|
|
log.Printf("using filesystem at / (startup directory: %s)", startAbs)
|
|
|
|
logic := editor.NewLogic(fs, startAbs, OpenFile)
|
|
activeLogic = logic
|
|
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
|
|
|
|
// In-app frame profiler (default off). Enabled by the presence of a marker
|
|
// file so the shipping APK needs no rebuild to toggle profiling. When on,
|
|
// it records logic-frame cadence plus scroll/visible-range context to a CSV
|
|
// and logs rolling percentiles; it also enables the scroll-jump debug poller.
|
|
const perfDir = "/storage/emulated/0/PadPerf"
|
|
var prof *perf.Profiler
|
|
var perfOn bool
|
|
var presentN int
|
|
var presentStart time.Time
|
|
// shiftDown tracks the hardware shift key on the MAIN goroutine.
|
|
// On Android Gio's JNI bridge drops modifier state (GioView.onKeyEvent
|
|
// never reads event.getMetaState), so key.Event.Modifiers is always 0 and
|
|
// shift+arrow is indistinguishable from a plain arrow. Gio does deliver
|
|
// the NameShift press/release as plain key.Events, so we track them here
|
|
// and attach the state to the ui.KeyEvent we forward.
|
|
var shiftDown bool
|
|
if _, err := os.Stat(filepath.Join(perfDir, "enable")); err == nil {
|
|
prof = perf.New(true, perfDir, "logic_frames.csv")
|
|
editor.PerfRecord = func(rec editor.ProbeRecord) {
|
|
prof.Record(perf.Ctx{
|
|
Page: rec.Page,
|
|
ScrollDP: rec.ScrollDP,
|
|
MaxScrollDP: rec.MaxScrollDP,
|
|
TotalLines: rec.TotalLines,
|
|
VisStart: rec.VisStart,
|
|
VisEnd: rec.VisEnd,
|
|
})
|
|
}
|
|
logic.EnableDebugCmdPoll(perfDir)
|
|
perfOn = true
|
|
log.Printf("PERF: enabled (dir=%s)", perfDir)
|
|
}
|
|
|
|
// The search bar's widget.Editor is owned by the MAIN goroutine: Gio
|
|
// mutates it during draw, and the logic goroutine must never touch it
|
|
// (architecture.md §1). Its text is forwarded to the logic goroutine via
|
|
// SearchQueryChan; the logic only stores the result in Browser.Query.
|
|
var searchEditor widget.Editor
|
|
renderer.RegisterGioEditor("search_bar", &searchEditor)
|
|
|
|
// The find bar's (in-file search) widget.Editor is main-owned the same
|
|
// way; its text is forwarded via FindQueryChan (editor.findSetQuery).
|
|
var findEditor widget.Editor
|
|
renderer.RegisterGioEditor("find_bar", &findEditor)
|
|
|
|
// Relaunch state restoration (spec §7): a tiny JSON file holds the last
|
|
// file, cursor, scroll, selection and find bar state. The cmd layer owns
|
|
// the file (sessionFilePath, per platform); the logic layer owns the
|
|
// snapshot (editor.SessionState) and calls the saver rate-limited and at
|
|
// Shutdown. A session taken on the editor page re-opens the last file
|
|
// straight into the editor; one taken on the browser page lands back in
|
|
// the browser at the saved directory.
|
|
sessPath := sessionFilePath()
|
|
logic.SetSessionSaver(newSessionSaver(sessPath))
|
|
if sess, ok := loadSession(sessPath); ok {
|
|
if sess.InBrowser {
|
|
log.Printf("restoring session: browser path=%q", sess.BrowserPath)
|
|
// The saved directory may have vanished (deleted, synced away) or
|
|
// be unreadable by this app (stat succeeds but the permission bits
|
|
// deny the read — e.g. /storage/emulated itself, which is
|
|
// media_rw:media_rw 0750; only /storage/emulated/0 is app-exposed).
|
|
// Probe with a real directory read — the same operation the
|
|
// browser's index does — rather than os.Stat, which passes for
|
|
// unreadable dirs. Fall back to the startup directory otherwise.
|
|
if _, err := os.ReadDir(sess.BrowserPath); err == nil {
|
|
logic.BeginBrowserRestore(sess.BrowserPath)
|
|
}
|
|
} else {
|
|
log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery)
|
|
logic.BeginRestore(sess)
|
|
// The find bar's input is a main-owned widget and the input source
|
|
// of truth: seed it with the restored query so its first frame
|
|
// matches the logic-side query (an empty widget would forward ""
|
|
// and clear the restored query).
|
|
if sess.FindQuery != "" {
|
|
findEditor.SetText(sess.FindQuery)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clipboard plumbing (architecture.md §6.3): the logic goroutine only
|
|
// REQUESTS clipboard operations through channels (copy/cut write, paste
|
|
// read); the main goroutine executes the Gio ops during a frame and
|
|
// forwards the read result back. clipTag tags the read so its
|
|
// transfer.DataEvent can be matched. A nil clipboard (e.g. a headless
|
|
// test) simply never completes a read.
|
|
var clipTag = struct{}{}
|
|
|
|
// frame is the frame-receiver-stored handoff (architecture.md §2.2/§9):
|
|
// the ONLY data the main goroutine reads from the logic side.
|
|
var mu sync.Mutex
|
|
var frame editor.Frame
|
|
|
|
go frameReceiver(w, &mu, &frame, logic.FrameChan())
|
|
go logic.Run()
|
|
|
|
// lastExcl tracks the selection-handle grab boxes last sent to
|
|
// SetGestureExclusions (Android: keeps the system back gesture from
|
|
// stealing drags that start on an edge handle). Only the JNI call when
|
|
// the set changes — the rects move every frame while a selection is
|
|
// visible/scrolling, but identical repeats are skipped.
|
|
var lastExcl [][4]int
|
|
// findBarFocused tracks whether the previous frame focused the find bar's
|
|
// main-owned input, so main hands key focus to it exactly once per open
|
|
// (see the focus handoff below).
|
|
var findBarFocused bool
|
|
// lastFindClearSeq is the FindClearSeq value whose wipe has already been
|
|
// applied to the main-owned find input (edge-triggered, see
|
|
// Frame.FindClearSeq).
|
|
var lastFindClearSeq int
|
|
// lastFrameW/H hold the previous FrameEvent's window size (px); 0 = no
|
|
// frame yet. Used to spot shrink frames (see ZeroWheelScroll below).
|
|
var lastFrameW, lastFrameH int
|
|
|
|
for {
|
|
e := w.Event()
|
|
switch e := e.(type) {
|
|
case app.DestroyEvent:
|
|
if prof != nil {
|
|
prof.Stop()
|
|
}
|
|
logic.Shutdown()
|
|
return e.Err
|
|
case app.ConfigEvent:
|
|
// ConfigEvent: raw pixel dimensions only.
|
|
logic.ConfigChan() <- editor.ConfigEvent{
|
|
PixelWidth: e.Config.Size.X,
|
|
PixelHeight: e.Config.Size.Y,
|
|
}
|
|
case app.FrameEvent:
|
|
if perfOn {
|
|
presentN++
|
|
if presentStart.IsZero() {
|
|
presentStart = time.Now()
|
|
}
|
|
if time.Since(presentStart) >= 2*time.Second {
|
|
dt := time.Since(presentStart).Seconds()
|
|
log.Printf("PERF-PRESENT frames=%d fps=%.1f", presentN, float64(presentN)/dt)
|
|
presentN = 0
|
|
presentStart = time.Time{}
|
|
}
|
|
}
|
|
gtx := app.NewContext(&ops, e)
|
|
newScale := gtx.Metric.PxPerDp
|
|
// User font-size setting: the shaper draws baselines in sp, so the
|
|
// rendered line pitch in density-dp is scaled by this factor. Logic
|
|
// bookkeeping (tap mapping, window start, scroll clamp) must follow
|
|
// it (EffectiveLineHeight).
|
|
newFontScale := float32(1)
|
|
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
|
|
newFontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
|
|
}
|
|
// Clipboard: forward a read result from an earlier frame to the
|
|
// logic goroutine (one DataEvent per ReadCmd).
|
|
for {
|
|
evt, ok := gtx.Event(transfer.TargetFilter{Target: clipTag, Type: "application/text"})
|
|
if !ok {
|
|
break
|
|
}
|
|
if de, ok := evt.(transfer.DataEvent); ok {
|
|
body := de.Open()
|
|
data, rerr := io.ReadAll(body)
|
|
body.Close()
|
|
if rerr == nil {
|
|
logic.PasteChan() <- string(data)
|
|
}
|
|
}
|
|
}
|
|
// Clipboard: a copy/cut write requested by the logic goroutine.
|
|
select {
|
|
case t := <-logic.ClipboardSetChan():
|
|
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(t))})
|
|
default:
|
|
}
|
|
// Clipboard: a paste request (one ReadCmd per request; the result
|
|
// arrives as a DataEvent on a later frame). On Android the read is
|
|
// synchronous and the resulting DataEvent is queued during this
|
|
// frame's op flush — but a queued DataEvent schedules no wakeup of
|
|
// its own, so invalidate to guarantee a follow-up frame in which
|
|
// the loop above can consume it.
|
|
select {
|
|
case <-logic.PasteReqChan():
|
|
gtx.Execute(clipboard.ReadCmd{Tag: clipTag})
|
|
w.Invalidate()
|
|
default:
|
|
}
|
|
// Read ONLY the frame-receiver-stored snapshot; the main goroutine
|
|
// never touches logic State (architecture.md §1).
|
|
mu.Lock()
|
|
curScale := frame.Scale
|
|
if curScale <= 0 {
|
|
curScale = 1 // no frame yet
|
|
}
|
|
curFontScale := frame.FontScale // 0 until the logic has the value
|
|
// App-local pinch font scale for the editor text (1.0 = default).
|
|
renderer.SetAppFontScale(frame.AppFontScale)
|
|
renderer.Draw(gtx, frame.Elems, curScale)
|
|
glyphLayout := renderer.GlyphLayout()
|
|
// Send search query update to the logic goroutine when it changes.
|
|
// The logic goroutine handles filtering and triggers a new frame.
|
|
newQuery := searchEditor.Text()
|
|
sendQuery := newQuery != frame.Query
|
|
// Mirror each NEW find-clear (the bar's X button) into the main-
|
|
// owned widget input exactly once. Edge-triggered on ClearSeq, NOT
|
|
// "FindQuery==\"\" && widget has text": that level check also fired on
|
|
// stale frames between the user's typing and the logic storing it,
|
|
// wiping the input while typing.
|
|
if frame.FindClearSeq > lastFindClearSeq {
|
|
lastFindClearSeq = frame.FindClearSeq
|
|
findEditor.SetText("")
|
|
w.Invalidate()
|
|
}
|
|
newFind := findEditor.Text()
|
|
sendFind := newFind != frame.FindQuery
|
|
// On a frame that shrinks the window (the IME opening under
|
|
// adjustResize), Gio's window synthesizes a scroll-to-focus
|
|
// pointer.Scroll via RevealFocus — it reads the focused field's
|
|
// stale pre-resize bounds and nudges the editor content. Flag the
|
|
// frame so CheckGestures drains that one synthetic event before the
|
|
// scroll gesture consumes it (Renderer.ZeroWheelScroll); finger
|
|
// scroll and the flinger are unaffected, and normal frames are
|
|
// untouched.
|
|
renderer.ZeroWheelScroll = lastFrameH > 0 &&
|
|
(e.Size.Y < lastFrameH || e.Size.X < lastFrameW)
|
|
lastFrameW, lastFrameH = e.Size.X, e.Size.Y
|
|
events := renderer.CheckGestures(e.Source, gtx.Metric)
|
|
renderer.ZeroWheelScroll = false
|
|
// Keep frames flowing while a long press is pending: a stationary
|
|
// finger generates no pointer events, so without this the window
|
|
// would sleep and the long-press threshold would never be reached.
|
|
if renderer.PendingLongPress() {
|
|
w.Invalidate()
|
|
}
|
|
// Selection-handle system-gesture exclusions (Android): forward the
|
|
// frame's handle grab boxes when they changed. Runs on the UI
|
|
// thread (the frame pump is), as View methods require.
|
|
if excl := renderer.GestureExclusions(); !exclEqual(excl, lastExcl) {
|
|
lastExcl = excl
|
|
SetGestureExclusions(excl)
|
|
}
|
|
|
|
// Gather key events
|
|
focusedID := frame.FocusedElementID
|
|
// Hand key focus to the find bar's main-owned input the moment the
|
|
// find bar opens: the search-icon tap never touches the widget, and
|
|
// widget.Editor only grabs key focus on its own click (gioui.org/
|
|
// widget/editor.go). The widget's FocusEvent handler raises the soft
|
|
// keyboard on focus gain, so the keyboard follows automatically. On
|
|
// the inverse transition the editor's TextField re-issues its own
|
|
// key.FocusCmd (the renderer clears the focus dedup on frames where
|
|
// no logic field is focused), so nothing is needed on close.
|
|
if focusedID == "find_bar" && !findBarFocused {
|
|
gtx.Execute(key.FocusCmd{Tag: &findEditor})
|
|
w.Invalidate() // next frame: the widget sees the FocusEvent
|
|
}
|
|
findBarFocused = focusedID == "find_bar"
|
|
if focusedID == "" {
|
|
// No focused input: drop any stale shift state (a release event
|
|
// for a key held across focus loss may never arrive).
|
|
shiftDown = false
|
|
}
|
|
if focusedID != "" {
|
|
if reg, ok := renderer.Keys[focusedID]; ok {
|
|
// Use key.Filter to only receive events destined for the focused element.
|
|
// We need both Key events (for arrow keys) and Edit events (for text input).
|
|
// In Gio, key.Filter covers key presses, while key.FocusFilter covers
|
|
// focus and text edit events.
|
|
//
|
|
// On mobile the window layer wraps plain arrow-key PRESSES in
|
|
// input.SystemEvent (it wants to use them for focus navigation) and
|
|
// such events match only filters that name the key explicitly. Querying
|
|
// the four arrow names below therefore (a) makes the presses deliverable
|
|
// and (b) suppresses the focus-move side effect: a matched event makes
|
|
// WakeupTime report handled, which skips the window's moveFocus call.
|
|
for {
|
|
// Filter for key events and focus/edit events targeted at focusedID
|
|
evt, ok := gtx.Event(
|
|
key.Filter{Focus: focusedID},
|
|
key.Filter{Focus: focusedID, Name: key.NameLeftArrow},
|
|
key.Filter{Focus: focusedID, Name: key.NameRightArrow},
|
|
key.Filter{Focus: focusedID, Name: key.NameUpArrow},
|
|
key.Filter{Focus: focusedID, Name: key.NameDownArrow},
|
|
key.FocusFilter{Target: focusedID},
|
|
)
|
|
if !ok {
|
|
break
|
|
}
|
|
switch k := evt.(type) {
|
|
case key.Event:
|
|
if k.Name == key.NameShift {
|
|
// Track shift; never forward it as a content key.
|
|
shiftDown = k.State == key.Press
|
|
continue
|
|
}
|
|
if k.State == key.Press {
|
|
// ui.KeyEvent carries modifier state so handlers
|
|
// can distinguish shift+arrow (extend selection)
|
|
// from plain arrow (move cursor). On Android the
|
|
// Modifiers field is always empty, hence the shiftDown OR.
|
|
events = append(events, ui.InputEvent{
|
|
Handler: reg.Handler,
|
|
Data: ui.KeyEvent{
|
|
Name: k.Name,
|
|
Shift: k.Modifiers.Contain(key.ModShift) || shiftDown,
|
|
},
|
|
})
|
|
}
|
|
case key.EditEvent:
|
|
if focusedID == "editor_text" {
|
|
// Apply the commit to the pushed snippet model
|
|
// IMMEDIATELY (see Renderer.ApplyIMECommitToModel):
|
|
// gioui's EditEvent callback already advanced its
|
|
// own window state, and without this the op queue
|
|
// lags it by one commit — every frame event in
|
|
// the gap regresses the state and sends a
|
|
// restartInput with pre-commit text per keystroke
|
|
// (the random-caps bug).
|
|
renderer.ApplyIMECommitToModel(gtx, k, frame.EditSeq)
|
|
// The commit range is in absolute file runes (the
|
|
// coordinate space of the pushed snippet, whose
|
|
// Range.Start is the context start). The logic maps
|
|
// it to bytes against the whole buffer — exact
|
|
// even mid-fling — and applies the drift guard
|
|
// (see editor.HandleIMECommit).
|
|
events = append(events, ui.InputEvent{
|
|
Handler: editor.HandleIMECommit,
|
|
Data: editor.IMECommit{
|
|
StartRune: k.Range.Start,
|
|
EndRune: k.Range.End,
|
|
Text: k.Text,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
events = append(events, ui.InputEvent{
|
|
Handler: reg.Handler,
|
|
Data: k,
|
|
})
|
|
case key.SnippetEvent:
|
|
// Handle snippet event if necessary, or ignore
|
|
case key.FocusEvent:
|
|
// Focus gain/loss on the key queue (emitted when key.FocusCmd is
|
|
// issued, i.e. on focus transitions). Nothing to do: the logic
|
|
// layer already owns focus state.
|
|
default:
|
|
log.Printf("unexpected event type: %T", k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Flush the IME snippet/selection for the focused editor field
|
|
// (see Renderer.FlushIME): after the key events above were drained
|
|
// and mirrored into the IME model, and before e.Frame below flushes
|
|
// the ops to the OS (which is when gioui compares the pushed state
|
|
// against the IME's own state.
|
|
if focusedID == "editor_text" {
|
|
for _, el := range frame.Elems {
|
|
if tf, ok := el.(ui.TextField); ok && tf.ID() == "editor_text" {
|
|
renderer.FlushIME(gtx, tf)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
e.Frame(&ops)
|
|
mu.Unlock()
|
|
|
|
if newScale != curScale || newFontScale != curFontScale {
|
|
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale, FontScale: newFontScale}
|
|
}
|
|
if len(events) > 0 {
|
|
logic.InputChan() <- events
|
|
}
|
|
if sendQuery {
|
|
logic.SearchQueryChan() <- newQuery
|
|
}
|
|
if sendFind {
|
|
logic.FindQueryChan() <- newFind
|
|
}
|
|
// Skip layout feedback for frames built before the window size
|
|
// was known (frame.ViewportDegenerate, set at frame-build time —
|
|
// feedback delivery lags shaping by a frame, so checking the
|
|
// current size here would miss them): a zero-width shape wraps
|
|
// every line into many visual lines, and feeding those counts
|
|
// back would poison the WrapIndex for the window's lines
|
|
// (applied once, corrected only if those lines are re-shaped at a
|
|
// real width — a restored scroll that moves the viewport away
|
|
// never re-shapes them, and the poisoned counts then map a
|
|
// legitimate scroll offset to the wrong line).
|
|
if !frame.ViewportDegenerate {
|
|
logic.LayoutChan() <- ui.LayoutFeedback{
|
|
GlyphLayout: glyphLayout,
|
|
WindowText: frame.WindowText,
|
|
WindowStartByte: frame.WindowStartByte,
|
|
WindowStartLine: frame.WindowStartLine,
|
|
EditSeq: frame.EditSeq,
|
|
ScrollOffset: frame.ScrollOffset,
|
|
}
|
|
}
|
|
default:
|
|
handleEvent(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// exclEqual reports whether two exclusion-rect sets are identical.
|
|
// newSessionSaver builds the relaunch-session file writer registered with
|
|
// the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn
|
|
// write is rejected by loadSession's parse on the next launch, so a plain
|
|
// write is safe (no temp+rename needed).
|
|
func newSessionSaver(path string) func(editor.SessionState) {
|
|
return func(s editor.SessionState) {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
log.Printf("session: marshal: %v", err)
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
log.Printf("session: mkdir: %v", err)
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
log.Printf("session: write: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadSession reads and sanity-checks the relaunch session file (spec §7).
|
|
// ok=false when there is no session, it is corrupt, or nothing is restorable
|
|
// — in which case the app starts in the browser at the startup directory
|
|
// as before. Browser-page sessions carry no file; an editor-page session
|
|
// (the pre-InBrowser legacy shape included) needs one.
|
|
func loadSession(path string) (editor.SessionState, bool) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return editor.SessionState{}, false
|
|
}
|
|
var s editor.SessionState
|
|
s.ScrollLine = -1 // pre-line session files lack the key; 0 would restore to the top
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return editor.SessionState{}, false
|
|
}
|
|
// A partial/corrupt file must not restore garbage positions.
|
|
if s.Cursor < 0 {
|
|
s.Cursor = 0
|
|
}
|
|
if s.Scroll < 0 {
|
|
s.Scroll = 0
|
|
}
|
|
if s.SelStart < -1 || s.SelEnd < -1 || s.SelEnd <= s.SelStart {
|
|
s.SelStart, s.SelEnd = -1, -1
|
|
}
|
|
if s.FindCurByte < -1 {
|
|
s.FindCurByte = -1
|
|
}
|
|
if s.ScrollLine < -1 {
|
|
s.ScrollLine = -1
|
|
}
|
|
if s.ScrollSub < 0 {
|
|
s.ScrollSub = 0
|
|
}
|
|
if !s.InBrowser && s.File == "" {
|
|
return editor.SessionState{}, false
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
func exclEqual(a, b [][4]int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func frameReceiver(w *app.Window, mu *sync.Mutex, frame *editor.Frame, frameChan <-chan editor.Frame) {
|
|
for {
|
|
f := <-frameChan
|
|
mu.Lock()
|
|
*frame = f
|
|
w.Invalidate()
|
|
mu.Unlock()
|
|
}
|
|
}
|