VGlyph Editing API Reference
February 6, 2026 ยท View on GitHub
This document covers VGlyph's text editing APIs: cursor positioning, selection, text mutation, undo/redo, and IME composition.
Table of Contents
Quick Start
VGlyph provides building blocks for text editors. Here's a minimal editing loop:
import vglyph
// Initialize state
mut text := 'Hello World'
mut cursor := 0
mut undo_mgr := vglyph.new_undo_manager(100)
// Insert a character
result := vglyph.insert_text(text, cursor, 'X')
undo_mgr.record_mutation(result, 'X', cursor, cursor)
// Apply result atomically
text = result.new_text
cursor = result.cursor_pos
// Rebuild layout for display
layout := ts.layout_text(text, cfg) or { return }
Key concepts:
- Mutation functions return
MutationResult, they don't modify state directly - Application applies changes and rebuilds layout
UndoManagertracks mutations for undo/redo- Layout provides cursor/selection geometry
Cursor API
Cursor APIs live on Layout. All methods return byte indices into UTF-8 text.
Cursor Position
// get_cursor_pos returns geometry for cursor rendering
// Returns none if byte_index is not a valid cursor position
pub fn (l Layout) get_cursor_pos(byte_index int) ?CursorPosition
pub struct CursorPosition {
pub:
x f32 // Left edge of cursor
y f32 // Top of cursor
height f32 // Cursor height (line height)
}
Example:
if pos := layout.get_cursor_pos(cursor_idx) {
gg_ctx.draw_rect_filled(pos.x, pos.y, 2, pos.height, gg.red)
}
Cursor Movement
Navigation methods move between valid cursor positions (grapheme boundaries), never landing inside multi-byte characters or emoji clusters.
// Character movement - respects grapheme clusters
pub fn (l Layout) move_cursor_left(byte_index int) int
pub fn (l Layout) move_cursor_right(byte_index int) int
// Word movement - jumps to word boundaries
pub fn (l Layout) move_cursor_word_left(byte_index int) int
pub fn (l Layout) move_cursor_word_right(byte_index int) int
// Line movement - start/end of current line
pub fn (l Layout) move_cursor_line_start(byte_index int) int
pub fn (l Layout) move_cursor_line_end(byte_index int) int
// Vertical movement - maintains horizontal position
pub fn (l Layout) move_cursor_up(byte_index int, preferred_x f32) int
pub fn (l Layout) move_cursor_down(byte_index int, preferred_x f32) int
Example - arrow key handling:
match key_code {
.left { cursor = layout.move_cursor_left(cursor) }
.right { cursor = layout.move_cursor_right(cursor) }
.up { cursor = layout.move_cursor_up(cursor, preferred_x) }
.down { cursor = layout.move_cursor_down(cursor, preferred_x) }
else {}
}
Mouse Click to Cursor
// get_closest_offset returns byte index nearest to click position
// Always returns valid cursor position, handles clicks outside text bounds
pub fn (l Layout) get_closest_offset(x f32, y f32) int
Example:
fn on_mouse_click(x f32, y f32) {
// Convert screen coords to layout coords
local_x := x - text_offset_x
local_y := y - text_offset_y
cursor = layout.get_closest_offset(local_x, local_y)
}
Selection API
VGlyph uses an anchor-focus model: anchor stays at initial click, cursor (focus) moves with drag or shift+arrow.
Selection Rectangles
// get_selection_rects returns rectangles covering text range [start, end)
// Handles multi-line selections correctly
pub fn (l Layout) get_selection_rects(start int, end int) []gg.Rect
Example:
if has_selection {
start := min(cursor, anchor)
end := max(cursor, anchor)
for rect in layout.get_selection_rects(start, end) {
gg_ctx.draw_rect_filled(rect.x, rect.y, rect.width, rect.height,
gg.Color{50, 50, 200, 100})
}
}
Word Selection
// get_word_at_index returns (start, end) byte indices for word at index
// Uses Pango word boundaries (locale-aware)
pub fn (l Layout) get_word_at_index(byte_index int) (int, int)
Example - double-click word select:
fn on_double_click(x f32, y f32) {
idx := layout.get_closest_offset(x - offset_x, y - offset_y)
anchor, cursor = layout.get_word_at_index(idx)
has_selection = true
}
Valid Cursor Positions
// get_valid_cursor_positions returns sorted list of valid cursor byte indices
// Useful for debugging or custom navigation
pub fn (l Layout) get_valid_cursor_positions() []int
Mutation API
All mutation functions are pure: they take current state, return new state. Application applies changes and rebuilds layout.
MutationResult
pub struct MutationResult {
pub:
new_text string // Result of applying mutation
cursor_pos int // New cursor position after mutation
deleted_text string // Text removed (empty for insert)
range_start int // Start of affected range
range_end int // End of affected range
}
Basic Mutations
// Insert text at cursor
pub fn insert_text(text string, cursor int, insert string) MutationResult
// Delete one grapheme backward (Backspace)
pub fn delete_backward(text string, layout Layout, cursor int) MutationResult
// Delete one grapheme forward (Delete key)
pub fn delete_forward(text string, layout Layout, cursor int) MutationResult
Extended Deletions
// Option+Backspace - delete to word boundary
pub fn delete_to_word_boundary(text string, layout Layout, cursor int) MutationResult
// Cmd+Backspace - delete to line start
pub fn delete_to_line_start(text string, layout Layout, cursor int) MutationResult
// Option+Delete - delete to word end
pub fn delete_to_word_end(text string, layout Layout, cursor int) MutationResult
// Cmd+Delete - delete to line end
pub fn delete_to_line_end(text string, layout Layout, cursor int) MutationResult
Selection Operations
// Delete selected text
pub fn delete_selection(text string, cursor int, anchor int) MutationResult
// Insert text, replacing selection
pub fn insert_replacing_selection(text string, cursor int, anchor int, insert string) MutationResult
// Get selected text (for copy)
pub fn get_selected_text(text string, cursor int, anchor int) string
// Cut: returns (clipboard_text, mutation_result)
pub fn cut_selection(text string, cursor int, anchor int) (string, MutationResult)
Example - character input with selection:
fn on_char_input(ch string) {
cursor_before := cursor
anchor_before := anchor
result := if has_selection {
vglyph.insert_replacing_selection(text, cursor, anchor, ch)
} else {
vglyph.insert_text(text, cursor, ch)
}
// Record for undo before applying
undo_mgr.record_mutation(result, ch, cursor_before, anchor_before)
// Apply
text = result.new_text
cursor = result.cursor_pos
anchor = cursor
has_selection = false
// Rebuild layout
layout = ts.layout_text(text, cfg) or { return }
}
Undo API
UndoManager implements undo/redo with operation coalescing.
UndoManager
pub struct UndoManager {
// Internal: dual stacks, coalescing state
}
// Create with history limit (default 100)
pub fn new_undo_manager(max_history int) UndoManager
Recording Mutations
// Record mutation for undo support
// Handles coalescing automatically (1s timeout, adjacent operations)
pub fn (mut um UndoManager) record_mutation(
result MutationResult,
inserted string,
cursor_before int,
anchor_before int
)
Performing Undo/Redo
// Undo last operation, returns (new_text, cursor, anchor) or none
pub fn (mut um UndoManager) undo(text string, cursor int, anchor int) ?(string, int, int)
// Redo undone operation
pub fn (mut um UndoManager) redo(text string, cursor int, anchor int) ?(string, int, int)
// Check availability
pub fn (um &UndoManager) can_undo() bool
pub fn (um &UndoManager) can_redo() bool
Example:
// Ctrl+Z / Cmd+Z
if e.key_code == .z && cmd_held && !shift_held {
if new_text, new_cursor, new_anchor := undo_mgr.undo(text, cursor, anchor) {
text = new_text
cursor = new_cursor
anchor = new_anchor
has_selection = (cursor != anchor)
layout = ts.layout_text(text, cfg) or { return }
}
}
// Ctrl+Shift+Z / Cmd+Shift+Z (Redo)
if e.key_code == .z && cmd_held && shift_held {
if new_text, new_cursor, new_anchor := undo_mgr.redo(text, cursor, anchor) {
// ... apply same as undo
}
}
Coalescing Control
Typing adjacent characters coalesces into single undo operation. Break coalescing on navigation to avoid undoing too much.
// Break coalescing when user navigates
pub fn (mut um UndoManager) break_coalescing()
// Clear all history
pub fn (mut um UndoManager) clear()
When to break coalescing:
- Arrow key navigation
- Mouse clicks
- After programmatic cursor moves
Example:
fn on_arrow_key() {
undo_mgr.break_coalescing()
// ... move cursor
}
IME API
Input Method Editor support for dead keys and CJK composition.
See IME-APPENDIX.md for detailed IME documentation including dead key tables and CJK IME details with overlay architecture.
Overlay API (macOS, v1.8+)
Overlay creates transparent NSView sibling above MTKView, receives IME events directly.
Overlay lifecycle:
// Create overlay (discovers MTKView automatically)
ns_window := C.sapp_macos_get_window()
overlay := vglyph.ime_overlay_create_auto(ns_window)
// Register callbacks
vglyph.ime_overlay_register_callbacks(overlay,
on_marked_text_fn, on_insert_text_fn,
on_do_command_fn, on_get_rect_fn,
on_clause_fn, user_data)
// Activate for a field
vglyph.ime_overlay_set_focused_field(overlay, 'my_field')
// Cleanup
vglyph.ime_overlay_free(overlay)
Note: Falls back to global callbacks if overlay creation fails.
Dead Keys (Working)
Dead key composition for accented Latin characters:
pub struct DeadKeyState {
pub mut:
pending ?rune // Dead key waiting for combination
pending_pos int // Document position where typed
}
// Check for pending dead key
pub fn (dks &DeadKeyState) has_pending() bool
// Record dead key press
pub fn (mut dks DeadKeyState) start_dead_key(dead rune, pos int)
// Try combining with base character
// Returns (result_string, was_combined)
pub fn (mut dks DeadKeyState) try_combine(base rune) (string, bool)
// Check if rune is a dead key
pub fn is_dead_key(r rune) bool
Example:
fn on_char(ch rune) {
if vglyph.is_dead_key(ch) {
dead_key.start_dead_key(ch, cursor)
return
}
if dead_key.has_pending() {
combined, was_combined := dead_key.try_combine(ch)
insert_string(combined) // "e" -> "e" (was_combined: true) or "`x" (false)
return
}
insert_string(ch.str())
}
IME Composition State
For CJK input methods with preedit text:
pub struct CompositionState {
pub mut:
phase CompositionPhase // none | composing
preedit_text string // Current composition string
preedit_start int // Byte offset where preedit begins
cursor_offset int // Cursor within preedit
}
// Lifecycle
pub fn (cs &CompositionState) is_composing() bool
pub fn (mut cs CompositionState) start(cursor_pos int)
pub fn (mut cs CompositionState) set_marked_text(text string, cursor int)
pub fn (mut cs CompositionState) commit() string
pub fn (mut cs CompositionState) reset()
// Geometry for rendering
pub fn (cs &CompositionState) get_composition_bounds(layout Layout) ?gg.Rect
pub fn (cs &CompositionState) get_clause_rects(layout Layout) []ClauseRects
Integration Patterns
State Management
Editor state should be heap-allocated for gg/sokol callback survival:
@[heap]
struct EditorState {
mut:
gg_ctx &gg.Context
ts &vglyph.TextSystem
text string
layout vglyph.Layout
cursor_idx int
anchor_idx int
has_selection bool
undo_mgr vglyph.UndoManager
composition vglyph.CompositionState
dead_key vglyph.DeadKeyState
}
Render Loop Pattern
fn frame(state &EditorState) {
// 1. Draw selection highlight
if state.has_selection {
start := min(state.cursor_idx, state.anchor_idx)
end := max(state.cursor_idx, state.anchor_idx)
for r in state.layout.get_selection_rects(start, end) {
state.gg_ctx.draw_rect_filled(r.x, r.y, r.width, r.height, selection_color)
}
}
// 2. Draw text
state.ts.draw_layout(state.layout, offset_x, offset_y)
// 3. Draw cursor
if pos := state.layout.get_cursor_pos(state.cursor_idx) {
state.gg_ctx.draw_rect_filled(pos.x, pos.y, 2, pos.height, cursor_color)
}
// 4. Draw IME composition underline
if state.composition.is_composing() {
for cr in state.composition.get_clause_rects(state.layout) {
// Draw underline for each clause
}
}
state.ts.commit()
}
Mutation Pattern
Always: record -> apply -> rebuild layout
fn mutate(mut state EditorState, result MutationResult, inserted string) {
// Record for undo
state.undo_mgr.record_mutation(result, inserted, state.cursor_idx, state.anchor_idx)
// Apply changes
state.text = result.new_text
state.cursor_idx = result.cursor_pos
state.anchor_idx = state.cursor_idx
state.has_selection = false
// Rebuild layout
state.layout = state.ts.layout_text(state.text, state.cfg) or { return }
}
See Also
- API.md - Core rendering API
- IME-APPENDIX.md - IME details and dead key tables
- examples/editor_demo.v - Working editor example