editor

package
v0.2.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package editor provides the text-buffer primitives, the syntax highlighter, and the Tab type that combines them with view state and rendering. The buffer is intentionally simple: one Go string per line. That's plenty fast for the review-and-light-edit workloads this editor is aimed at, and keeps the rendering and tokenisation code uncluttered.

Index

Constants

View Source
const TabStop = 4

TabStop is the visual cell width of a hard tab. Four matches what most modern editors and viewers default to (cat, less, GitHub) and matches the editor's typical insert width when the user presses Tab.

Variables

View Source
var ErrBinaryFile = errors.New("looks like a binary file")

ErrBinaryFile marks a refusal to open non-text content into a text buffer. Callers surface it as a flash; image formats never hit it — they take the image-tab path first.

View Source
var ErrFileTooLarge = errors.New("file is too large to open")

ErrFileTooLarge marks a refusal to open a file above maxOpenBytes. Callers surface it as a flash exactly the way they surface ErrBinaryFile; the wrapped message names the file's size and the cap so the user can tell "seen and declined" from "missing".

View Source
var ErrImageTooLarge = errors.New("image dimensions too large to preview")

ErrImageTooLarge marks a refusal to decode an image whose declared dimensions exceed maxImagePixels — a guard against decode bombs, not a judgment about the file. Callers flash it like ErrFileTooLarge.

View Source
var ErrNotUTF8 = errors.New("not valid UTF-8 — convert the file to UTF-8 to edit it")

ErrNotUTF8 marks a refusal to open content that is not valid UTF-8. The buffer edits text through rune slices, and Go maps invalid bytes to U+FFFD on decode — so editing any line holding such bytes would silently rewrite them on save. Refusing at the door is the same tradeoff as ErrBinaryFile: skiff edits UTF-8 text, and says so instead of corrupting quietly.

Functions

func ClusterAt added in v0.2.1

func ClusterAt(runes []rune, i, visualCol int) (end, width int)

ClusterAt measures the grapheme cluster that starts at rune index i: it returns the index one past the cluster's last rune and the number of terminal cells the cluster occupies when it starts at visualCol. i must be a valid index into runes and should be a cluster boundary — pass ClusterStart's answer if that is not already guaranteed.

end always advances (end > i), so a walk over a line can never stall.

func ClusterStart added in v0.2.1

func ClusterStart(runes []rune, i int) int

ClusterStart snaps a rune index back to the first rune of the grapheme cluster containing it, so no caller can leave the caret sitting between a base character and its combining mark. An index already on a boundary (including len(runes), the end-of-line caret position) is returned unchanged.

The scan starts from the nearest index a boundary is guaranteed at rather than from column 0, which makes the ASCII case O(1) and the worst case no worse than the O(line) visual-column walks the renderer already runs on every frame.

func DetectIndent

func DetectIndent(lines []string, path string) string

DetectIndent picks the indent unit a freshly-opened buffer should use when the user presses Tab. The algorithm in priority order:

  1. Walk every line; classify lines that start with whitespace as either tab-indented or space-indented. Count both.
  2. If tab-indented lines outnumber space-indented ones, return "\t".
  3. If space-indented wins, return that many spaces (using the smallest non-zero leading-space count as the indent width — that matches what "infer" tools in other editors do).
  4. With no signal, fall back to the path's extension: ".go" / Makefiles / ".tsv" default to tabs (those file types either require or strongly prefer tabs); everything else defaults to four spaces.

The result is what the *Tab* key inserts; existing characters in the file are not rewritten.

func FirstMatchAtOrAfter

func FirstMatchAtOrAfter(matches []Match, cursor Position) int

FirstMatchAtOrAfter returns the index into matches of the first hit at or after cursor, or 0 when cursor sits past the last match (we wrap around to the top — that's what the user expects after typing a query at the bottom of a file).

Returns -1 when matches is empty so callers can short-circuit without re-checking the length.

func Highlight

func Highlight(filename, src string, t theme.Theme) [][]tcell.Style

Highlight tokenises src using a Chroma lexer chosen by filename (falling back to content-based detection, then to a plain-text lexer) and returns a per-line slice of styles parallel to the buffer's lines: styles[i][j] is the style for rune j on line i.

Returning a per-rune style grid keeps the renderer simple — it just looks up the style for each cell it draws — at the cost of some memory. For files small enough to comfortably review, that's a fine trade.

func HighlightVisible

func HighlightVisible(filename string, lines []string, startLine, height int, t theme.Theme) [][]tcell.Style

HighlightVisible returns a style grid for the current viewport. Only visible rows are kept in the output so keystroke cost follows terminal height, not file size. To stay correct inside multi-line comments and strings that span the viewport, tokenisation starts a bounded lead above the top and ends a bounded lead below the bottom; those lead rows are styled then thrown away.

func HighlightWindow added in v0.1.1

func HighlightWindow(filename string, lines []string, startLine, height int, t theme.Theme) ([][]tcell.Style, int, int)

HighlightWindow tokenises a window around the viewport — the visible rows plus the same lead HighlightVisible uses — but keeps every row it computed, returning the styled grid plus the window's [start, end) span. The caller caches the result and reuses it while the viewport stays inside the window, so scrolling costs nothing until the view nears an edge; re-lexing on every wheel tick is what made scrolling crawl over remote links.

The grid is indexed by ABSOLUTE buffer line, not by window offset; rows outside [start, end) come back nil. Both render paths keep it as Tab.Styles and read t.Styles[lineIdx] directly (tab.go, wrap.go), so a window-relative grid would push a "- start" correction into every consumer, including the cursor and hit-test paths where an off-by-one mis-colours silently. The price is one nil slice header per file line, paid only when the window is actually re-tokenised.

func IsWordChar added in v0.2.1

func IsWordChar(r rune) bool

IsWordChar reports whether r counts as part of a word for selection and caret motion. Underscore is included because snake_case identifiers read as one token to a programmer; everything else — punctuation, whitespace, operators — is a boundary. See the file comment for why letters outside ASCII (including CJK) are in.

func LineCommentPrefix

func LineCommentPrefix(path string) (string, bool)

LineCommentPrefix returns the single-line comment marker for path. The boolean is false for file types that do not have a safe line-comment syntax.

func LineVisualCol

func LineVisualCol(runes []rune, runeCol int) int

LineVisualCol returns the visual column (0-based) at the rune position runeCol within runes. Tabs in the prefix expand to tab stops and wide glyphs count double. Used for cursor placement and selection / find highlighting.

runeCol is clamped to [0, len(runes)] so callers can pass an end-of-line position without bounds-checking, and a runeCol that lands *inside* a grapheme cluster reports the column the cluster starts at: a caret can sit before or after "é" but there is no cell between the e and its accent to report.

func NextCluster added in v0.2.1

func NextCluster(runes []rune, i int) int

NextCluster returns the rune index of the cluster boundary after the cluster containing i — where a rightward caret step or a forward Delete lands. Past the last rune it returns the rune count, the end-of-line caret position.

func PosLess

func PosLess(a, b Position) bool

PosLess is the exported version of posLess for use from neighbouring packages that need to compare positions.

func PosOrdered

func PosOrdered(a, b Position) (Position, Position)

PosOrdered returns (a, b) sorted in document order.

func PrevCluster added in v0.2.1

func PrevCluster(runes []rune, i int) int

PrevCluster returns the rune index of the cluster boundary before i — where a leftward caret step or a Backspace lands. An i sitting inside a cluster snaps to that cluster's start rather than skipping a whole character, so a caret that got there by some other route is repaired instead of stepping over text the user can see.

func RuneColAtVisual

func RuneColAtVisual(runes []rune, targetVisualCol int) int

RuneColAtVisual returns the rune index of the cluster whose cells cover targetVisualCol, snapping clicks inside a multi-cell glyph back to its first rune. Used by mouse hit-testing — clicking the right half of a CJK ideograph or anywhere in a 4-cell tab places the cursor on that character, not somewhere "inside" it, and never between a base rune and its combining marks.

When targetVisualCol is past the line's end, the rune count is returned (cursor lands at the end-of-line virtual position). Zero-width clusters are skipped rather than returned: there is no cell to click on them with.

func RuneVisualWidth

func RuneVisualWidth(r rune, visualCol int) int

RuneVisualWidth returns the cell count occupied by r when it lands at the given visualCol (0-based, measured from the start of the line). Hard tabs expand to fill enough cells to reach the next TabStop boundary; every other rune reports the width Unicode gives it in a monospace grid — 0 for combining marks, ZWJ, and control characters, 2 for east-asian wide and fullwidth glyphs and emoji, 1 for the rest.

This is a per-RUNE answer, and a rune is not always a character: U+FE0F reports 0 here but widens the emoji in front of it, and the five runes of a family emoji report 2+0+2+0+2 while the family itself paints in two cells. Layout code walks clusters through ClusterAt for exactly that reason. Reach for this only with a rune that has no neighbours.

func WordLeft added in v0.2.1

func WordLeft(runes []rune, col int) int

WordLeft returns the column a leftward word motion from col lands on: skip backwards over any boundary clusters, then over the word run behind them, so the caret stops at the START of the word to its left. col is clamped into runes at both ends — the caret can legitimately sit one past the last rune, and clamping here means no caller has to.

The walk steps by grapheme cluster and classifies a cluster by its base rune, so a motion can never stop between a letter and its accent.

func WordRangeAt added in v0.2.1

func WordRangeAt(runes []rune, col int) (start, end int, ok bool)

WordRangeAt returns the half-open [start, end) column span of the word covering col. ok is false when col does not sit on a word rune (the click landed in whitespace or punctuation) — callers treat that as "select nothing" rather than selecting the empty range, which would silently drop whatever selection the user already had.

A caret sitting immediately after a word (col == end of the run) still resolves to that word: that is where a double-click at the right edge of a token puts you, and it is what makes Alt+Right then double-click agree.

func WordRight added in v0.2.1

func WordRight(runes []rune, col int) int

WordRight returns the column a rightward word motion from col lands on: skip forward over any boundary clusters, then over the word run past them, so the caret stops at the END of the word to its right. This mirrors WordLeft — left lands on a word's first rune, right lands one past its last — which is what makes a left-then-right round trip re-select the same token.

func WrapRowOfCol added in v0.1.5

func WrapRowOfCol(segs []int, col int) int

WrapRowOfCol returns the segment index that renders rune column col: the largest i with segs[i] <= col. A col at a segment boundary belongs to the later row (the cursor sits at that row's first cell), and the end-of-line position belongs to the last row.

func WrapSegments added in v0.1.5

func WrapSegments(runes []rune, width int) []int

WrapSegments returns the rune start index of each visual row of a line wrapped to width cells. The result always holds at least [0], and every segment starts strictly after the previous one, so callers can rely on progress even for pathological widths. Whitespace never triggers a break — a run of spaces or tabs hangs past the right edge (painting clips it) so continuation rows never start with the wrapping space.

The walk steps by grapheme cluster, so a break can never land between a base rune and its combining mark, and a two-cell glyph that would straddle the right edge moves down whole rather than being sliced in half. Every returned index is therefore a cluster boundary, which is what lets callers hand a segment's rune subslice to the visual-column helpers as if it were a line of its own.

Types

type BracketMatch added in v0.2.1

type BracketMatch struct {
	Found   bool
	Matched bool
	At      Position
	Match   Position
}

BracketMatch is the answer to "what bracket is the caret touching, and where is its partner". Found is false both when the caret is not on a bracket and when the scan hit bracketScanLimit without deciding — in either case the honest render is no highlight at all, so the two collapse into one flag on purpose. Matched distinguishes "found the partner" from "this bracket is unbalanced", which render differently.

func MatchBracketAt added in v0.2.1

func MatchBracketAt(buf *Buffer, p Position) BracketMatch

MatchBracketAt returns the bracket the caret at p is touching together with its partner. The scan starts on the bracket itself so its own depth contribution needs no special case.

type Buffer

type Buffer struct {
	Lines []string
	// contains filtered or unexported fields
}

Buffer is a simple editable text buffer backed by one string per line. String-per-line keeps the surrounding code readable; Go string ops are fast enough that rebuilding a single line on each edit is fine for the file sizes this editor actually opens (review + small edits).

Lines never carry a line terminator: NewBuffer strips the CR of a CRLF pair on load and Tab restores the file's own ending on save (see Tab.LineEnding), so everything between load and save deals in one convention.

func NewBuffer

func NewBuffer(text string) *Buffer

NewBuffer constructs a Buffer from a string by splitting on newlines. A trailing newline produces an empty final line, mirroring how files commonly end and what most editors display. The CR of a CRLF pair is stripped: leaving it on would append an invisible extra column to every line of a Windows-authored file, widen every rune count, and get written straight back to disk. The file's real ending is recorded on the Tab and restored by Tab.Save.

func (*Buffer) Clamp

func (b *Buffer) Clamp(p Position) Position

Clamp adjusts a position so that Line and Col fall within the buffer. Col is clamped to the rune length of its line (so it can sit one past the last rune, which is where the cursor lives at end-of-line).

func (*Buffer) DeleteRange

func (b *Buffer) DeleteRange(a, c Position) Position

DeleteRange removes everything between a and b (in any order) and returns the resulting position (the smaller of the two). Both endpoints are clamped first; an empty range is a no-op.

func (*Buffer) EndPos

func (b *Buffer) EndPos() Position

EndPos returns the position just after the last rune of the buffer. Useful for select-all and end-of-document navigation.

func (*Buffer) InsertString

func (b *Buffer) InsertString(p Position, text string) Position

InsertString inserts text (which may contain newlines) at p and returns the position immediately after the inserted text. p is clamped first.

func (*Buffer) LineCount

func (b *Buffer) LineCount() int

LineCount returns the total number of lines in the buffer; always >= 1.

func (*Buffer) LineRunes

func (b *Buffer) LineRunes(i int) []rune

LineRunes returns the runes of the line at i, or nil if i is out of range. The caller MUST treat the returned slice as read-only: it is shared with the buffer's decode cache, so writing through it corrupts every later reader of that line.

The decode is memoised because the render and soft-wrap paths ask for the same handful of lines several times per frame — wrap.go walks the viewport once for EnsureVisible, again for the clamp, and again to paint — and a bare []rune conversion per call allocates O(viewport x lineLen) on every repaint. Freshness is checked against the source string instead of being tracked by invalidation calls, so an edit through any of the writers of Lines (here, comment.go, lineops.go, undo.go, or an outside caller) can never be served stale runes.

func (*Buffer) String

func (b *Buffer) String() string

String serialises the buffer back to a single LF-joined string. Use TextWith when writing to disk so the file keeps the ending it came in with.

func (*Buffer) Substring

func (b *Buffer) Substring(a, c Position) string

Substring returns the text between a and b. The returned string is always in document order, regardless of the order of the inputs.

func (*Buffer) TextWith added in v0.2.1

func (b *Buffer) TextWith(sep string) string

TextWith serialises the buffer joined by sep. Lines hold no terminator of their own, so the separator alone decides the file's line ending.

type GitLineChange

type GitLineChange int

GitLineChange describes the marker rendered in the editor gutter for a line.

const (
	GitLineNone GitLineChange = iota
	GitLineModified
	GitLineAdded
	GitLineDeleted
)

type LineEnding added in v0.2.1

type LineEnding int

LineEnding is the newline convention a file uses on disk. Buffers are normalised to bare LF-separated lines on load (see NewBuffer) and the recorded ending is written back on save, so editing one line of a CRLF file doesn't turn the whole file into a diff.

const (
	// LineEndingLF is "\n". It is the zero value, so a Tab built by hand
	// (tests, scratch buffers) writes POSIX-style.
	LineEndingLF LineEnding = iota
	// LineEndingCRLF is "\r\n" — the convention Windows-authored files
	// arrive with.
	LineEndingCRLF
)

func (LineEnding) Newline added in v0.2.1

func (e LineEnding) Newline() string

Newline returns the bytes this ending writes between lines.

type Match

type Match struct {
	Line  int
	Col   int
	Width int
}

Match describes one find hit. Line and Col follow the same rune-indexed convention as Position; Width is the rune count of the query so the renderer can paint the right number of cells without re-running the matcher.

func FindAll

func FindAll(buf *Buffer, query string) []Match

FindAll returns every substring match of query inside buf, in document order. Matching is smart-case: an all-lowercase query matches any case, any uppercase letter in the query makes the match exact — so "id" finds ID and id, while "ID" finds only ID. An empty query returns nil — the caller is expected to clear its UI rather than show "0 of 0" results. Matches do not overlap: after a hit the scanner advances past the matched run, so "aaaa" with query "aa" yields two matches at columns 0 and 2.

type Position

type Position struct {
	Line int
	Col  int
}

Position is a buffer location measured in lines and rune-indexed columns. Line is 0-based; Col is 0-based and counts runes — not bytes, not screen cells, and not characters as a user counts them. A CJK glyph is one column but two cells; an "é" written as e + U+0301 is two columns, one cell, and one character. cluster.go converts between the three, and the caret only ever rests on a column that starts a grapheme cluster.

func MatchEndPosition

func MatchEndPosition(m Match) Position

MatchEndPosition returns the position one past the end of m — useful when the caller wants to set a selection that covers the match.

func MatchPosition

func MatchPosition(m Match) Position

MatchPosition returns the cursor-friendly Position at the start of m. Trivial helper, but it keeps callers from constructing Position literals by hand (which loses the "rune-indexed" intent at the call site).

type Tab

type Tab struct {
	Path    string // Empty for an unsaved/scratch tab.
	Buffer  *Buffer
	Cursor  Position // Where new typed text appears.
	Anchor  Position // Selection anchor; equals Cursor when nothing is selected.
	ScrollY int      // Index of the first visible line.
	ScrollX int      // Index of the first visible column (rune-indexed). Always 0 with Wrap on.
	Dirty   bool

	// LineEnding is the convention the file had on disk. The buffer
	// holds unterminated lines, so this is the only record of how the
	// file wants to be written back; Save re-joins with it.
	LineEnding LineEnding

	// Wrap turns on soft wrap: long lines flow onto continuation rows
	// instead of panning horizontally. Stamped by the app from the user
	// config on tab creation and flipped via SetWrap. ScrollSeg is the
	// wrap-mode half of the scroll anchor — the segment index within
	// ScrollY's line of the first visible row (always 0 with Wrap off).
	// lastWrapW caches the content width of the last wrap-mode render so
	// wheel scrolling between frames can do visual-row math; 0 means
	// "never rendered", which falls back to line scrolling. See wrap.go.
	Wrap      bool
	ScrollSeg int

	// ScrollbarActive is a pure presentational flag: the app sets it
	// while the user is dragging this tab's scrollbar thumb so
	// renderScrollbar can brighten the thumb to Accent, exactly the way
	// drawSplitter brightens the sidebar splitter mid-drag. It never
	// affects geometry or scroll state — see App.setScrollbarDrag.
	ScrollbarActive bool

	Styles     [][]tcell.Style
	StyleStale bool
	GitLines   map[int]GitLineChange

	// Mtime is the file's modification time as of the last successful
	// read or write. The app's periodic disk-reconcile loop compares it
	// against the live mtime to detect external edits.
	Mtime time.Time

	// DiskGone is set when the most recent disk check found the file
	// missing. It exists so we only flash the "deleted on disk" warning
	// once, instead of re-flashing every reconcile tick.
	DiskGone bool

	// Mode is "" for a normal text tab and imageMode (= "image") for a
	// read-only image preview. Image tabs reuse the Tab type so the
	// app's tab list, switcher, and modal-routing all just work — the
	// content-mutating methods short-circuit on imageMode and Render
	// delegates to renderImage. See image.go for the render path.
	Mode     string
	Image    image.Image // populated when Mode == imageMode
	ImageFmt string      // "png" / "jpeg" / "gif" — for the status bar

	// Find state — populated when the user opens the find bar and
	// types a query. The UI layer (App) owns the bar geometry and
	// keystroke routing; the tab owns the query, the resolved match
	// list, and the index of the "current" match so the query
	// survives switching tabs and re-opening the bar.
	FindQuery   string
	FindMatches []Match
	FindIndex   int // -1 = no current match; otherwise an index into FindMatches.

	// Preview marks a tab opened by single-clicking the file tree: the
	// next single-click preview replaces it in place instead of piling
	// up tabs (VS Code / druk behavior). Editing or an explicit open
	// "pins" the tab. Always read through IsPreview(), which treats a
	// dirty buffer as pinned regardless of this flag.
	Preview bool

	// IndentUnit is the string the editor inserts when the user presses
	// Tab. Detected on file open (DetectIndent) so the editor matches
	// whatever the file already does — a tab-indented Go file gets a
	// real tab; a 2-space-indented file gets two spaces. Mixed-style
	// files take the dominant signal.
	IndentUnit string
	// contains filtered or unexported fields
}

Tab is a single open file. It owns the on-disk path, the in-memory buffer, the per-tab view state (scroll position, cursor, selection anchor), the cached syntax-highlight styles, and a dirty flag.

func NewTab

func NewTab(path string) (*Tab, error)

NewTab opens path and returns a Tab. If the file does not exist, the tab is created with an empty buffer that will be written on first save — matching what most editors do when you "open" a brand-new file path. When path looks like an image we recognise (PNG / JPEG / GIF), the tab is opened in read-only image-preview mode instead of as text.

func (*Tab) Backspace

func (t *Tab) Backspace()

Backspace deletes the character before the cursor (or the selection if any). "Character" means grapheme cluster, not rune: backspacing over "é" takes the accent with the e rather than stranding a combining mark on the letter in front of it, and one press removes one thing the user can see. Coalesces with adjacent backspaces inside the undo window. No-op on image tabs.

func (*Tab) CanRedo

func (t *Tab) CanRedo() bool

CanRedo reports whether a previously undone change can be re-applied.

func (*Tab) CanRevert

func (t *Tab) CanRevert() bool

CanRevert reports whether the buffer differs from the original state captured at NewTab / Reload time. Used to gate the Revert menu row.

func (*Tab) CanUndo

func (t *Tab) CanUndo() bool

CanUndo reports whether there is anything to roll back. The action menu uses this to enable / disable the Undo row.

func (*Tab) CenterOnCursor added in v0.1.1

func (t *Tab) CenterOnCursor(viewH int)

CenterOnCursor scrolls so the cursor's line sits mid-viewport. Used by goto-line so the target lands with context above and below it instead of hugging the edge the way plain EnsureVisible leaves it. viewH <= 0 (headless callers, pre-first-draw) is a no-op — the next Render's EnsureVisible still guarantees visibility.

func (*Tab) ClampCursorToView added in v0.2.13

func (t *Tab) ClampCursorToView(viewH int)

ClampCursorToView moves the caret to the nearest visible line when a viewport-only scroll (wheel, scrollbar) has left it off screen — the optional "caret follows scroll" behavior, called by the app only when the user turned it on. Guards, in order: an active selection is never clobbered (silently moving the selection head mid-scroll would destroy what the user highlighted), and a caret already inside the viewport is left untouched so the render pass sees no cursor motion at all. The clamped position is inside the viewport by construction, which keeps EnsureVisible a no-op afterwards — scroll → cursor never feeds back into cursor → scroll, so the one-directional cursorMoved contract survives with this feature enabled. Wrap width comes from lastWrapW, the same source Scroll uses.

func (*Tab) ClearFind

func (t *Tab) ClearFind()

ClearFind drops every piece of find state. The app calls this when the buffer has been edited enough that the cached match list is stale and can't safely be re-used; the user will re-type their query.

func (*Tab) Delete

func (t *Tab) Delete()

Delete removes the character after the cursor (or the selection if any), again a whole grapheme cluster so a forward delete can't behead a character and leave its marks behind. Coalesces with adjacent forward-deletes inside the undo window. No-op on image tabs.

func (*Tab) DeleteSelection

func (t *Tab) DeleteSelection()

DeleteSelection removes the selected range and collapses the cursor to the start of the selection. A no-op when nothing is selected.

func (*Tab) DisplayName

func (t *Tab) DisplayName() string

DisplayName returns the basename of Path, or "untitled" for unsaved tabs.

func (*Tab) DuplicateLines added in v0.1.1

func (t *Tab) DuplicateLines()

DuplicateLines inserts a copy of the selected line block directly below itself. The cursor (and anchor) land on the copy at the same column, so "duplicate then edit the copy" needs no extra movement.

func (*Tab) EnsureVisible

func (t *Tab) EnsureVisible(viewW, viewH int)

EnsureVisible scrolls the viewport so the cursor is on screen. The caller passes the editor area's width and height because the Tab itself doesn't know its render rect.

func (*Tab) FindNext

func (t *Tab) FindNext()

FindNext advances FindIndex by one (wrapping at the end) and moves the cursor onto the new match. No-op when there are no matches. Used by Enter inside the find bar and by the Esc-g "again" leader.

func (*Tab) FindPrev

func (t *Tab) FindPrev()

FindPrev moves FindIndex backwards by one (wrapping at the start) and moves the cursor onto the new match. Used by Shift-Enter inside the find bar.

func (*Tab) FocusCurrentMatch

func (t *Tab) FocusCurrentMatch()

FocusCurrentMatch moves the cursor (and anchor — we don't want a dangling selection from an earlier action) to the start of the currently-pointed match. No-op when FindIndex is out of range, so callers don't have to re-check it themselves.

func (*Tab) GoToMatchingBracket added in v0.2.1

func (t *Tab) GoToMatchingBracket() bool

GoToMatchingBracket jumps the caret onto the partner of the bracket it is touching and returns true. False means there was nothing to jump to, so the caller can say so instead of leaving the user wondering.

The jump lands ON the partner rather than past it, which means the pair stays highlighted from the far end and pressing the action again returns you to where you started.

func (*Tab) HasMatchingBracket added in v0.2.1

func (t *Tab) HasMatchingBracket() bool

HasMatchingBracket reports whether the caret is touching a bracket whose partner we found. The action menu uses it to dim "Go to matching bracket" when the jump would do nothing.

func (*Tab) HasSelection

func (t *Tab) HasSelection() bool

HasSelection reports whether the tab currently has a non-empty selection.

func (*Tab) HitTest

func (t *Tab) HitTest(localX, localY, w, h int) (Position, bool)

HitTest converts screen coordinates within this tab's render area to a buffer position. ok=false means the click was outside any line.

func (*Tab) InsertNewline added in v0.2.1

func (t *Tab) InsertNewline()

InsertNewline is what Enter does: split the line at the caret and open the new line with the same indentation the old one had, plus one level when the caret was sitting after an opening brace / bracket / paren (or, in Python and YAML, a colon). Without this, every line in an indented block starts at column 0 and the user re-types the leading whitespace by hand, which is the single most-noticed thing a terminal editor can get wrong.

The whole press is one InsertString call and therefore exactly one undo step: Enter-then-undo returns the buffer to where it was, rather than stranding the user on a half-indented line.

The indent is read from the text BEFORE the caret at the point the split will happen — the start of the selection, when there is one. Deleting a selection never touches the runes ahead of its start, so reading the prefix up front gives the same answer as reading it after the delete, and avoids splitting the operation into two undo entries.

Only "\n" is ever inserted; the file's own ending is restored by Save (see Tab.LineEnding), so a CRLF file must not get a CR spliced into the middle of a line here.

func (*Tab) InsertRune

func (t *Tab) InsertRune(r rune)

InsertRune inserts a single typed character at the cursor. Coalesces with adjacent runes inside the undo window so a typed word collapses into a single undo step rather than one entry per keystroke. No-op on image tabs.

func (*Tab) InsertString

func (t *Tab) InsertString(s string)

InsertString inserts s at the cursor (replacing any selection first) and advances the cursor past the inserted text. Always recorded as a structural undo step — pasted text or "\n" presses shouldn't merge with the surrounding typing burst. No-op on image tabs.

func (*Tab) IsImage

func (t *Tab) IsImage() bool

IsImage reports whether the tab is an image-preview, not a text editor. Callers use this to skip text-only behaviour (cursor placement, key dispatch, save, etc.) without having to know about Mode strings.

func (*Tab) IsPreview added in v0.1.1

func (t *Tab) IsPreview() bool

IsPreview reports whether the tab is still replaceable by the next tree-click preview. A dirty buffer is never a preview — the user's edits pin it implicitly, so a half-typed change can't be silently swapped out from under them.

func (*Tab) JumpToLine added in v0.1.1

func (t *Tab) JumpToLine(n int)

JumpToLine moves the cursor to column 0 of the 1-based line n, clamping to the buffer. The selection collapses — a goto is navigation, not extension — and cursorMoved is set so the next Render scrolls the target into view even if the caller never centers the viewport.

func (*Tab) MoveCursor

func (t *Tab) MoveCursor(dLine, dCol int, extend bool)

MoveCursor shifts the cursor by dLine lines and dCol characters. When extend is true the anchor is left in place so the user is extending a selection.

dCol counts grapheme clusters, taken one step at a time: an arrow key walks past "é" or an emoji in a single press, and a multi-column delta that runs off the end of a line keeps stepping onto the next line instead of losing the overshoot. Vertical motion keeps the rune column (the historical behaviour — it is not a "sticky visual column") but snaps it onto a cluster boundary in the new line.

func (*Tab) MoveCursorTo

func (t *Tab) MoveCursorTo(p Position, extend bool)

MoveCursorTo sets the cursor to a specific buffer position. Position is clamped within the buffer and snapped back to a grapheme boundary — a caret parked between a base rune and its accent would render a cell to the left of where it edits — and extend=true preserves the selection anchor.

func (*Tab) MoveLineEnd

func (t *Tab) MoveLineEnd(extend bool)

MoveLineEnd moves the cursor to the last column of the current line.

func (*Tab) MoveLineHome

func (t *Tab) MoveLineHome(extend bool)

MoveLineHome moves the cursor to column 0 of the current line.

func (*Tab) MoveLinesDown added in v0.1.1

func (t *Tab) MoveLinesDown()

MoveLinesDown is the mirror gesture: the block swaps with the line below it, stopping dead at the bottom of the buffer.

func (*Tab) MoveLinesUp added in v0.1.1

func (t *Tab) MoveLinesUp()

MoveLinesUp swaps the selected line block with the line above it. The cursor and anchor ride along, so a selection survives repeated nudges. At the top of the buffer it's a hard no-op — no dirty flag, no undo entry.

func (*Tab) MoveWordLeft added in v0.2.1

func (t *Tab) MoveWordLeft(extend bool)

MoveWordLeft walks the caret one word to the left, extending the selection when extend is set. At column 0 the motion wraps to the end of the previous line — one step, not "wrap and keep hunting" — so the gesture stays predictable at a line boundary, exactly like Left does.

func (*Tab) MoveWordRight added in v0.2.1

func (t *Tab) MoveWordRight(extend bool)

MoveWordRight walks the caret one word to the right, extending the selection when extend is set. Past the last rune of a line the motion wraps to column 0 of the next line, mirroring MoveWordLeft.

func (*Tab) Pin added in v0.1.1

func (t *Tab) Pin()

Pin makes a preview tab permanent (drops the italic, stops the replace-in-place behavior). Safe to call on any tab.

func (*Tab) Redo

func (t *Tab) Redo() bool

Redo re-applies a step that was just undone. Returns true when a step was actually redone.

func (*Tab) Reload

func (t *Tab) Reload() error

Reload re-reads the file from disk into the buffer and resets undo history. Image tabs decode the file again instead of replacing the text buffer. Use this for reloads the user explicitly chose (the disk-conflict prompt's "Reload" button) — history is discarded because the user is knowingly taking the disk version. For reloads the user did NOT ask for (format-on-save, the background external-change reconcile), use ReloadKeepHistory instead so their prior edits stay undoable.

func (*Tab) ReloadKeepHistory added in v0.2.11

func (t *Tab) ReloadKeepHistory() error

ReloadKeepHistory re-reads the file from disk like Reload but keeps the tab's undo history, pushing the pre-reload buffer as its own undoable step. This is the right shape for reloads the user did NOT explicitly ask for — format-on-save and the background external-change reconcile — where wiping history would destroy work the user never chose to give up.

func (*Tab) Render

func (t *Tab) Render(scr tcell.Screen, th theme.Theme, x, y, w, h int)

Render draws the editor's content (line numbers, code with syntax highlighting, selection, cursor) into the rectangle (x, y, w, h). Image tabs delegate to renderImage instead of drawing text.

func (*Tab) ReplaceAllMatches added in v0.1.1

func (t *Tab) ReplaceAllMatches(repl string) int

ReplaceAllMatches swaps every match for repl as ONE undo step and returns how many were replaced. Matches are applied last-to-first so earlier spans stay valid while later ones are rewritten.

func (*Tab) ReplaceCurrentMatch added in v0.1.1

func (t *Tab) ReplaceCurrentMatch(repl string) bool

ReplaceCurrentMatch swaps the current find match for repl and re-runs the query so the highlights (and the match count) stay truthful. The cursor lands just after the replacement and the current index stays put, so "replace, replace, replace" walks the file forward naturally. Returns false when there is nothing to replace.

func (*Tab) ReplaceLines added in v0.1.2

func (t *Tab) ReplaceLines(newLines map[int]string) int

ReplaceLines swaps whole lines (0-based index → new content) as ONE undo step — project-wide replace routes open buffers through here so a tab keeps its history and its dirty-state semantics. Out-of-range indexes are ignored (the caller verified against a live buffer, but buffers move). Returns how many lines were actually swapped.

func (*Tab) RestoreView added in v0.2.12

func (t *Tab) RestoreView(cursor Position, scrollY int)

RestoreView puts a tab back on a remembered place: the caret at cursor (clamped, selection collapsed) and the viewport at scrollY. It is the seam the app restores a session, a reopened tab, or any other saved spot through, so nothing outside this package has to assign Cursor, Anchor and ScrollY by hand — which is what used to let a restore skip cursorMoved (the next Render would not scroll the caret into view) and skip the undo-group break (typing right after a restore would coalesce into whatever burst was in flight before it).

ScrollSeg resets because scrollY names a BUFFER LINE: a remembered place carries no segment to go with it, and wrap.go's anchor is the pair (ScrollY, ScrollSeg) — a stale segment left behind would open the file part-way down a wrapped line. Row 0 of that line is the honest reading of "scrolled to line N", and it is what CenterOnCursor settles on for the same reason.

func (*Tab) RevertFile

func (t *Tab) RevertFile() bool

RevertFile rewinds the buffer all the way back to the snapshot captured when the file was first opened (or last reloaded). The current state is pushed onto the undo stack first so the user can recover from an accidental Revert with one Undo. Returns true when the buffer actually changed; false if it was already at the original.

func (*Tab) Save

func (t *Tab) Save() error

Save writes the buffer to disk and clears Dirty. It is an error to call Save on an untitled tab — callers should prompt for a path first. Mtime is refreshed so the disk-reconcile loop doesn't immediately think the file we just wrote was changed by someone else. Image tabs return an error since the editor only knows how to read those, not re-encode them.

func (*Tab) Scroll

func (t *Tab) Scroll(deltaLines int)

Scroll moves the viewport by delta lines (negative = up). Render runs clampScroll afterwards so the user never scrolls into pure void; here we just adjust the raw value. In wrap mode a "line" of scrolling is a visual row, so a wheel tick over a long wrapped line moves one row, not one whole paragraph; before the first render (no cached width yet) we fall back to buffer-line motion.

func (*Tab) ScrollH

func (t *Tab) ScrollH(deltaCols int)

ScrollH moves the viewport horizontally by delta rune-columns (negative = left). Clamped at zero; the right side is naturally bounded by Render's contentW window — scrolling past the longest visible line just shows blank space, which is fine. Lives next to Scroll so the app's mouse-wheel dispatcher can treat horizontal and vertical wheels symmetrically. A no-op in wrap mode — nothing extends past the right edge, so a horizontal wheel has nothing to reveal.

func (*Tab) ScrollTargetForClick added in v0.1.1

func (t *Tab) ScrollTargetForClick(viewH, clickY int) int

ScrollTargetForClick maps a click at bar-local row clickY to the ScrollY it requests — the app's mouse handler funnels both the initial press and thumb drags through this.

func (*Tab) ScrollbarVisible added in v0.1.1

func (t *Tab) ScrollbarVisible(viewH int) bool

ScrollbarVisible reports whether the tab draws a scrollbar in a viewH-row viewport — text tabs taller than the view only.

func (*Tab) SelectWordAt added in v0.2.1

func (t *Tab) SelectWordAt(p Position)

SelectWordAt selects the word under the buffer position p, or does nothing when p sits in whitespace / punctuation. Used by double-click in the app layer.

cursorMoved is deliberately NOT set: the word was just clicked, so it is on screen by construction, and flagging a scroll here would fight the auto-scroll a drag may already be running.

func (*Tab) SelectionText

func (t *Tab) SelectionText() string

SelectionText returns the currently selected text, or "" if nothing is selected. The text is always returned in document order.

func (*Tab) SetFindQuery

func (t *Tab) SetFindQuery(query string)

SetFindQuery installs a new search query on the tab, recomputes the match list against the current buffer, and points FindIndex at the first match at or after the cursor (so the user lands on the nearest hit, not always the first hit in the file). An empty query clears all find state — symmetrical with closing the bar via Esc.

The cursor is left where it is; SetFindQuery only updates state. It is the caller's job to call FocusCurrentMatch when they want the cursor to actually move (which is what happens on the first non-empty query and on every Enter / Shift-Enter press).

func (*Tab) SetWrap added in v0.1.5

func (t *Tab) SetWrap(on bool)

SetWrap switches soft wrap on or off for this tab and resets the view state the other mode owns: wrap clears any horizontal pan, unwrap clears the segment anchor. cursorMoved is set so the next Render brings the cursor back into view in the new geometry.

func (*Tab) ToggleLineComment

func (t *Tab) ToggleLineComment() (changed bool, ok bool)

ToggleLineComment comments or uncomments the selected lines. It returns ok=false when the active file type has no known line-comment marker.

func (*Tab) Undo

func (t *Tab) Undo() bool

Undo restores the previous snapshot. Returns true when a step was actually undone — false lets the caller flash a "nothing to undo" message. The current state is moved onto the redo stack first so a subsequent Redo can replay it forward.

type TabList added in v0.1.7

type TabList struct {
	// contains filtered or unexported fields
}

TabList owns the ordered set of open tabs, the active tab, and the preview-slot rules. Its interface speaks tab identity (*Tab) — never list position and never path — so a reference held across a mutation (a dirty-close modal's callback, an async format result) can never act on the wrong tab. Indexes appear only as transient values for the tab strip's geometry; they must not be stored.

func (*TabList) Activate added in v0.1.7

func (l *TabList) Activate(t *Tab) bool

Activate makes t the active tab. Returns false when t is not in the list — a stale reference to a closed tab activates nothing.

func (*TabList) ActivateAt added in v0.1.7

func (l *TabList) ActivateAt(i int)

ActivateAt activates the tab at position i, clamped into range — for tab strip clicks, where the index comes fresh from the hit test.

func (*TabList) Active added in v0.1.7

func (l *TabList) Active() *Tab

Active returns the active tab, or nil when the list is empty.

func (*TabList) ActiveIndex added in v0.1.7

func (l *TabList) ActiveIndex() int

ActiveIndex returns the active tab's position — transient, for the tab strip's scroll math.

func (*TabList) Append added in v0.1.7

func (l *TabList) Append(t *Tab)

Append adds t at the end and activates it.

func (*TabList) At added in v0.1.7

func (l *TabList) At(i int) *Tab

At returns the tab at position i, or nil when i is out of range — for the tab strip's row-by-row drawing only.

func (*TabList) IndexOf added in v0.1.7

func (l *TabList) IndexOf(t *Tab) int

IndexOf returns t's position, or -1 when t is not in the list.

func (*TabList) InsertPreview added in v0.1.7

func (l *TabList) InsertPreview(t *Tab)

InsertPreview places the preview tab t: reusing the existing preview tab's slot when there is one — tab order is part of the user's spatial memory, so browsing must not reshuffle it — and appending otherwise. Activates t either way.

func (*TabList) Len added in v0.1.7

func (l *TabList) Len() int

Len returns how many tabs are open.

func (*TabList) Lookup added in v0.1.7

func (l *TabList) Lookup(path string) *Tab

Lookup returns the open tab for path, or nil. Paths are unique in the list: the open path always reuses an existing tab.

func (*TabList) Preview added in v0.1.7

func (l *TabList) Preview() *Tab

Preview returns the current preview tab, or nil. There is at most one: every preview open either replaces or pins it.

func (*TabList) Remove added in v0.1.7

func (l *TabList) Remove(t *Tab) bool

Remove takes t out of the list by identity. Removing a background tab keeps the current active tab active; removing the active tab activates its right neighbour (or the new last tab). Returns false when t is not in the list — a stale reference removes nothing.

func (*TabList) Tabs added in v0.1.7

func (l *TabList) Tabs() []*Tab

Tabs returns the ordered tabs for ranging. Callers must not mutate the returned slice — every mutation goes through this list.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL