Files
kte/Buffer.h
T
Claude 404b983009 Undo: hold large texts as storage spans; byte budget with pruning
- Undo texts of 4 KiB or more (Insert/Paste from the buffer, Delete from
  the spans the piece table remembers for its last large deletion) are
  held as TextSpan references into the piece table's append-only
  storage, after checking they hold exactly the recorded bytes. Undo and
  redo insert them with Buffer::insert_spans, which adds pieces over the
  stored bytes instead of copying them into storage again (the journal
  still gets the bytes, as one record). A replace-all session on a 26 MB
  file peaks at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB
  kill no longer grow memory by 6.6 MB each.
- UndoSystem keeps a byte budget (default max(64 MiB, twice the buffer
  size); SetByteBudget). Over it, history is pruned to 75% of the budget:
  branches off the current path, then the oldest edits (an undo group
  goes whole, and never the current state's group), then the redo line.
  When the saved state is dropped the buffer stays modified until saved.
- replace_all_bytes clears undo history, which was recorded against (and
  may reference the storage of) the previous content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00

789 lines
18 KiB
C++

/*
* Buffer.h - editor buffer representing an open document
*
* Buffer is the central document model in kte. Each Buffer represents one open file
* or scratch document and manages:
*
* - Content storage: Uses PieceTable for efficient text operations
* - Cursor state: Current position (curx_, cury_), rendered column (rx_)
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
* - File backing: Optional association with a file on disk
* - Undo/Redo: Integrated UndoSystem for operation history
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
* - Swap/crash recovery: Integration with SwapRecorder for journaling
* - Dirty tracking: Modification state for save prompts
*
* Key concepts:
*
* 1. Cursor coordinates:
* - (curx_, cury_): Logical character position in the document
* - rx_: Rendered column accounting for tab expansion
*
* 2. File backing:
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
* - File identity tracking detects external modifications
*
* 3. Legacy Line wrapper:
* - Buffer::Line provides a string-like interface for legacy command code
* - New code should prefer direct PieceTable operations
* - See DEVELOPER_GUIDE.md for migration guidance
*
* 4. Content access:
* - Rows(): Materialized line cache (legacy, being phased out)
* - GetLineView(): Zero-copy line access via string_view (preferred)
* - Direct PieceTable access for new editing operations
*/
#pragma once
#include <atomic>
#include <algorithm>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <string>
#include <unordered_set>
#include <vector>
#include <string_view>
#include "PieceTable.h"
#include "UndoSystem.h"
#include <cstdint>
#include "syntax/HighlighterEngine.h"
#include "Highlight.h"
#include <mutex>
// Edit mode determines which font class is used for a buffer.
enum class EditMode { Code, Writing };
// Detect edit mode from a filename's extension.
inline EditMode
DetectEditMode(const std::string &filename)
{
std::string ext = std::filesystem::path(filename).extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
static const std::unordered_set<std::string> writing_exts = {
".txt", ".md", ".markdown", ".rst", ".org",
".tex", ".adoc", ".asciidoc",
};
if (writing_exts.count(ext))
return EditMode::Writing;
return EditMode::Code;
}
// Forward declaration for swap journal integration
namespace kte {
class SwapRecorder;
}
class Buffer {
public:
Buffer();
Buffer(const Buffer &other);
Buffer &operator=(const Buffer &other);
Buffer(Buffer &&other) noexcept;
Buffer &operator=(Buffer &&other) noexcept;
explicit Buffer(const std::string &path);
// File operations
bool OpenFromFile(const std::string &path, std::string &err);
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
// External modification detection.
// Returns true if the file on disk differs from the last observed identity recorded
// on open/save.
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
// Refresh the stored on-disk identity to match current stat (used after open/save).
void RefreshOnDiskIdentity();
// Accessors
[[nodiscard]] std::size_t Curx() const
{
return curx_;
}
[[nodiscard]] std::size_t Cury() const
{
return cury_;
}
[[nodiscard]] std::size_t Rx() const
{
return rx_;
}
[[nodiscard]] std::size_t Nrows() const
{
return content_LineCount_();
}
[[nodiscard]] std::size_t Rowoffs() const
{
return rowoffs_;
}
[[nodiscard]] std::size_t Coloffs() const
{
return coloffs_;
}
// Line wrapper used by legacy command paths.
// Keep this lightweight: store materialized bytes only for that line.
class Line {
public:
Line() = default;
explicit Line(const char *s)
{
assign_from(s ? std::string(s) : std::string());
}
explicit Line(const std::string &s)
{
assign_from(s);
}
Line(const Line &other) = default;
Line &operator=(const Line &other) = default;
Line(Line &&other) noexcept = default;
Line &operator=(Line &&other) noexcept = default;
// capacity helpers
void Clear()
{
s_.clear();
}
// size/access
[[nodiscard]] std::size_t size() const
{
return s_.size();
}
[[nodiscard]] bool empty() const
{
return s_.empty();
}
// read-only raw view
[[nodiscard]] const char *Data() const
{
return s_.data();
}
[[nodiscard]] std::size_t Size() const
{
return s_.size();
}
// element access (read-only)
[[nodiscard]] char operator[](std::size_t i) const
{
return (i < s_.size()) ? s_[i] : '\0';
}
// conversions
explicit operator std::string() const
{
return s_;
}
// string-like API used by command/renderer layers (implemented via materialization for now)
[[nodiscard]] std::string substr(std::size_t pos) const
{
return pos < s_.size() ? s_.substr(pos) : std::string();
}
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
{
return pos < s_.size() ? s_.substr(pos, len) : std::string();
}
// minimal find() to support search within a line
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
{
return s_.find(needle, pos);
}
void erase(std::size_t pos)
{
if (pos < s_.size())
s_.erase(pos);
}
void erase(std::size_t pos, std::size_t len)
{
if (pos < s_.size())
s_.erase(pos, len);
}
void insert(std::size_t pos, const std::string &seg)
{
if (pos > s_.size())
pos = s_.size();
s_.insert(pos, seg);
}
Line &operator+=(const Line &other)
{
s_ += other.s_;
return *this;
}
Line &operator+=(const std::string &s)
{
s_ += s;
return *this;
}
Line &operator=(const std::string &s)
{
assign_from(s);
return *this;
}
private:
void assign_from(const std::string &s)
{
s_ = s;
}
std::string s_;
};
[[nodiscard]] const std::vector<Line> &Rows() const
{
ensure_rows_cache();
return rows_;
}
[[nodiscard]] std::vector<Line> &Rows()
{
ensure_rows_cache();
return rows_;
}
// Lightweight, lazy per-line accessors that avoid materializing all rows.
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
[[nodiscard]] std::string GetLineString(std::size_t row) const
{
return content_.GetLine(row);
}
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
{
return content_.GetLineRange(row);
}
// Zero-copy view of a line. Points into the materialized backing store; becomes
// invalid after subsequent edits. Use immediately.
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
[[nodiscard]] const std::string &Filename() const
{
return filename_;
}
// Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+"
// This does not mark the buffer as file-backed.
void SetVirtualName(const std::string &name)
{
filename_ = name;
is_file_backed_ = false;
is_virtual_ = true;
}
[[nodiscard]] bool IsFileBacked() const
{
return is_file_backed_;
}
// A virtual buffer (e.g. "+HELP+") has a display name that is not a path:
// it is never journaled, and saving it asks for a file name.
[[nodiscard]] bool IsVirtual() const
{
return is_virtual_;
}
// Identity of this document, stable for its lifetime, including when the
// Buffer object is moved (e.g. by the editor's buffer vector). Copies get
// a new id. Use it, not the object's address, to key per-buffer caches.
[[nodiscard]] std::uint64_t Id() const
{
return id_;
}
[[nodiscard]] bool Dirty() const
{
return dirty_;
}
// Read-only flag
[[nodiscard]] bool IsReadOnly() const
{
return read_only_;
}
void SetReadOnly(bool ro)
{
read_only_ = ro;
}
void ToggleReadOnly()
{
read_only_ = !read_only_;
}
void SetCursor(const std::size_t x, const std::size_t y)
{
curx_ = x;
cury_ = y;
}
void SetRenderX(const std::size_t rx)
{
rx_ = rx;
}
void SetOffsets(const std::size_t row, const std::size_t col)
{
rowoffs_ = row;
coloffs_ = col;
}
void SetDirty(bool d)
{
// The version tracks content, not the dirty flag: every text mutation
// bumps it (see edited_at_), including undo/redo back to the saved state.
dirty_ = d;
}
// Whole-buffer content change (load, reload, replace): bump the version
// and drop all cached highlighting.
void MarkContentChanged()
{
edited_at_(0);
}
// Mark support
void ClearMark()
{
mark_set_ = false;
}
void SetMark(const std::size_t x, const std::size_t y)
{
mark_set_ = true;
mark_curx_ = x;
mark_cury_ = y;
}
[[nodiscard]] bool MarkSet() const
{
return mark_set_;
}
[[nodiscard]] std::size_t MarkCurx() const
{
return mark_curx_;
}
[[nodiscard]] std::size_t MarkCury() const
{
return mark_cury_;
}
// Visual-line selection support (multicursor/visual mode)
void VisualLineClear()
{
visual_line_active_ = false;
}
void VisualLineStart()
{
visual_line_active_ = true;
visual_line_anchor_y_ = cury_;
visual_line_active_y_ = cury_;
}
void VisualLineToggle()
{
if (visual_line_active_)
VisualLineClear();
else
VisualLineStart();
}
[[nodiscard]] bool VisualLineActive() const
{
return visual_line_active_;
}
void VisualLineSetActiveY(std::size_t y)
{
visual_line_active_y_ = y;
}
// The selected rows, clamped to the buffer: they are stored as row numbers
// and go stale when lines are removed, and commands must never act on rows
// past the end (edits there were misplaced and could not be undone).
[[nodiscard]] std::size_t VisualLineStartY() const
{
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_
: visual_line_active_y_;
return clamp_row_(y);
}
[[nodiscard]] std::size_t VisualLineEndY() const
{
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_
: visual_line_anchor_y_;
return clamp_row_(y);
}
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
// cursor "spot" (Curx clamped to each line length), not the entire line.
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
{
if (!visual_line_active_)
return false;
if (y < VisualLineStartY() || y > VisualLineEndY())
return false;
std::string_view ln = GetLineView(y);
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
if (!ln.empty() && ln.back() == '\n')
ln.remove_suffix(1);
const std::size_t spot = std::min(Curx(), ln.size());
return sx == spot;
}
[[nodiscard]] std::string AsString() const;
private:
[[nodiscard]] std::size_t clamp_row_(std::size_t y) const
{
const std::size_t n = Nrows();
return (n == 0) ? 0 : (y < n ? y : n - 1);
}
public:
// Syntax highlighting integration (per-buffer)
[[nodiscard]] std::uint64_t Version() const
{
return version_;
}
// Edit mode (code vs writing)
[[nodiscard]] EditMode GetEditMode() const
{
return edit_mode_;
}
void SetEditMode(EditMode m)
{
edit_mode_ = m;
edit_mode_detected_ = true;
}
void ToggleEditMode()
{
edit_mode_ = (edit_mode_ == EditMode::Code)
? EditMode::Writing
: EditMode::Code;
edit_mode_detected_ = true;
}
[[nodiscard]] bool EditModeDetected() const
{
return edit_mode_detected_;
}
void SetSyntaxEnabled(bool on)
{
syntax_enabled_ = on;
}
[[nodiscard]] bool SyntaxEnabled() const
{
return syntax_enabled_;
}
// Marks that the user explicitly set syntax state via a command (:syntax
// on/off, :set filetype=...), as opposed to it being auto-applied from
// GUIConfig. Frontends that re-apply the config-driven default every
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
// pattern - otherwise a manual :syntax off is silently undone on the very
// next frame.
void SetSyntaxUserOverride(bool on)
{
syntax_user_override_ = on;
}
[[nodiscard]] bool SyntaxUserOverride() const
{
return syntax_user_override_;
}
void SetFiletype(const std::string &ft)
{
filetype_ = ft;
}
[[nodiscard]] const std::string &Filetype() const
{
return filetype_;
}
[[nodiscard]] kte::HighlighterEngine *Highlighter()
{
return highlighter_.get();
}
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
{
return highlighter_.get();
}
void EnsureHighlighter()
{
if (!highlighter_)
highlighter_ = std::make_unique<kte::HighlighterEngine>();
}
// Swap journal integration (set by Editor)
void SetSwapRecorder(kte::SwapRecorder *rec)
{
swap_rec_ = rec;
}
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
{
return swap_rec_;
}
// Raw, low-level editing APIs used by UndoSystem apply().
// These must NOT trigger undo recording. They also do not move the cursor.
void insert_text(int row, int col, std::string_view text);
void delete_text(int row, int col, std::size_t len);
void split_line(int row, int col);
void join_lines(int row);
void insert_row(int row, std::string_view text);
void delete_row(int row);
// Insert the bytes of storage spans (see TextSpan.h) at (row, col)
// without copying them into storage again. Used by undo for large texts.
void insert_spans(int row, int col, const std::vector<TextSpan> &spans);
// Replace the entire buffer content with raw bytes.
// Intended for crash recovery (swap replay) and test harnesses.
// This does not trigger swap or undo recording, and clears undo history
// (recorded against the previous content, which it also references).
void replace_all_bytes(std::string_view bytes);
// ===== Span access for undo (see TextSpan.h) =====
[[nodiscard]] std::vector<TextSpan> SpansAt(int row, int col, std::size_t len) const;
// Spans removed by the most recent deletion, if it was exactly `len`
// bytes at (row, col) (see PieceTable::TakeDeletedSpans).
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(int row, int col, std::size_t len);
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
{
return content_.SpansEqual(spans, text);
}
template<typename Fn>
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
{
content_.VisitSpans(spans, std::forward<Fn>(fn));
}
// Total size of the content in bytes.
[[nodiscard]] std::size_t ContentBytes() const
{
return content_.Size();
}
// Undo system accessors (created per-buffer)
[[nodiscard]] UndoSystem *Undo();
[[nodiscard]] const UndoSystem *Undo() const;
#if defined(KTE_TESTS)
// Test-only: return the raw buffer bytes (including newlines) as a string.
[[nodiscard]] std::string BytesForTests() const;
#endif
private:
struct FileIdentity {
bool valid = false;
std::uint64_t mtime_ns = 0;
std::uint64_t size = 0;
std::uint64_t dev = 0;
std::uint64_t ino = 0;
};
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
mutable FileIdentity on_disk_identity_{};
// State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
// PieceTable is the source of truth.
PieceTable content_{};
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
// Helper to rebuild rows_ from content_
void ensure_rows_cache() const;
// Helper to query content_.LineCount() while keeping header minimal
std::size_t content_LineCount_() const;
std::string filename_;
bool is_file_backed_ = false;
bool is_virtual_ = false;
std::uint64_t id_ = NextBufferId();
static std::uint64_t NextBufferId()
{
static std::atomic<std::uint64_t> next{1};
return next.fetch_add(1);
}
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_;
std::unique_ptr<UndoSystem> undo_sys_;
// Edit mode (code vs writing)
EditMode edit_mode_ = EditMode::Code;
bool edit_mode_detected_ = false; // true after initial auto-detection
// Syntax/highlighting state
std::uint64_t version_ = 0; // increment on edits
// Record a content change starting at `row`: bump the version and let the
// highlighter drop cached rows from `row` down (rows above are unaffected).
void edited_at_(int row)
{
++version_;
if (highlighter_)
highlighter_->OnEdit(row < 0 ? 0 : row, version_);
}
bool syntax_enabled_ = true;
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr;
mutable std::mutex buffer_mutex_;
};