278 lines
6.9 KiB
Nim
278 lines
6.9 KiB
Nim
# SPDX-License-Identifier: LUL-1.0
|
|
# Copyright (c) 2026 Markus Maiwald
|
|
# Stewardship: Self Sovereign Society Foundation
|
|
#
|
|
# This file is part of the Nexus SDK.
|
|
# See legal/LICENSE_UNBOUND.md for license terms.
|
|
|
|
# MARKUS MAIWALD (ARCHITECT) | VOXIS FORGE (AI)
|
|
# Scribe v3: The Sovereign TUI Editor
|
|
# Phase 24: Full TUI with Navigation & Multi-Sector IO
|
|
|
|
import strutils, sequtils
|
|
import ../../libs/membrane/libc as lb
|
|
|
|
# --- CONSTANTS ---
|
|
const
|
|
KEY_CTRL_Q = char(17)
|
|
KEY_CTRL_S = char(19)
|
|
KEY_CTRL_X = char(24) # Alternative exit
|
|
KEY_ESC = char(27)
|
|
KEY_BACKSPACE = char(127)
|
|
KEY_ENTER = char(13) # CR
|
|
KEY_LF = char(10)
|
|
|
|
# --- STATE ---
|
|
var lines: seq[string]
|
|
var cursor_x: int = 0
|
|
var cursor_y: int = 0 # Line index in buffer
|
|
var scroll_y: int = 0 # Index of top visible line
|
|
var screen_rows: int = 20 # Fixed for now, or detect?
|
|
var screen_cols: int = 80
|
|
var filename: string = ""
|
|
var status_msg: string = "CTRL-S: Save | CTRL-Q: Quit"
|
|
var is_running: bool = true
|
|
|
|
# --- TERMINAL HELPERS ---
|
|
|
|
proc write_raw(s: string) =
|
|
if s.len > 0:
|
|
discard lb.write(cint(1), cast[pointer](unsafeAddr s[0]), csize_t(s.len))
|
|
|
|
proc term_clear() =
|
|
write_raw("\x1b[2J") # Clear entire screen
|
|
|
|
proc term_move(row, col: int) =
|
|
# ANSI is 1-indexed
|
|
write_raw("\x1b[" & $(row + 1) & ";" & $(col + 1) & "H")
|
|
|
|
proc term_hide_cursor() = write_raw("\x1b[?25l")
|
|
proc term_show_cursor() = write_raw("\x1b[?25h")
|
|
|
|
# --- FILE IO ---
|
|
|
|
proc load_file(fname: string) =
|
|
lines = @[]
|
|
let fd = lb.open(fname.cstring, 0)
|
|
if fd >= 0:
|
|
var content = ""
|
|
var buf: array[512, char]
|
|
while true:
|
|
let n = lb.read(fd, addr buf[0], 512)
|
|
if n <= 0: break
|
|
for i in 0..<n: content.add(buf[i])
|
|
discard lb.close(fd)
|
|
|
|
if content.len > 0:
|
|
lines = content.splitLines()
|
|
else:
|
|
lines.add("")
|
|
else:
|
|
# New File
|
|
lines.add("")
|
|
|
|
if lines.len == 0: lines.add("")
|
|
|
|
proc save_file() =
|
|
var content = lines.join("\n")
|
|
# Ensure trailing newline often expected
|
|
if content.len > 0 and content[^1] != '\n': content.add('\n')
|
|
|
|
# FLAGS: O_WRONLY(1) | O_CREAT(64) | O_TRUNC(512) = 577
|
|
let fd = lb.open(filename.cstring, 577)
|
|
if fd < 0:
|
|
status_msg = "Error: Save Failed (VFS Open)."
|
|
return
|
|
|
|
let n = lb.write(fd, cast[pointer](unsafeAddr content[0]), csize_t(content.len))
|
|
discard lb.close(fd)
|
|
status_msg = "Saved " & $n & " bytes."
|
|
|
|
# --- LOGIC ---
|
|
|
|
proc scroll_to_cursor() =
|
|
if cursor_y < scroll_y:
|
|
scroll_y = cursor_y
|
|
if cursor_y >= scroll_y + screen_rows:
|
|
scroll_y = cursor_y - screen_rows + 1
|
|
|
|
proc render() =
|
|
term_hide_cursor()
|
|
term_move(0, 0)
|
|
|
|
# Draw Content
|
|
for i in 0..<screen_rows:
|
|
let line_idx = scroll_y + i
|
|
term_move(i, 0)
|
|
write_raw("\x1b[K") # Clear Line
|
|
|
|
if line_idx < lines.len:
|
|
var line = lines[line_idx]
|
|
# Truncate for display if needed? For now wrap or let terminal handle
|
|
if line.len > screen_cols: line = line[0..<screen_cols]
|
|
write_raw(line)
|
|
else:
|
|
write_raw("~") # Vim style empty lines
|
|
|
|
# Draw Status Bar
|
|
term_move(screen_rows, 0)
|
|
write_raw("\x1b[7m") # Invert
|
|
var bar = " " & filename & " - " & $cursor_x & ":" & $cursor_y & " | " & status_msg
|
|
while bar.len < screen_cols: bar.add(" ")
|
|
if bar.len > screen_cols: bar = bar[0..<screen_cols]
|
|
write_raw(bar)
|
|
write_raw("\x1b[0m") # Reset
|
|
|
|
# Position Cursor
|
|
term_move(cursor_y - scroll_y, cursor_x)
|
|
term_show_cursor()
|
|
|
|
proc insert_char(c: char) =
|
|
if cursor_y >= lines.len: lines.add("")
|
|
var line = lines[cursor_y]
|
|
if cursor_x > line.len: cursor_x = line.len
|
|
|
|
if cursor_x == line.len:
|
|
line.add(c)
|
|
else:
|
|
line.insert($c, cursor_x)
|
|
|
|
lines[cursor_y] = line
|
|
cursor_x += 1
|
|
|
|
proc insert_newline() =
|
|
if cursor_y >= lines.len: lines.add("") # Should catch
|
|
|
|
let current_line = lines[cursor_y]
|
|
if cursor_x >= current_line.len:
|
|
# Append new empty line
|
|
lines.insert("", cursor_y + 1)
|
|
else:
|
|
# Split line
|
|
let left = current_line[0..<cursor_x]
|
|
let right = current_line[cursor_x..^1]
|
|
lines[cursor_y] = left
|
|
lines.insert(right, cursor_y + 1)
|
|
|
|
cursor_y += 1
|
|
cursor_x = 0
|
|
|
|
proc backspace() =
|
|
if cursor_y >= lines.len: return
|
|
|
|
if cursor_x > 0:
|
|
var line = lines[cursor_y]
|
|
# Delete char at x-1
|
|
if cursor_x - 1 < line.len:
|
|
line.delete(cursor_x - 1, cursor_x - 1)
|
|
lines[cursor_y] = line
|
|
cursor_x -= 1
|
|
elif cursor_y > 0:
|
|
# Merge with previous line
|
|
let current = lines[cursor_y]
|
|
let prev_len = lines[cursor_y - 1].len
|
|
lines[cursor_y - 1].add(current)
|
|
lines.delete(cursor_y)
|
|
cursor_y -= 1
|
|
cursor_x = prev_len
|
|
|
|
proc handle_arrow(code: char) =
|
|
case code:
|
|
of 'A': # UP
|
|
if cursor_y > 0: cursor_y -= 1
|
|
of 'B': # DOWN
|
|
if cursor_y < lines.len - 1: cursor_y += 1
|
|
of 'C': # RIGHT
|
|
if cursor_y < lines.len:
|
|
if cursor_x < lines[cursor_y].len: cursor_x += 1
|
|
elif cursor_y < lines.len - 1: # Wrap to next line
|
|
cursor_y += 1
|
|
cursor_x = 0
|
|
of 'D': # LEFT
|
|
if cursor_x > 0: cursor_x -= 1
|
|
elif cursor_y > 0: # Wrap to prev line end
|
|
cursor_y -= 1
|
|
cursor_x = lines[cursor_y].len
|
|
else: discard
|
|
|
|
# Snap cursor to line length
|
|
if cursor_y < lines.len:
|
|
if cursor_x > lines[cursor_y].len: cursor_x = lines[cursor_y].len
|
|
|
|
# --- MAIN LOOP ---
|
|
|
|
proc read_input() =
|
|
# We need a custom input loop that handles escapes
|
|
# This uses libc.read on fd 0 (stdin)
|
|
|
|
var c: char
|
|
let n = lb.read(0, addr c, 1)
|
|
if n <= 0: return
|
|
|
|
if c == KEY_CTRL_Q or c == KEY_CTRL_X:
|
|
is_running = false
|
|
return
|
|
|
|
if c == KEY_CTRL_S:
|
|
save_file()
|
|
return
|
|
|
|
if c == KEY_ESC:
|
|
# Potential Sequence
|
|
# Busy wait briefly for next char to confirm sequence vs lone ESC
|
|
# In a real OS we'd have poll/timeout. Here we hack.
|
|
# Actually, let's just try to read immediately.
|
|
var c2: char
|
|
let n2 = lb.read(0, addr c2, 1) # This might block if not buffered?
|
|
# Our lb.read is non-blocking if ring is empty, returns 0.
|
|
# But for a sequence, the chars should be in the packet together or close.
|
|
# If 0, it was just ESC.
|
|
|
|
if n2 > 0 and c2 == '[':
|
|
var c3: char
|
|
let n3 = lb.read(0, addr c3, 1)
|
|
if n3 > 0:
|
|
handle_arrow(c3)
|
|
return
|
|
|
|
if c == KEY_BACKSPACE or c == '\b':
|
|
backspace()
|
|
return
|
|
|
|
if c == KEY_ENTER or c == KEY_LF:
|
|
insert_newline()
|
|
return
|
|
|
|
# Normal char
|
|
if c >= ' ' and c <= '~':
|
|
insert_char(c)
|
|
|
|
proc start_editor*(fname: string) =
|
|
filename = fname
|
|
is_running = true
|
|
cursor_x = 0
|
|
cursor_y = 0
|
|
scroll_y = 0
|
|
status_msg = "CTRL-S: Save | CTRL-Q: Quit"
|
|
|
|
write_raw("[Scribe] Loading " & fname & "...\n")
|
|
load_file(fname)
|
|
|
|
term_clear()
|
|
|
|
while is_running:
|
|
# lb.pump_membrane_stack() - Handled by Kernel
|
|
scroll_to_cursor()
|
|
render()
|
|
|
|
# Input Loop (Non-blocking check mostly)
|
|
# We loop quickly to feel responsive
|
|
read_input()
|
|
|
|
# Yield slightly
|
|
for i in 0..5000: discard
|
|
|
|
term_clear()
|
|
term_move(0, 0)
|
|
write_raw("Scribe Closed.\n")
|