Files
Asuro/src/app/app.notepad.pas
T

1134 lines
39 KiB
ObjectPascal

{
Prog->Notepad - GUI text editor
Features:
- Open / Save / Save As via inline file picker
- Text selection (Shift+Arrow keys)
- Delete selected range on Backspace / Del
- Tab to indent selected lines (4 spaces), Shift+Tab to dedent
- Ctrl+A to select all, Ctrl+S to save
- Word-wrap toggle (via Wrap button)
- Status bar showing Line, Col, filename, and dirty flag
Key design notes:
- lv_event_t.stop_processing (bit 1 at struct byte offset 24) is set
directly to prevent LVGL's default textarea key handler from also
acting on keys we have fully handled ourselves.
- Shift state is read from driver.hid.keyboard.is_shift / driver.hid.keyboard.is_ctrl.
- selection anchor (sel_anchor) is stored as a sint32 byte offset;
-1 means no selection is active.
@author(Aaron Hance <[email protected]>)
}
unit app.notepad;
interface
procedure init();
implementation
uses
driver.video.desktop,
app.filepicker,
driver.hid.keyboard,
memory.heap,
driver.video.lvgl,
proc.mgr,
proc.types,
driver.storage.types,
driver.storage.filedispatch,
core.strings,
core.strings.helpers,
io.syslog,
io.stdio,
debug.tracer,
core.util, arch.x86.util,
driver.storage.vfs,
driver.video,
driver.video.windows;
const
WIN_W = 700;
WIN_H = 500;
TOOLBAR_H = 40;
STATUSBAR_H = 24;
MAX_NOTEPAD_INST = 8;
{ LV_DRAW_LABEL_NO_TXT_SEL — sentinel returned when no selection is set }
LV_NO_SEL = $FFFF;
{ LV_TEXTAREA_CURSOR_LAST — pass to set_cursor_pos to go to end-of-text }
LV_CURSOR_LAST = $7FFF;
FILE_BUF_SIZE = 65536; { 64 KB read / write buffer }
{
Byte offset and bitmask for lv_event_t.stop_processing.
struct lv_event_t (32-bit ABI):
offset 0: current_target (ptr)
offset 4: original_target (ptr)
offset 8: code (uint32 — lv_event_code_t)
offset 12: user_data (ptr)
offset 16: param (ptr)
offset 20: prev (ptr)
offset 24: flags byte [bit0=deleted, bit1=stop_processing, bit2=stop_bubbling]
}
LV_EVT_STOP_OFF = 24;
LV_EVT_STOP_BIT = 2; { bit 1 = stop_processing }
type
PNotepadState = ^TNotepadState;
TNotepadState = record
win_id : uint32;
pid : uint32;
ta : Plv_obj;
status_label : Plv_obj;
dirty_label : Plv_obj;
currentPath : pchar;
isDirty : boolean;
sel_anchor : sint32;
end;
{ ============================================================
Module-level state (multi-instance)
============================================================ }
var
g_instances : array[0..MAX_NOTEPAD_INST-1] of PNotepadState;
g_inst_count : uint32;
{ File-dispatch deferred open }
g_dispatch_path : pchar;
g_dispatch_pending : uint32;
g_dispatch_timer : Plv_timer;
{ ============================================================
Forward declarations
============================================================ }
procedure notepad_entry(ctx: PProcessContext); forward;
function launchInstance: PNotepadState; forward;
procedure launch; forward;
procedure onClose(wid: uint32); forward;
procedure updateStatusBar(state: PNotepadState); forward;
procedure saveFile(state: PNotepadState); forward;
procedure loadFile(state: PNotepadState; path: pchar); forward;
procedure performNew(state: PNotepadState); forward;
procedure freeState(state: PNotepadState); forward;
procedure doCloseWindow(state: PNotepadState); forward;
procedure showDiscardMsgbox(discard_cb: lv_event_cb_t; state: PNotepadState); forward;
procedure showErrorMsgbox(title: pchar; msg: pchar); forward;
procedure mbox_cancel_cb(e: Plv_event); cdecl; forward;
procedure np_open_done_cb(path: pchar; userdata: pointer); cdecl; forward;
procedure np_saveas_done_cb(path: pchar; userdata: pointer); cdecl; forward;
function findStateByWid(wid: uint32): PNotepadState; forward;
procedure addInstance(state: PNotepadState); forward;
procedure removeInstance(state: PNotepadState); forward;
{ ============================================================
stopEvent — set LVGL's stop_processing flag in the event
struct so the widget's default key handler does not run.
User callbacks fire BEFORE the widget class handler, so this
correctly suppresses LVGL's default action.
============================================================ }
procedure stopEvent(e: Plv_event);
begin
puint8(uint32(e) + LV_EVT_STOP_OFF)^ :=
puint8(uint32(e) + LV_EVT_STOP_OFF)^ or LV_EVT_STOP_BIT;
end;
{ ============================================================
buildSplicedString
Returns a new kalloc'd pchar containing:
src[0 .. from_idx) + insert + src[to_idx .. end)
insert may be nil (no insertion). Caller must kfree result.
============================================================ }
function buildSplicedString(src: pchar; from_idx, to_idx: uint32;
insert: pchar): pchar;
var
src_len : uint32;
ins_len : uint32;
result_len : uint32;
buf : pchar;
pos : uint32;
begin
src_len := stringSize(src);
if insert <> nil then ins_len := stringSize(insert)
else ins_len := 0;
if from_idx > src_len then from_idx := src_len;
if to_idx > src_len then to_idx := src_len;
if to_idx < from_idx then to_idx := from_idx;
result_len := from_idx + ins_len + (src_len - to_idx);
buf := pchar(kalloc(result_len + 1));
memset(uint32(buf), 0, result_len + 1);
pos := 0;
if from_idx > 0 then begin
memcpy(uint32(src), uint32(buf), from_idx);
pos := from_idx;
end;
if ins_len > 0 then begin
memcpy(uint32(insert), uint32(@buf[pos]), ins_len);
pos := pos + ins_len;
end;
if to_idx < src_len then
memcpy(uint32(@src[to_idx]), uint32(@buf[pos]), src_len - to_idx);
buildSplicedString := buf;
end;
{ ============================================================
applyIndent
Indent (or dedent) every line that overlaps [sel_start, sel_end)
by 4 spaces. Returns a new kalloc'd string. Caller must kfree.
============================================================ }
function applyIndent(src: pchar; sel_start, sel_end: uint32;
dedent: boolean): pchar;
var
src_len : uint32;
out_buf : pchar;
out_pos : uint32;
i : uint32;
first_ls : uint32;
j : uint32;
begin
src_len := stringSize(src);
if sel_end > src_len then sel_end := src_len;
if sel_start > src_len then sel_start := src_len;
{ Find start of the line that contains sel_start }
first_ls := sel_start;
while (first_ls > 0) and (src[first_ls - 1] <> char(10)) do
dec(first_ls);
{ Generous output buffer: original + 4 bytes per line (worst case ~512 lines) }
out_buf := pchar(kalloc(src_len + 2049));
memset(uint32(out_buf), 0, src_len + 2049);
out_pos := 0;
{ Copy everything before the affected region verbatim }
if first_ls > 0 then begin
memcpy(uint32(src), uint32(out_buf), first_ls);
out_pos := first_ls;
end;
{ Process from first_ls onward, line by line }
i := first_ls;
while i <= src_len do begin
if i = src_len then break; { nothing left }
if i >= sel_end then begin
{ Past the affected region: copy the remainder verbatim }
if i < src_len then begin
memcpy(uint32(@src[i]), uint32(@out_buf[out_pos]), src_len - i);
out_pos := out_pos + (src_len - i);
end;
break;
end;
{ Apply indent / dedent at the start of this affected line }
if not dedent then begin
out_buf[out_pos] := ' ';
out_buf[out_pos + 1] := ' ';
out_buf[out_pos + 2] := ' ';
out_buf[out_pos + 3] := ' ';
out_pos := out_pos + 4;
end else begin
{ Strip up to 4 leading spaces }
j := 0;
while (j < 4) and (i < src_len) and (src[i] = ' ') do begin
inc(i);
inc(j);
end;
end;
{ Copy the rest of the line up to and including the newline }
while i < src_len do begin
out_buf[out_pos] := src[i];
inc(out_pos);
if src[i] = char(10) then begin
inc(i);
break;
end;
inc(i);
end;
end;
out_buf[out_pos] := char(0);
applyIndent := out_buf;
end;
{ ============================================================
computeLineCol
Calculate 1-based line and column numbers for a byte offset.
============================================================ }
procedure computeLineCol(src: pchar; byte_pos: uint32;
var line_out, col_out: uint32);
var
i : uint32;
line : uint32;
col : uint32;
len : uint32;
begin
line := 1;
col := 1;
len := stringSize(src);
if byte_pos > len then byte_pos := len;
i := 0;
while i < byte_pos do begin
if src[i] = char(10) then begin
inc(line);
col := 1;
end else
inc(col);
inc(i);
end;
line_out := line;
col_out := col;
end;
{ ============================================================
freeState — release all kalloc'd fields and the record itself.
============================================================ }
procedure freeState(state: PNotepadState);
begin
if state = nil then exit;
if state^.ta <> nil then lv_group_remove_obj(state^.ta);
if state^.currentPath <> nil then kfree(void(state^.currentPath));
kfree(void(state));
end;
{ ============================================================
Instance management helpers
============================================================ }
function findStateByWid(wid: uint32): PNotepadState;
var i: uint32;
begin
findStateByWid := nil;
if g_inst_count > 0 then
for i := 0 to g_inst_count - 1 do
if (g_instances[i] <> nil) and (g_instances[i]^.win_id = wid) then begin
findStateByWid := g_instances[i];
exit;
end;
end;
procedure addInstance(state: PNotepadState);
begin
if g_inst_count < MAX_NOTEPAD_INST then begin
g_instances[g_inst_count] := state;
inc(g_inst_count);
end;
end;
procedure removeInstance(state: PNotepadState);
var i, j: uint32;
begin
if g_inst_count > 0 then
for i := 0 to g_inst_count - 1 do
if g_instances[i] = state then begin
{ Shift remaining entries down }
if i + 1 < g_inst_count then
for j := i to g_inst_count - 2 do
g_instances[j] := g_instances[j + 1];
dec(g_inst_count);
g_instances[g_inst_count] := nil;
exit;
end;
end;
{ ============================================================
notepad_entry — lightweight process entry
============================================================ }
procedure notepad_entry(ctx : PProcessContext);
begin
while (ctx^.State <> psFinished) and (ctx^.PendingMsg <> smKill) and (ctx^.PendingMsg <> smTerminate) do
proc.mgr.proc_yield;
end;
{ ============================================================
doCloseWindow — unconditionally destroy a specific instance.
Called by discard-confirm dialogs and by onClose when clean.
============================================================ }
procedure doCloseWindow(state: PNotepadState);
var wid : uint32;
begin
if state = nil then exit;
wid := state^.win_id;
removeInstance(state);
if state^.pid <> 0 then begin
proc.mgr.kill(state^.pid);
state^.pid := 0;
end;
if state^.ta <> nil then lv_group_remove_obj(state^.ta);
state^.ta := nil;
driver.video.windows.destroyWindow(wid);
freeState(state);
end;
{ ============================================================
updateStatusBar
============================================================ }
procedure updateStatusBar(state: PNotepadState);
var
txt : pchar;
cpos : uint32;
line, col : uint32;
s1, s2, s3 : pchar;
begin
if (state = nil) or (state^.status_label = nil) then exit;
txt := lv_textarea_get_text(state^.ta);
cpos := lv_textarea_get_cursor_pos(state^.ta);
computeLineCol(txt, cpos, line, col);
s1 := intToString(line);
s2 := stringConcat('Ln ', s1);
kfree(void(s1));
s1 := intToString(col);
s3 := stringConcat(s2, stringConcat(' Col ', s1));
kfree(void(s2));
kfree(void(s1));
if state^.currentPath <> nil then
s1 := stringConcat(' ', state^.currentPath)
else
s1 := stringCopy(' Untitled');
s2 := stringConcat(s3, s1);
kfree(void(s3));
kfree(void(s1));
lv_label_set_text(state^.status_label, s2);
kfree(void(s2));
if state^.isDirty then
lv_label_set_text(state^.dirty_label, '*')
else
lv_label_set_text(state^.dirty_label, '');
end;
{ ============================================================
saveFile
============================================================ }
procedure saveFile(state: PNotepadState);
var
fHandle : driver.storage.vfs.TFileHandle;
fError : driver.storage.types.TError;
txt : pchar;
buf : pchar;
len : uint32;
written : uint32;
s1, s2 : pchar;
begin
debug.tracer.push_trace('notepad.saveFile');
if state^.currentPath = nil then begin
app.filepicker.show_save('Save As', '/disk', nil, nil, @np_saveas_done_cb, state);
debug.tracer.pop_trace;
exit;
end;
{ Show Saving... immediately for feedback before the blocking I/O call }
lv_label_set_text(state^.status_label, 'Saving...');
txt := lv_textarea_get_text(state^.ta);
len := stringSize(txt);
if len > FILE_BUF_SIZE - 1 then len := FILE_BUF_SIZE - 1;
buf := pchar(kalloc(FILE_BUF_SIZE));
memset(uint32(buf), 0, FILE_BUF_SIZE);
memcpy(uint32(txt), uint32(buf), len);
fHandle := driver.storage.vfs.OpenFile(state^.currentPath, omReadWrite, @fError);
if fHandle = 0 then
fHandle := driver.storage.vfs.OpenFile(state^.currentPath, omCreate, @fError);
if fHandle = 0 then begin
kfree(void(buf));
io.syslog.logln('NOTEPAD', 'saveFile: OpenFile failed');
showErrorMsgbox('Save Failed', 'Could not open file for writing.');
updateStatusBar(state);
debug.tracer.pop_trace;
exit;
end;
written := driver.storage.vfs.WriteFile(fHandle, 0, puint8(buf), len);
driver.storage.vfs.CloseFile(fHandle);
kfree(void(buf));
if written <> len then begin
io.syslog.logln('NOTEPAD', 'saveFile: WriteFile returned 0');
showErrorMsgbox('Save Failed', 'File write failed. Check filesystem.');
updateStatusBar(state);
debug.tracer.pop_trace;
exit;
end;
state^.isDirty := false;
s1 := stringCopy('Saved: ');
s2 := stringConcat(s1, state^.currentPath);
kfree(void(s1));
lv_label_set_text(state^.status_label, s2);
kfree(void(s2));
lv_label_set_text(state^.dirty_label, '');
debug.tracer.pop_trace;
end;
{ ============================================================
loadFile
============================================================ }
procedure loadFile(state: PNotepadState; path: pchar);
var
fHandle : driver.storage.vfs.TFileHandle;
fError : driver.storage.types.TError;
buf : pchar;
begin
debug.tracer.push_trace('notepad.loadFile');
fHandle := driver.storage.vfs.OpenFile(path, omRead, @fError);
if (fHandle = 0) or (fError <> eNone) then begin
io.syslog.logln('NOTEPAD', 'loadFile: could not open file');
showErrorMsgbox('Open Failed', 'Could not open file for reading.');
debug.tracer.pop_trace;
exit;
end;
buf := pchar(kalloc(FILE_BUF_SIZE));
memset(uint32(buf), 0, FILE_BUF_SIZE);
driver.storage.vfs.ReadFile(fHandle, 0, puint8(buf), FILE_BUF_SIZE - 1);
driver.storage.vfs.CloseFile(fHandle);
if state^.currentPath <> nil then kfree(void(state^.currentPath));
state^.currentPath := stringCopy(path);
lv_textarea_set_text(state^.ta, buf);
{ lv_textarea_set_text fires VALUE_CHANGED which marks dirty — reset it }
state^.isDirty := false;
lv_textarea_clear_selection(state^.ta);
state^.sel_anchor := -1;
lv_textarea_set_cursor_pos(state^.ta, 0);
updateStatusBar(state);
kfree(void(buf));
debug.tracer.pop_trace;
end;
{ ============================================================
performNew — clear buffer and reset path
============================================================ }
procedure performNew(state: PNotepadState);
begin
lv_textarea_set_text(state^.ta, '');
state^.isDirty := false;
state^.sel_anchor := -1;
lv_textarea_clear_selection(state^.ta);
if state^.currentPath <> nil then begin
kfree(void(state^.currentPath));
state^.currentPath := nil;
end;
updateStatusBar(state);
end;
{ ============================================================
Discard-confirmation msgbox callbacks
============================================================ }
{ Generic cancel: just close the msgbox (passed as user_data) }
procedure mbox_cancel_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
mbox : Plv_obj;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
mbox := Plv_obj(lv_event_get_user_data(e));
lv_obj_delete(mbox);
end;
{ Discard + close window }
procedure mbox_close_discard_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
mbox : Plv_obj;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
mbox := Plv_obj(lv_event_get_user_data(e));
state := PNotepadState(lv_obj_get_user_data(mbox));
lv_obj_delete(mbox);
doCloseWindow(state);
end;
{ Discard + new document }
procedure mbox_new_discard_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
mbox : Plv_obj;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
mbox := Plv_obj(lv_event_get_user_data(e));
state := PNotepadState(lv_obj_get_user_data(mbox));
lv_obj_delete(mbox);
if state <> nil then performNew(state);
end;
{ ============================================================
showDiscardMsgbox — generic "Unsaved changes / Discard?" dialog.
discard_cb receives the mbox pointer as user_data.
============================================================ }
procedure showDiscardMsgbox(discard_cb: lv_event_cb_t; state: PNotepadState);
var
mbox : Plv_obj;
scr : Plv_obj;
discard_b : Plv_obj;
cancel_b : Plv_obj;
begin
scr := lv_screen_active;
mbox := lv_msgbox_create(scr);
lv_msgbox_add_title(mbox, 'Unsaved Changes');
lv_msgbox_add_close_button(mbox);
{ Store owner state in mbox user_data so callbacks can retrieve it }
lv_obj_set_user_data(mbox, state);
cancel_b := lv_msgbox_add_footer_button(mbox, 'Cancel');
discard_b := lv_msgbox_add_footer_button(mbox, 'Discard');
lv_obj_add_event_cb(discard_b, discard_cb, LV_EVENT_CLICKED, mbox);
lv_obj_add_event_cb(cancel_b, @mbox_cancel_cb, LV_EVENT_CLICKED, mbox);
end;
{ ============================================================
showErrorMsgbox — modal error dialog with OK button
============================================================ }
procedure showErrorMsgbox(title: pchar; msg: pchar);
var
mbox : Plv_obj;
ok_b : Plv_obj;
begin
mbox := lv_msgbox_create(lv_screen_active);
lv_msgbox_add_title(mbox, title);
lv_msgbox_add_text(mbox, msg);
ok_b := lv_msgbox_add_footer_button(mbox, 'OK');
lv_obj_add_event_cb(ok_b, @mbox_cancel_cb, LV_EVENT_CLICKED, mbox);
end;
{ ============================================================
File picker done callbacks
============================================================ }
procedure np_open_done_cb(path: pchar; userdata: pointer); cdecl;
var
state: PNotepadState;
begin
state := PNotepadState(userdata);
if (path = nil) or (state = nil) then begin
if path <> nil then kfree(void(path));
exit;
end;
loadFile(state, path);
kfree(void(path));
end;
procedure np_saveas_done_cb(path: pchar; userdata: pointer); cdecl;
var
state: PNotepadState;
begin
state := PNotepadState(userdata);
if (path = nil) or (state = nil) then begin
if path <> nil then kfree(void(path));
exit;
end;
if state^.currentPath <> nil then kfree(void(state^.currentPath));
state^.currentPath := stringCopy(path);
kfree(void(path));
saveFile(state);
end;
{ ============================================================
Toolbar callbacks
============================================================ }
procedure new_btn_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
if state^.isDirty then
showDiscardMsgbox(@mbox_new_discard_cb, state)
else
performNew(state);
end;
procedure open_btn_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
app.filepicker.show_open('Open File', '/disk', nil, @np_open_done_cb, state);
end;
procedure save_btn_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
saveFile(state);
end;
procedure saveas_btn_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_CLICKED then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
app.filepicker.show_save('Save As', '/disk', nil, nil, @np_saveas_done_cb, state);
end;
{ ============================================================
Main textarea — key handler
User callbacks fire BEFORE the widget default handler.
We call stopEvent(e) for every key we handle ourselves so
LVGL does not also act on it.
============================================================ }
procedure ta_key_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
key : uint32;
ta : Plv_obj;
lbl : Plv_obj;
curr_pos : uint32;
new_pos : uint32;
anchor : uint32;
sel_s : uint32;
sel_e : uint32;
src : pchar;
new_text : pchar;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_KEY then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
ta := state^.ta;
lbl := lv_textarea_get_label(ta);
key := lv_event_get_key(e);
curr_pos := lv_textarea_get_cursor_pos(ta);
{ ---- Ctrl+S: save ---- }
if driver.hid.keyboard.is_ctrl and (key = ord('s')) then begin
saveFile(state);
stopEvent(e);
exit;
end;
{ ---- Ctrl+A: select all ---- }
if driver.hid.keyboard.is_ctrl and (key = ord('a')) then begin
src := lv_textarea_get_text(ta);
state^.sel_anchor := 0;
lv_label_set_text_selection_start(lbl, 0);
lv_label_set_text_selection_end(lbl, stringSize(src));
lv_textarea_set_cursor_pos(ta, LV_CURSOR_LAST);
stopEvent(e);
exit;
end;
{ ---- Plain arrow keys (no Shift): clear any existing selection ---- }
if (not driver.hid.keyboard.is_shift) and
((key = LV_KEY_LEFT) or (key = LV_KEY_RIGHT) or
(key = LV_KEY_UP) or (key = LV_KEY_DOWN)) then begin
if state^.sel_anchor <> -1 then begin
state^.sel_anchor := -1;
lv_textarea_clear_selection(ta);
end;
{ Let LVGL handle cursor movement — do NOT call stopEvent }
exit;
end;
{ ---- Home / End: clear selection, let LVGL handle position ---- }
if (key = LV_KEY_HOME) or (key = LV_KEY_END) then begin
state^.sel_anchor := -1;
lv_textarea_clear_selection(ta);
exit;
end;
{ ---- Shift+Arrow: extend / create selection ---- }
if driver.hid.keyboard.is_shift and
((key = LV_KEY_LEFT) or (key = LV_KEY_RIGHT) or
(key = LV_KEY_UP) or (key = LV_KEY_DOWN)) then begin
{ Initialise anchor on first Shift+Arrow }
if state^.sel_anchor = -1 then
state^.sel_anchor := sint32(curr_pos);
{ Move cursor ourselves so we get the updated position }
case key of
LV_KEY_LEFT : lv_textarea_cursor_left(ta);
LV_KEY_RIGHT : lv_textarea_cursor_right(ta);
LV_KEY_UP : lv_textarea_cursor_up(ta);
LV_KEY_DOWN : lv_textarea_cursor_down(ta);
end;
new_pos := lv_textarea_get_cursor_pos(ta);
anchor := uint32(state^.sel_anchor);
if new_pos < anchor then begin
lv_label_set_text_selection_start(lbl, new_pos);
lv_label_set_text_selection_end(lbl, anchor);
end else begin
lv_label_set_text_selection_start(lbl, anchor);
lv_label_set_text_selection_end(lbl, new_pos);
end;
{ Stop LVGL from also moving the cursor (would double-move) }
stopEvent(e);
exit;
end;
{ ---- Backspace / Del with active selection: delete the selection ---- }
if ((key = LV_KEY_BACKSPACE) or (key = LV_KEY_DEL)) and
(state^.sel_anchor <> -1) then begin
anchor := uint32(state^.sel_anchor);
if anchor < curr_pos then begin
sel_s := anchor;
sel_e := curr_pos;
end else begin
sel_s := curr_pos;
sel_e := anchor;
end;
src := lv_textarea_get_text(ta);
new_text := buildSplicedString(src, sel_s, sel_e, nil);
lv_textarea_set_text(ta, new_text);
kfree(void(new_text));
lv_textarea_set_cursor_pos(ta, sint32(sel_s));
state^.sel_anchor := -1;
lv_textarea_clear_selection(ta);
state^.isDirty := true;
updateStatusBar(state);
stopEvent(e); { prevent LVGL from also deleting one more char }
exit;
end;
{ ---- Tab key ---- }
if key = LV_KEY_NEXT then begin
if state^.sel_anchor <> -1 then begin
{ Indent or dedent the selected lines }
anchor := uint32(state^.sel_anchor);
if anchor < curr_pos then begin
sel_s := anchor;
sel_e := curr_pos;
end else begin
sel_s := curr_pos;
sel_e := anchor;
end;
src := lv_textarea_get_text(ta);
new_text := applyIndent(src, sel_s, sel_e, driver.hid.keyboard.is_shift);
lv_textarea_set_text(ta, new_text);
kfree(void(new_text));
state^.sel_anchor := -1;
lv_textarea_clear_selection(ta);
state^.isDirty := true;
updateStatusBar(state);
end else begin
{ No selection: insert 4 spaces }
lv_textarea_add_text(ta, ' ');
end;
stopEvent(e); { prevent LVGL from inserting a raw tab char }
exit;
end;
{ ---- Any other key with selection active: clear selection, let LVGL type ---- }
if state^.sel_anchor <> -1 then begin
state^.sel_anchor := -1;
lv_textarea_clear_selection(ta);
end;
end;
{ ============================================================
Main textarea — VALUE_CHANGED (dirty tracking + status bar)
============================================================ }
procedure ta_changed_cb(e: Plv_event); cdecl;
var
code : lv_event_code_t;
state : PNotepadState;
begin
code := lv_event_get_code(e);
if code <> LV_EVENT_VALUE_CHANGED then exit;
state := PNotepadState(lv_event_get_user_data(e));
if state = nil then exit;
state^.isDirty := true;
updateStatusBar(state);
end;
{ ============================================================
makeToolBtn — compact toolbar button helper
============================================================ }
function makeToolBtn(parent: Plv_obj; text: pchar; w: sint32;
state_ptr: PNotepadState;
cb: lv_event_cb_t): Plv_obj;
var
btn, lbl_obj : Plv_obj;
begin
btn := lv_button_create(parent);
lv_obj_remove_style_all(btn);
lv_obj_set_size(btn, w, 30);
lv_obj_set_style_bg_color(btn, lv_color_make(45, 50, 68), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_radius(btn, 4, 0);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_layout(btn, LV_LAYOUT_FLEX, 0);
lv_obj_set_flex_flow(btn, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btn, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lbl_obj := lv_label_create(btn);
lv_label_set_text(lbl_obj, text);
lv_obj_set_style_text_color(lbl_obj, lv_color_make(210, 215, 230), 0);
lv_obj_set_style_text_font(lbl_obj, @lv_font_montserrat_14, 0);
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, state_ptr);
makeToolBtn := btn;
end;
{ ============================================================
onClose — called by the driver.video.windows unit when the X button is pressed
============================================================ }
procedure onClose(wid: uint32);
var state : PNotepadState;
begin
debug.tracer.push_trace('notepad.onClose');
state := findStateByWid(wid);
if state = nil then begin debug.tracer.pop_trace; exit; end;
if state^.isDirty then begin
showDiscardMsgbox(@mbox_close_discard_cb, state);
debug.tracer.pop_trace;
exit;
end;
doCloseWindow(state);
debug.tracer.pop_trace;
end;
{ ============================================================
launch — create the window and populate the UI
============================================================ }
{ ============================================================
launchInstance — create a new notepad window; returns the
state pointer or nil on failure.
============================================================ }
function launchInstance: PNotepadState;
var
scr_w, scr_h : sint32;
wx, wy : sint32;
content : Plv_obj;
toolbar : Plv_obj;
statusbar : Plv_obj;
ta : Plv_obj;
sl : Plv_obj;
dl : Plv_obj;
state : PNotepadState;
ctx : PProcessContext;
wid : uint32;
begin
debug.tracer.push_trace('notepad.launchInstance');
launchInstance := nil;
if g_inst_count >= MAX_NOTEPAD_INST then begin
debug.tracer.pop_trace; exit;
end;
scr_w := sint32(driver.video.frontBufferWidth);
scr_h := sint32(driver.video.frontBufferHeight);
wx := (scr_w - WIN_W) div 2 + sint32(g_inst_count) * 30;
wy := (scr_h - WIN_H) div 2 - 30 + sint32(g_inst_count) * 30;
wid := driver.video.windows.createWindow('Notepad', wx, wy, WIN_W, WIN_H,
@onClose, nil);
if wid = 0 then begin
debug.tracer.pop_trace; exit;
end;
content := driver.video.windows.getWindowContent(wid);
if content = nil then begin
driver.video.windows.destroyWindow(wid);
debug.tracer.pop_trace; exit;
end;
{ Allocate and zero-initialise state }
state := PNotepadState(kalloc(sizeof(TNotepadState)));
memset(uint32(state), 0, sizeof(TNotepadState));
state^.win_id := wid;
state^.sel_anchor := -1;
addInstance(state);
{ ---- Content area: flex column ---- }
lv_obj_set_style_layout(content, LV_LAYOUT_FLEX, 0);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(content, 0, 0);
lv_obj_set_style_pad_row(content, 0, 0);
lv_obj_remove_flag(content, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(content, lv_color_make(28, 31, 40), 0);
lv_obj_set_style_bg_opa(content, LV_OPA_COVER, 0);
{ ---- Toolbar ---- }
toolbar := lv_obj_create(content);
lv_obj_remove_style_all(toolbar);
lv_obj_set_size(toolbar, lv_pct(100), TOOLBAR_H);
lv_obj_set_style_bg_color(toolbar, lv_color_make(28, 31, 40), 0);
lv_obj_set_style_bg_opa(toolbar, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(toolbar, lv_color_make(48, 52, 68), 0);
lv_obj_set_style_border_side(toolbar, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_border_width(toolbar, 1, 0);
lv_obj_set_style_pad_all(toolbar, 5, 0);
lv_obj_set_style_pad_column(toolbar, 4, 0);
lv_obj_set_style_layout(toolbar, LV_LAYOUT_FLEX, 0);
lv_obj_set_flex_flow(toolbar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(toolbar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(toolbar, LV_OBJ_FLAG_SCROLLABLE);
{ File group: New, Open, Save, Save As }
makeToolBtn(toolbar, 'New', 58, state, @new_btn_cb);
makeToolBtn(toolbar, 'Open', 64, state, @open_btn_cb);
makeToolBtn(toolbar, 'Save', 58, state, @save_btn_cb);
makeToolBtn(toolbar, 'Save As', 80, state, @saveas_btn_cb);
{ ---- Main textarea ---- }
ta := lv_textarea_create(content);
lv_obj_set_size(ta, lv_pct(100), 0);
lv_obj_set_flex_grow(ta, 1);
lv_textarea_set_text(ta, '');
lv_textarea_set_text_selection(ta, true);
{ Background and text }
lv_obj_set_style_bg_color(ta, lv_color_make(20, 22, 32), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ta, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(ta, lv_color_make(210, 218, 240), LV_PART_MAIN);
lv_obj_set_style_text_font(ta, @hack_14, LV_PART_MAIN);
lv_obj_set_style_border_width(ta, 0, LV_PART_MAIN);
lv_obj_set_style_radius(ta, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(ta, 8, LV_PART_MAIN);
{ Cursor }
lv_obj_set_style_bg_color(ta, lv_color_make(100, 160, 255), LV_PART_CURSOR);
lv_obj_set_style_border_width(ta, 0, LV_PART_CURSOR);
{ Selection highlight }
lv_obj_set_style_bg_color(ta, lv_color_make(55, 90, 190), LV_PART_SELECTED);
lv_obj_set_style_bg_opa(ta, LV_OPA_COVER, LV_PART_SELECTED);
{ Register with driver.hid.keyboard group }
lv_group_add_obj(lvgl_get_kb_group, ta);
lv_group_focus_obj(ta);
{ Always wrap — set before attaching callbacks }
lv_label_set_long_mode(lv_textarea_get_label(ta), LV_LABEL_LONG_WRAP);
{ Attach event callbacks (state passed as user_data) }
lv_obj_add_event_cb(ta, @ta_key_cb, LV_EVENT_KEY, state);
lv_obj_add_event_cb(ta, @ta_changed_cb, LV_EVENT_VALUE_CHANGED, state);
state^.ta := ta;
{ ---- Status bar ---- }
statusbar := lv_obj_create(content);
lv_obj_remove_style_all(statusbar);
lv_obj_set_size(statusbar, lv_pct(100), STATUSBAR_H);
lv_obj_set_style_bg_color(statusbar, lv_color_make(28, 31, 40), 0);
lv_obj_set_style_bg_opa(statusbar, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(statusbar, lv_color_make(48, 52, 68), 0);
lv_obj_set_style_border_side(statusbar, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_width(statusbar, 1, 0);
lv_obj_set_style_layout(statusbar, LV_LAYOUT_FLEX, 0);
lv_obj_set_flex_flow(statusbar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(statusbar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_left(statusbar, 8, 0);
lv_obj_remove_flag(statusbar, LV_OBJ_FLAG_SCROLLABLE);
sl := lv_label_create(statusbar);
lv_label_set_text(sl, 'Ln 1 Col 1 Untitled');
lv_obj_set_style_text_color(sl, lv_color_make(140, 150, 170), 0);
lv_obj_set_style_text_font(sl, @lv_font_montserrat_14, 0);
state^.status_label := sl;
dl := lv_label_create(statusbar);
lv_label_set_text(dl, '');
lv_obj_set_style_text_color(dl, lv_color_make(255, 180, 60), 0);
lv_obj_set_style_text_font(dl, @lv_font_montserrat_14, 0);
state^.dirty_label := dl;
{ Create process so app.notepad appears in PS and is manageable }
ctx := proc.mgr.create('Notepad', @notepad_entry, void(state), 1);
if ctx <> nil then begin
state^.pid := ctx^.ProcessID;
driver.video.windows.setWindowOwner(wid, ctx^.ProcessID);
end;
launchInstance := state;
debug.tracer.pop_trace;
end;
{ launch — desktop-launcher callback; creates a new empty notepad window }
procedure launch;
begin
launchInstance;
end;
{ ============================================================
File dispatch support
============================================================ }
{ Poll timer — runs in LVGL context, checks for deferred file opens }
procedure notepad_dispatch_poll(tmr: Plv_timer); cdecl;
var
path : pchar;
state : PNotepadState;
begin
if puint32(@g_dispatch_pending)^ <> 1 then exit;
puint32(@g_dispatch_pending)^ := 0;
path := g_dispatch_path;
g_dispatch_path := nil;
if path = nil then exit;
{ Always open a new window for dispatched files }
state := launchInstance;
if state <> nil then
loadFile(state, path);
kfree(void(path));
end;
{ Handler callback — called by driver.storage.filedispatch from
any process context (typically the file-browser worker).
We cannot call LVGL directly (Lesson #15), so we store the path
and let the poll timer pick it up in LVGL context. }
function notepad_file_handler(path : pchar;
params : PParamList;
stdin_buf, stdout_buf, stderr_buf : POutBuf) : uint32;
begin
debug.tracer.push_trace('notepad.file_handler');
notepad_file_handler := 0;
{ If a dispatch is already pending, reject to avoid leaking the path }
if puint32(@g_dispatch_pending)^ = 1 then begin
debug.tracer.pop_trace;
exit;
end;
g_dispatch_path := stringCopy(path);
puint32(@g_dispatch_pending)^ := 1;
{ Return 1 to signal "handled — UI will open asynchronously" }
notepad_file_handler := 1;
debug.tracer.pop_trace;
end;
{ ============================================================
init — register with the driver.video.desktop program launcher
and with the file dispatch system for text extensions
============================================================ }
procedure init();
begin
debug.tracer.push_trace('notepad.init');
g_inst_count := 0;
g_dispatch_path := nil;
g_dispatch_pending := 0;
driver.video.desktop.registerProgram('Notepad', @launch);
{ Register as the handler for common text-file extensions }
driver.storage.filedispatch.registerExtHandler(
'.txt,.md,.log,.cfg,.ini,.json,.xml,.yml,.yaml,.csv,.pas,.inc,.c,.h,.cpp,.py,.js,.ts,.sh,.bat,.conf,.toml',
'Text',
@notepad_file_handler);
{ Create a 100ms poll timer for deferred file-dispatch opens }
g_dispatch_timer := lv_timer_create(@notepad_dispatch_poll, 100, nil);
debug.tracer.pop_trace;
end;
end.