summaryrefslogtreecommitdiff
path: root/runtime/lua/core
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/lua/core')
-rw-r--r--runtime/lua/core/app.lua595
-rw-r--r--runtime/lua/core/cmdline.lua63
-rw-r--r--runtime/lua/core/commands.lua41
-rw-r--r--runtime/lua/core/hooks.lua61
-rw-r--r--runtime/lua/core/modes.lua20
-rw-r--r--runtime/lua/core/motion.lua66
-rw-r--r--runtime/lua/core/state.lua35
-rw-r--r--runtime/lua/core/theme.lua34
-rw-r--r--runtime/lua/core/themes.lua8
-rw-r--r--runtime/lua/core/ui.lua84
10 files changed, 1007 insertions, 0 deletions
diff --git a/runtime/lua/core/app.lua b/runtime/lua/core/app.lua
new file mode 100644
index 0000000..a596f59
--- /dev/null
+++ b/runtime/lua/core/app.lua
@@ -0,0 +1,595 @@
+local theme = require("core.theme")
+local themes = require("core.themes")
+local state = require("core.state")
+local ui = require("core.ui")
+local command = require("core.commands")
+local cmdline = require("core.cmdline")
+local hooks = require("core.hooks")
+local motion = require("core.motion")
+local modes = require("core.modes")
+
+local M = {}
+
+state.hooks = hooks.new_store()
+
+local function current_palette()
+ return themes.all[state.current_theme]
+end
+
+local function set_theme(name)
+ local palette = themes.all[name]
+ if not palette then
+ ui.set_message(state, "Unknown theme: " .. tostring(name))
+ return false
+ end
+
+ local prev = state.current_theme
+ state.current_theme = name
+ theme.apply_base16(palette)
+ ui.setup_layout(state, palette)
+ ui.set_message(state, "Theme: " .. name)
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "theme_changed", name, prev)
+ return true
+end
+
+local function update_cursor_for_current_mode()
+ if state.command_mode then
+ editor.set_cursor_shape("block")
+ editor.set_cursor_thickness(2)
+ return
+ end
+
+ if modes.is_insert(state) then
+ editor.set_cursor_shape("bar")
+ editor.set_cursor_thickness(2)
+ return
+ end
+
+ editor.set_cursor_shape("block")
+ editor.set_cursor_thickness(2)
+end
+
+local function on_commandline_key(key)
+ if key == "escape" then
+ cmdline.exit(state, ui, current_palette())
+ ui.set_message(state, "lua cmdline cancelled")
+ update_cursor_for_current_mode()
+ hooks.run(state.hooks, "commandline_exit", "cancel")
+ return true
+ end
+
+ if key == "backspace" then
+ state.command_text = state.command_text:sub(1, #state.command_text - 1)
+ ui.update_commandline_buffer(state)
+ hooks.run(state.hooks, "commandline_change", state.command_text)
+ return true
+ end
+
+ if key == "enter" then
+ local line = state.command_text
+ local kind = state.command_kind
+ cmdline.exit(state, ui, current_palette())
+ update_cursor_for_current_mode()
+ if line ~= "" then
+ hooks.run(state.hooks, "commandline_execute", line)
+ local ok, message
+ if kind == "command" then
+ ok, message = cmdline.eval_command(state, line)
+ else
+ ok, message = cmdline.eval_line(line)
+ end
+ if message then
+ ui.set_message(state, message)
+ end
+ hooks.run(state.hooks, "commandline_result", line, ok, message)
+ return ok
+ end
+ ui.set_message(state, "lua cmdline empty")
+ hooks.run(state.hooks, "commandline_exit", "empty")
+ return true
+ end
+
+ return true
+end
+
+local function define_editor_commands()
+ command.define(state, "quit", function()
+ hooks.run(state.hooks, "before_quit")
+ editor.request_quit()
+ end)
+
+ command.define(state, "save", function()
+ local path = ui.current_buffer_label()
+ local ok = editor.save_file(nil)
+ if ok then
+ ui.set_message(state, "Saved " .. path)
+ else
+ ui.set_message(state, "Save failed (no file path yet)")
+ end
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_save", ok, path)
+ end)
+
+ command.define(state, "new-buffer", function()
+ local index = editor.new_buffer()
+ if not index then
+ ui.set_message(state, "Failed to create new buffer")
+ return
+ end
+ ui.set_message(state, string.format("New buffer #%d", index))
+ ui.update_modeline(state)
+ end)
+
+ command.define(state, "next-buffer", function()
+ local count = editor.buffer_count()
+ if count <= 0 then
+ return
+ end
+
+ local current = editor.current_buffer_index()
+ local next_index = (current + 1) % count
+ editor.switch_buffer(next_index)
+ local path = editor.buffer_path()
+ if path then
+ ui.set_message(state, "Buffer: " .. path)
+ else
+ ui.set_message(state, string.format("Buffer #%d", next_index))
+ end
+ ui.update_modeline(state)
+ end)
+
+ command.define(state, "prev-buffer", function()
+ local count = editor.buffer_count()
+ if count <= 0 then
+ return
+ end
+
+ local current = editor.current_buffer_index()
+ local prev_index = (current - 1 + count) % count
+ editor.switch_buffer(prev_index)
+ local path = editor.buffer_path()
+ if path then
+ ui.set_message(state, "Buffer: " .. path)
+ else
+ ui.set_message(state, string.format("Buffer #%d", prev_index))
+ end
+ ui.update_modeline(state)
+ end)
+
+ command.define(state, "list-buffers", function()
+ local count = editor.buffer_count()
+ local current = editor.current_buffer_index()
+ local names = {}
+ for i = 0, count - 1 do
+ local path = editor.buffer_path_at(i)
+ local label = path or string.format("*buffer-%d*", i)
+ local dirty = editor.buffer_modified_at(i) and "*" or ""
+ local mark = (i == current) and ">" or " "
+ names[#names + 1] = string.format("%s%d:%s%s", mark, i, label, dirty)
+ end
+ ui.set_message(state, table.concat(names, " | "))
+ end)
+
+ command.define(state, "buffer", function()
+ ui.set_message(state, "Usage: :buffer <index>")
+ end)
+
+ command.define(state, "buffer-switch", function(index)
+ if type(index) ~= "number" then
+ ui.set_message(state, "buffer-switch expects number")
+ return
+ end
+ if editor.switch_buffer(index) then
+ local path = editor.buffer_path()
+ if path then
+ ui.set_message(state, "Buffer: " .. path)
+ else
+ ui.set_message(state, string.format("Buffer #%d", index))
+ end
+ ui.update_modeline(state)
+ else
+ ui.set_message(state, string.format("Invalid buffer index: %s", tostring(index)))
+ end
+ end)
+
+ command.define(state, "write", function()
+ command.call(state, "save")
+ end)
+
+ command.define(state, "w", function()
+ command.call(state, "write")
+ end)
+
+ command.define(state, "write-quit", function()
+ local ok = editor.save_file(nil)
+ local path = ui.current_buffer_label()
+ if ok then
+ ui.set_message(state, "Saved " .. path)
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_save", ok, path)
+ hooks.run(state.hooks, "before_quit")
+ editor.request_quit()
+ else
+ ui.set_message(state, "Save failed (no file path yet)")
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_save", ok, path)
+ end
+ end)
+
+ command.define(state, "wq", function()
+ command.call(state, "write-quit")
+ end)
+
+ command.define(state, "help", function()
+ ui.set_message(state, "Modes: i/a/o insert, esc normal. Normal: hjkl w b d ^ $ : | <leader>l lua")
+ end)
+
+ command.define(state, "toggle-theme", function()
+ if state.current_theme == "base16-default-light" then
+ set_theme("base16-gruvbox-dark-hard")
+ else
+ set_theme("base16-default-light")
+ end
+ end)
+
+ command.define(state, "lua-commandline", function()
+ if state.command_mode then
+ cmdline.exit(state, ui, current_palette())
+ hooks.run(state.hooks, "commandline_exit", "toggle")
+ else
+ cmdline.enter(state, ui, current_palette(), "lua")
+ hooks.run(state.hooks, "commandline_enter")
+ end
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "named-command-prompt", function()
+ if state.command_mode then
+ cmdline.exit(state, ui, current_palette())
+ hooks.run(state.hooks, "commandline_exit", "toggle")
+ else
+ cmdline.enter(state, ui, current_palette(), "command")
+ hooks.run(state.hooks, "commandline_enter")
+ end
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "enter-insert-mode", function()
+ modes.set_mode(state, "insert")
+ ui.update_modeline(state)
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "append-mode", function()
+ editor.move_right()
+ modes.set_mode(state, "insert")
+ ui.update_modeline(state)
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "open-below", function()
+ editor.move_end_of_line()
+ editor.insert("\n")
+ modes.set_mode(state, "insert")
+ ui.update_modeline(state)
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "enter-normal-mode", function()
+ modes.set_mode(state, "normal")
+ state.leader_pending = false
+ ui.update_modeline(state)
+ update_cursor_for_current_mode()
+ end)
+
+ command.define(state, "move-left", function() editor.move_left() end)
+ command.define(state, "move-right", function() editor.move_right() end)
+ command.define(state, "move-up", function() editor.move_up() end)
+ command.define(state, "move-down", function() editor.move_down() end)
+ command.define(state, "delete-char", function() editor.delete_forward() end)
+ command.define(state, "beginning-of-line", function() editor.move_beginning_of_line() end)
+ command.define(state, "end-of-line", function() editor.move_end_of_line() end)
+ command.define(state, "forward-word", function() motion.forward_word() end)
+ command.define(state, "backward-word", function() motion.backward_word() end)
+ command.define(state, "backspace", function() editor.backspace() end)
+ command.define(state, "delete-forward", function() editor.delete_forward() end)
+ command.define(state, "newline", function() editor.insert("\n") end)
+ command.define(state, "tab", function() editor.insert(" ") end)
+
+ command.bind(state, "C-q", "quit")
+ command.bind(state, "C-s", "save")
+ command.bind(state, "C-h", "help")
+ command.bind(state, "C-t", "toggle-theme")
+ command.bind(state, "C-a", "beginning-of-line")
+ command.bind(state, "C-e", "end-of-line")
+ command.bind(state, "C-f", "forward-word")
+ command.bind(state, "C-b", "backward-word")
+
+ state.leader_map["l"] = "lua-commandline"
+
+ command.bind(state, "left", "move-left")
+ command.bind(state, "right", "move-right")
+ command.bind(state, "up", "move-up")
+ command.bind(state, "down", "move-down")
+ command.bind(state, "backspace", "backspace")
+ command.bind(state, "delete", "delete-forward")
+ command.bind(state, "enter", "newline")
+ command.bind(state, "tab", "tab")
+end
+
+function M.add_hook(event_name, fn)
+ return hooks.add(state.hooks, event_name, fn)
+end
+
+function M.bind_key(key, target)
+ if type(target) == "string" then
+ command.bind(state, key, target)
+ return true
+ end
+
+ if type(target) == "function" then
+ return command.bind_fn(state, key, target)
+ end
+
+ return false, "bind target must be command name or function"
+end
+
+function M.bind_leader(key, target)
+ if type(key) ~= "string" or key == "" then
+ return false, "leader key must be non-empty"
+ end
+
+ if type(target) == "string" then
+ state.leader_map[key] = target
+ return true
+ end
+
+ if type(target) == "function" then
+ local name = string.format("leader:%s", key)
+ command.define(state, name, target)
+ state.leader_map[key] = name
+ return true
+ end
+
+ return false, "leader bind target must be command name or function"
+end
+
+local function try_extended_named_command(input)
+ local name, arg = input:match("^(%S+)%s+(.+)$")
+ if not name then
+ return false
+ end
+
+ if name ~= "buffer" and name ~= "b" then
+ return false
+ end
+
+ local index = tonumber(arg)
+ if not index then
+ return false
+ end
+
+ command.call(state, "buffer-switch", index)
+ return true
+end
+
+function M.bootstrap()
+ state.mode_line_buffer = editor.aux_buffer_new()
+ state.message_buffer = editor.aux_buffer_new()
+ state.command_buffer = editor.aux_buffer_new()
+ if not state.mode_line_buffer or not state.message_buffer or not state.command_buffer then
+ error("failed to allocate UI buffers")
+ end
+
+ editor.set_font("monospace:size=14")
+ set_theme(state.current_theme)
+ update_cursor_for_current_mode()
+ ui.set_message(state, state.message_text)
+ ui.update_commandline_buffer(state)
+ define_editor_commands()
+ ui.update_modeline(state)
+ ui.set_message(state, "ready")
+ hooks.run(state.hooks, "bootstrap")
+end
+
+function M.on_open_file(path)
+ if not path or path == "" then
+ return false
+ end
+
+ local ok = editor.open_file(path)
+ if ok then
+ ui.set_message(state, "Opened " .. path)
+ else
+ ui.set_message(state, "Could not open: " .. path)
+ end
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_open_file", ok, path)
+ return ok
+end
+
+function M.on_key(key)
+ hooks.run(state.hooks, "before_key", key)
+
+ if hooks.run_first_truthy(state.hooks, "intercept_key", key) then
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+
+ if state.command_mode then
+ local handled = on_commandline_key(key)
+ hooks.run(state.hooks, "after_key", key, handled)
+ return handled
+ end
+
+ if modes.is_insert(state) and key == "escape" then
+ state.suppress_next_text = true
+ command.call(state, "enter-normal-mode")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+
+ if modes.is_normal(state) then
+ if state.leader_pending then
+ state.suppress_next_text = true
+ state.leader_pending = false
+ local command_name = state.leader_map[key]
+ if command_name then
+ command.call(state, command_name)
+ else
+ ui.set_message(state, string.format("No <leader> mapping for '%s'", key))
+ end
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+
+ if key == ":" then
+ state.suppress_next_text = true
+ command.call(state, "named-command-prompt")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+
+ if key == state.leader_key then
+ state.suppress_next_text = true
+ state.leader_pending = true
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+
+ if key == "i" then
+ state.suppress_next_text = true
+ command.call(state, "enter-insert-mode")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "a" then
+ state.suppress_next_text = true
+ command.call(state, "append-mode")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "o" then
+ state.suppress_next_text = true
+ command.call(state, "open-below")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "h" then
+ state.suppress_next_text = true
+ command.call(state, "move-left")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "j" then
+ state.suppress_next_text = true
+ command.call(state, "move-down")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "k" then
+ state.suppress_next_text = true
+ command.call(state, "move-up")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "l" then
+ state.suppress_next_text = true
+ command.call(state, "move-right")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "w" then
+ state.suppress_next_text = true
+ command.call(state, "forward-word")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "b" then
+ state.suppress_next_text = true
+ command.call(state, "backward-word")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "d" then
+ state.suppress_next_text = true
+ command.call(state, "delete-char")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "^" then
+ state.suppress_next_text = true
+ command.call(state, "beginning-of-line")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ if key == "$" then
+ state.suppress_next_text = true
+ command.call(state, "end-of-line")
+ hooks.run(state.hooks, "after_key", key, true)
+ return true
+ end
+ end
+
+ local command_name = state.keymap[key]
+ if not command_name then
+ hooks.run(state.hooks, "after_key", key, false)
+ return false
+ end
+
+ local ok, err = command.call(state, command_name)
+ if not ok and err then
+ ui.set_message(state, err)
+ end
+ hooks.run(state.hooks, "after_key", key, ok)
+ return ok
+end
+
+function M.try_named_command_extension(input)
+ return try_extended_named_command(input)
+end
+
+function M.on_text(text)
+ hooks.run(state.hooks, "before_text", text)
+
+ if state.suppress_next_text then
+ state.suppress_next_text = false
+ hooks.run(state.hooks, "after_text", text, true)
+ return true
+ end
+
+ if hooks.run_first_truthy(state.hooks, "intercept_text", text) then
+ hooks.run(state.hooks, "after_text", text, true)
+ return true
+ end
+
+ if state.command_mode then
+ state.command_text = state.command_text .. text
+ ui.update_commandline_buffer(state)
+ hooks.run(state.hooks, "after_text", text, true)
+ return true
+ end
+
+ if not modes.is_insert(state) then
+ hooks.run(state.hooks, "after_text", text, true)
+ return true
+ end
+
+ local ok = editor.insert(text)
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_text", text, ok)
+ return ok
+end
+
+function M.on_tick()
+ hooks.run(state.hooks, "before_tick")
+ ui.setup_layout(state, current_palette())
+ ui.update_modeline(state)
+ hooks.run(state.hooks, "after_layout")
+ hooks.run(state.hooks, "after_tick")
+end
+
+return M
diff --git a/runtime/lua/core/cmdline.lua b/runtime/lua/core/cmdline.lua
new file mode 100644
index 0000000..4d7e44a
--- /dev/null
+++ b/runtime/lua/core/cmdline.lua
@@ -0,0 +1,63 @@
+local M = {}
+
+function M.eval_line(line)
+ local chunk, load_err = load("return " .. line, "=lemacs-cmd", "t", _G)
+ if not chunk then
+ chunk, load_err = load(line, "=lemacs-cmd", "t", _G)
+ end
+
+ if not chunk then
+ return false, "lua compile error: " .. tostring(load_err)
+ end
+
+ local ok, result = pcall(chunk)
+ if not ok then
+ return false, "lua runtime error: " .. tostring(result)
+ end
+
+ if result ~= nil then
+ return true, "=> " .. tostring(result)
+ end
+
+ return true, "lua ok"
+end
+
+function M.eval_command(state, line)
+ local name = (line or ""):gsub("^%s+", ""):gsub("%s+$", "")
+ if name == "" then
+ return false, "command prompt empty"
+ end
+
+ local app = require("core.app")
+ if app.try_named_command_extension(name) then
+ return true, nil
+ end
+
+ local command = require("core.commands")
+ local ok, err = command.call(state, name)
+ if not ok then
+ return false, err
+ end
+
+ return true, nil
+end
+
+function M.enter(state, ui, palette, kind)
+ state.command_mode = true
+ state.command_kind = kind or "lua"
+ state.command_text = ""
+ ui.update_commandline_buffer(state)
+ ui.update_modeline(state)
+ ui.setup_layout(state, palette)
+end
+
+function M.exit(state, ui, palette)
+ state.command_mode = false
+ state.command_kind = "lua"
+ state.command_text = ""
+ ui.update_commandline_buffer(state)
+ ui.update_modeline(state)
+ ui.setup_layout(state, palette)
+end
+
+return M
diff --git a/runtime/lua/core/commands.lua b/runtime/lua/core/commands.lua
new file mode 100644
index 0000000..2aa484d
--- /dev/null
+++ b/runtime/lua/core/commands.lua
@@ -0,0 +1,41 @@
+local M = {}
+
+function M.define(state, name, fn)
+ state.commands[name] = fn
+end
+
+function M.bind(state, key, command_name)
+ state.keymap[key] = command_name
+end
+
+function M.call(state, name, ...)
+ local fn = state.commands[name]
+ if not fn then
+ return false, "Unknown command: " .. tostring(name)
+ end
+
+ local ok, err = pcall(fn, ...)
+ if not ok then
+ return false, tostring(err)
+ end
+
+ return true
+end
+
+function M.bind_fn(state, key, fn)
+ if type(key) ~= "string" or key == "" then
+ return false, "key must be a non-empty string"
+ end
+ if type(fn) ~= "function" then
+ return false, "bind_fn requires a function"
+ end
+
+ local name = string.format("anon:%d", state.next_anon_command_id)
+ state.next_anon_command_id = state.next_anon_command_id + 1
+
+ state.commands[name] = fn
+ state.keymap[key] = name
+ return true
+end
+
+return M
diff --git a/runtime/lua/core/hooks.lua b/runtime/lua/core/hooks.lua
new file mode 100644
index 0000000..9623e9c
--- /dev/null
+++ b/runtime/lua/core/hooks.lua
@@ -0,0 +1,61 @@
+local M = {}
+
+function M.new_store()
+ return {}
+end
+
+function M.add(store, event_name, fn)
+ if type(event_name) ~= "string" or event_name == "" then
+ return false, "event name must be a non-empty string"
+ end
+ if type(fn) ~= "function" then
+ return false, "hook must be a function"
+ end
+
+ local bucket = store[event_name]
+ if not bucket then
+ bucket = {}
+ store[event_name] = bucket
+ end
+
+ bucket[#bucket + 1] = fn
+ return true
+end
+
+function M.run(store, event_name, ...)
+ local bucket = store[event_name]
+ if not bucket then
+ return true
+ end
+
+ local ok_all = true
+ for i = 1, #bucket do
+ local ok, err = pcall(bucket[i], ...)
+ if not ok then
+ ok_all = false
+ print(string.format("lemacs hook error [%s]: %s", event_name, tostring(err)))
+ end
+ end
+
+ return ok_all
+end
+
+function M.run_first_truthy(store, event_name, ...)
+ local bucket = store[event_name]
+ if not bucket then
+ return false
+ end
+
+ for i = 1, #bucket do
+ local ok, result = pcall(bucket[i], ...)
+ if not ok then
+ print(string.format("lemacs hook error [%s]: %s", event_name, tostring(result)))
+ elseif result then
+ return true
+ end
+ end
+
+ return false
+end
+
+return M
diff --git a/runtime/lua/core/modes.lua b/runtime/lua/core/modes.lua
new file mode 100644
index 0000000..c0cb751
--- /dev/null
+++ b/runtime/lua/core/modes.lua
@@ -0,0 +1,20 @@
+local M = {}
+
+function M.set_mode(state, mode_name)
+ if mode_name ~= "normal" and mode_name ~= "insert" then
+ return false, "unknown mode: " .. tostring(mode_name)
+ end
+
+ state.mode = mode_name
+ return true
+end
+
+function M.is_insert(state)
+ return state.mode == "insert"
+end
+
+function M.is_normal(state)
+ return state.mode == "normal"
+end
+
+return M
diff --git a/runtime/lua/core/motion.lua b/runtime/lua/core/motion.lua
new file mode 100644
index 0000000..a6bcb80
--- /dev/null
+++ b/runtime/lua/core/motion.lua
@@ -0,0 +1,66 @@
+local M = {}
+
+local function is_word_char(ch)
+ if ch == "" then
+ return false
+ end
+ local byte = string.byte(ch)
+ if not byte then
+ return false
+ end
+
+ if byte >= string.byte("a") and byte <= string.byte("z") then
+ return true
+ end
+ if byte >= string.byte("A") and byte <= string.byte("Z") then
+ return true
+ end
+ if byte >= string.byte("0") and byte <= string.byte("9") then
+ return true
+ end
+ return ch == "_"
+end
+
+function M.forward_word()
+ local len = editor.buffer_length()
+ local point = editor.point()
+ if point >= len then
+ return
+ end
+
+ local ch = editor.char_at(point)
+ if is_word_char(ch) then
+ while point < len and is_word_char(editor.char_at(point)) do
+ point = point + 1
+ end
+ else
+ while point < len and not is_word_char(editor.char_at(point)) do
+ point = point + 1
+ end
+ while point < len and is_word_char(editor.char_at(point)) do
+ point = point + 1
+ end
+ end
+
+ editor.set_point(point)
+end
+
+function M.backward_word()
+ local point = editor.point()
+ if point <= 0 then
+ return
+ end
+
+ point = point - 1
+ while point >= 0 and not is_word_char(editor.char_at(point)) do
+ point = point - 1
+ end
+
+ while point >= 0 and is_word_char(editor.char_at(point)) do
+ point = point - 1
+ end
+
+ editor.set_point(point + 1)
+end
+
+return M
diff --git a/runtime/lua/core/state.lua b/runtime/lua/core/state.lua
new file mode 100644
index 0000000..783c137
--- /dev/null
+++ b/runtime/lua/core/state.lua
@@ -0,0 +1,35 @@
+local M = {}
+
+M.commands = {}
+M.keymap = {}
+M.current_theme = "base16-default-light"
+
+M.mode_line_buffer = nil
+M.message_buffer = nil
+M.command_buffer = nil
+M.picker_buffer = nil
+
+M.message_text = "ready"
+M.command_mode = false
+M.command_kind = "lua"
+M.command_text = ""
+M.mode = "normal"
+M.suppress_next_text = false
+M.leader_key = "space"
+M.leader_pending = false
+M.leader_map = {}
+M.picker = {
+ active = false,
+ title = "",
+ query = "",
+ items = {},
+ filtered = {},
+ selected = 1,
+ on_select = nil,
+ on_cancel = nil,
+}
+
+M.hooks = {}
+M.next_anon_command_id = 1
+
+return M
diff --git a/runtime/lua/core/theme.lua b/runtime/lua/core/theme.lua
new file mode 100644
index 0000000..cead212
--- /dev/null
+++ b/runtime/lua/core/theme.lua
@@ -0,0 +1,34 @@
+local M = {}
+
+local function rgba(hex)
+ local h = hex:gsub("#", "")
+ if #h ~= 6 then
+ return 0, 0, 0, 255
+ end
+
+ local r = tonumber(h:sub(1, 2), 16) or 0
+ local g = tonumber(h:sub(3, 4), 16) or 0
+ local b = tonumber(h:sub(5, 6), 16) or 0
+ return r, g, b, 255
+end
+
+local default_map = {
+ bg_top = "base00",
+ bg_bottom = "base01",
+ text = "base05",
+ cursor = "base08",
+ accent = "base09",
+}
+
+function M.apply_base16(palette, color_map)
+ local map = color_map or default_map
+ for editor_color, base_key in pairs(map) do
+ local hex = palette[base_key]
+ if hex then
+ local r, g, b, a = rgba(hex)
+ editor.set_theme_color(editor_color, r, g, b, a)
+ end
+ end
+end
+
+return M
diff --git a/runtime/lua/core/themes.lua b/runtime/lua/core/themes.lua
new file mode 100644
index 0000000..8a76645
--- /dev/null
+++ b/runtime/lua/core/themes.lua
@@ -0,0 +1,8 @@
+local M = {}
+
+M.all = {
+ ["base16-default-light"] = require("themes.base16-default-light"),
+ ["base16-gruvbox-dark-hard"] = require("themes.base16-gruvbox-dark-hard"),
+}
+
+return M
diff --git a/runtime/lua/core/ui.lua b/runtime/lua/core/ui.lua
new file mode 100644
index 0000000..9ef25d1
--- /dev/null
+++ b/runtime/lua/core/ui.lua
@@ -0,0 +1,84 @@
+local M = {}
+
+local function hex_to_rgba(hex)
+ local h = hex:gsub("#", "")
+ if #h ~= 6 then
+ return 0, 0, 0, 255
+ end
+
+ local r = tonumber(h:sub(1, 2), 16) or 0
+ local g = tonumber(h:sub(3, 4), 16) or 0
+ local b = tonumber(h:sub(5, 6), 16) or 0
+ return r, g, b, 255
+end
+
+local function current_buffer_label()
+ local path = editor.buffer_path()
+ if not path then
+ return "*scratch*"
+ end
+ return path
+end
+
+function M.update_modeline(state)
+ if not state.mode_line_buffer then
+ return
+ end
+
+ local dirty = editor.buffer_modified() and "*" or "-"
+ local cmd_mode = string.format("[%s-cmd]", state.command_kind or "lua")
+ local mode = state.command_mode and cmd_mode or string.format("[%s]", state.mode or "normal")
+ local leader = state.leader_pending and " [leader]" or ""
+ local text = string.format("lemacs %s | %s | %s %s%s", dirty, current_buffer_label(), state.current_theme, mode, leader)
+ editor.aux_buffer_set_text(state.mode_line_buffer, text)
+end
+
+function M.set_message(state, text)
+ state.message_text = text
+ if state.message_buffer then
+ editor.aux_buffer_set_text(state.message_buffer, text)
+ end
+end
+
+function M.update_commandline_buffer(state)
+ if not state.command_buffer then
+ return
+ end
+ local prefix = (state.command_kind == "command") and ":" or ":lua "
+ editor.aux_buffer_set_text(state.command_buffer, prefix .. state.command_text)
+end
+
+function M.setup_layout(state, palette)
+ local width = editor.screen_width()
+ local height = editor.screen_height()
+ local mode_h = 28
+ local msg_h = 24
+ local cmd_h = state.command_mode and 24 or 0
+ local body_h = height - mode_h - msg_h - cmd_h
+ if body_h < 1 then
+ body_h = 1
+ end
+
+ local base00_r, base00_g, base00_b = hex_to_rgba(palette.base00)
+ local base01_r, base01_g, base01_b = hex_to_rgba(palette.base01)
+ local base02_r, base02_g, base02_b = hex_to_rgba(palette.base02)
+ local base03_r, base03_g, base03_b = hex_to_rgba(palette.base03)
+ local base05_r, base05_g, base05_b = hex_to_rgba(palette.base05)
+ local base06_r, base06_g, base06_b = hex_to_rgba(palette.base06)
+ local base09_r, base09_g, base09_b = hex_to_rgba(palette.base09)
+
+ editor.layout_reset()
+ editor.layout_add_view(0, 0, 0, 0, width, body_h, true, base00_r, base00_g, base00_b, 255, base05_r, base05_g, base05_b, 255)
+ editor.layout_add_view(1, state.mode_line_buffer, 0, body_h, width, mode_h, false, base02_r, base02_g, base02_b, 235, base06_r, base06_g, base06_b, 255)
+ editor.layout_add_view(1, state.message_buffer, 0, body_h + mode_h, width, msg_h, false, base01_r, base01_g, base01_b, 240, base09_r, base09_g, base09_b, 255)
+
+ if state.command_mode then
+ editor.layout_add_view(1, state.command_buffer, 0, body_h + mode_h + msg_h, width, cmd_h, false, base03_r, base03_g, base03_b, 245, base06_r, base06_g, base06_b, 255)
+ end
+end
+
+function M.current_buffer_label()
+ return current_buffer_label()
+end
+
+return M