summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt40
-rw-r--r--Makefile46
-rwxr-xr-xbuild/lemacsbin0 -> 47688 bytes
-rw-r--r--build/obj/core/buffer.d2
-rw-r--r--build/obj/core/buffer.obin0 -> 6456 bytes
-rw-r--r--build/obj/core/editor.d4
-rw-r--r--build/obj/core/editor.obin0 -> 10960 bytes
-rw-r--r--build/obj/core/lua_bridge.d5
-rw-r--r--build/obj/core/lua_bridge.obin0 -> 28920 bytes
-rw-r--r--build/obj/main.d7
-rw-r--r--build/obj/main.obin0 -> 16536 bytes
-rw-r--r--init.lua75
-rw-r--r--runtime/init.lua42
-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
-rw-r--r--runtime/lua/themes/base16-default-light.lua19
-rw-r--r--runtime/lua/themes/base16-gruvbox-dark-hard.lua19
-rw-r--r--shell.nix13
-rw-r--r--src/core/buffer.c296
-rw-r--r--src/core/buffer.h38
-rw-r--r--src/core/editor.c466
-rw-r--r--src/core/editor.h96
-rw-r--r--src/core/lua_bridge.c611
-rw-r--r--src/core/lua_bridge.h26
-rw-r--r--src/main.c429
-rw-r--r--test.txt5
34 files changed, 3246 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..d8cfcf6
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.16)
+project(lemacs C)
+
+set(CMAKE_C_STANDARD 99)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+set(CMAKE_C_EXTENSIONS OFF)
+
+find_package(PkgConfig REQUIRED)
+
+pkg_check_modules(RAYLIB REQUIRED IMPORTED_TARGET raylib)
+pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
+
+find_package(Lua 5.4 QUIET)
+if(Lua_FOUND)
+ set(LEMACS_LUA_LIBRARIES ${LUA_LIBRARIES})
+ set(LEMACS_LUA_INCLUDE_DIRS ${LUA_INCLUDE_DIR})
+else()
+ pkg_check_modules(LUA54 REQUIRED IMPORTED_TARGET lua5.4)
+endif()
+
+add_executable(lemacs
+ src/main.c
+ src/core/buffer.c
+ src/core/editor.c
+ src/core/lua_bridge.c
+)
+
+target_compile_definitions(lemacs PRIVATE LEMACS_RUNTIME_INIT="${CMAKE_SOURCE_DIR}/runtime/init.lua")
+
+target_include_directories(lemacs
+ PRIVATE
+ src
+)
+
+if(Lua_FOUND)
+ target_include_directories(lemacs PRIVATE ${LEMACS_LUA_INCLUDE_DIRS})
+ target_link_libraries(lemacs PRIVATE PkgConfig::RAYLIB PkgConfig::FONTCONFIG ${LEMACS_LUA_LIBRARIES} m)
+else()
+ target_link_libraries(lemacs PRIVATE PkgConfig::RAYLIB PkgConfig::FONTCONFIG PkgConfig::LUA54 m)
+endif()
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..e6f5cda
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,46 @@
+.PHONY: build test run clean rebuild
+
+CC ?= gcc
+PKG_CONFIG ?= pkg-config
+
+BUILD_DIR := build
+OBJ_DIR := $(BUILD_DIR)/obj
+BIN := $(BUILD_DIR)/lemacs
+RUN_FILE ?=
+
+SRC := \
+ src/main.c \
+ src/core/buffer.c \
+ src/core/editor.c \
+ src/core/lua_bridge.c
+
+OBJ := $(SRC:src/%.c=$(OBJ_DIR)/%.o)
+DEP := $(OBJ:.o=.d)
+
+CPPFLAGS += -Isrc -DLEMACS_RUNTIME_INIT=\"$(CURDIR)/runtime/init.lua\"
+CFLAGS += -std=c99 -Wall -Wextra -Wpedantic -O2 -MMD -MP
+LDLIBS += $(shell $(PKG_CONFIG) --libs raylib lua5.4 fontconfig) -lm
+LDFLAGS +=
+
+build: $(BIN)
+
+$(BIN): $(OBJ)
+ mkdir -p "$(BUILD_DIR)"
+ $(CC) $(OBJ) $(LDFLAGS) $(LDLIBS) -o "$@"
+
+$(OBJ_DIR)/%.o: src/%.c
+ mkdir -p "$(dir $@)"
+ $(CC) $(CPPFLAGS) $(CFLAGS) $(shell $(PKG_CONFIG) --cflags raylib lua5.4 fontconfig) -c "$<" -o "$@"
+
+test: build
+ printf '%s\n' 'No test suite is configured yet.'
+
+run: build
+ "./$(BIN)" $(RUN_FILE)
+
+clean:
+ rm -rf "$(BUILD_DIR)"
+
+rebuild: clean build
+
+-include $(DEP)
diff --git a/build/lemacs b/build/lemacs
new file mode 100755
index 0000000..e3cf056
--- /dev/null
+++ b/build/lemacs
Binary files differ
diff --git a/build/obj/core/buffer.d b/build/obj/core/buffer.d
new file mode 100644
index 0000000..ca3bf04
--- /dev/null
+++ b/build/obj/core/buffer.d
@@ -0,0 +1,2 @@
+build/obj/core/buffer.o: src/core/buffer.c src/core/buffer.h
+src/core/buffer.h:
diff --git a/build/obj/core/buffer.o b/build/obj/core/buffer.o
new file mode 100644
index 0000000..7bedb3c
--- /dev/null
+++ b/build/obj/core/buffer.o
Binary files differ
diff --git a/build/obj/core/editor.d b/build/obj/core/editor.d
new file mode 100644
index 0000000..d89e37f
--- /dev/null
+++ b/build/obj/core/editor.d
@@ -0,0 +1,4 @@
+build/obj/core/editor.o: src/core/editor.c src/core/editor.h \
+ src/core/buffer.h
+src/core/editor.h:
+src/core/buffer.h:
diff --git a/build/obj/core/editor.o b/build/obj/core/editor.o
new file mode 100644
index 0000000..1d76919
--- /dev/null
+++ b/build/obj/core/editor.o
Binary files differ
diff --git a/build/obj/core/lua_bridge.d b/build/obj/core/lua_bridge.d
new file mode 100644
index 0000000..d645b44
--- /dev/null
+++ b/build/obj/core/lua_bridge.d
@@ -0,0 +1,5 @@
+build/obj/core/lua_bridge.o: src/core/lua_bridge.c src/core/lua_bridge.h \
+ src/core/editor.h src/core/buffer.h
+src/core/lua_bridge.h:
+src/core/editor.h:
+src/core/buffer.h:
diff --git a/build/obj/core/lua_bridge.o b/build/obj/core/lua_bridge.o
new file mode 100644
index 0000000..56b65a1
--- /dev/null
+++ b/build/obj/core/lua_bridge.o
Binary files differ
diff --git a/build/obj/main.d b/build/obj/main.d
new file mode 100644
index 0000000..97ed752
--- /dev/null
+++ b/build/obj/main.d
@@ -0,0 +1,7 @@
+build/obj/main.o: src/main.c src/core/buffer.h src/core/editor.h \
+ src/core/buffer.h src/core/lua_bridge.h src/core/editor.h
+src/core/buffer.h:
+src/core/editor.h:
+src/core/buffer.h:
+src/core/lua_bridge.h:
+src/core/editor.h:
diff --git a/build/obj/main.o b/build/obj/main.o
new file mode 100644
index 0000000..cd63024
--- /dev/null
+++ b/build/obj/main.o
Binary files differ
diff --git a/init.lua b/init.lua
new file mode 100644
index 0000000..9930ba7
--- /dev/null
+++ b/init.lua
@@ -0,0 +1,75 @@
+local open_prompt = {
+ active = false,
+ text = "",
+ buffer = editor.aux_buffer_new(),
+}
+
+local function hide_open_prompt()
+ open_prompt.active = false
+ open_prompt.text = ""
+ editor.aux_buffer_set_text(open_prompt.buffer, "")
+end
+
+local function show_open_prompt()
+ open_prompt.active = true
+ open_prompt.text = ""
+ editor.aux_buffer_set_text(open_prompt.buffer, "open file: ")
+end
+
+local function refresh_open_prompt()
+ editor.aux_buffer_set_text(open_prompt.buffer, "open file: " .. open_prompt.text)
+end
+
+lemacs.keys.bind_leader("f", function()
+ if not open_prompt.active then
+ show_open_prompt()
+ end
+end)
+
+lemacs.hooks.add("intercept_key", function(key)
+ if not open_prompt.active then
+ return false
+ end
+
+ if key == "escape" then
+ hide_open_prompt()
+ return true
+ end
+
+ if key == "backspace" then
+ open_prompt.text = open_prompt.text:sub(1, #open_prompt.text - 1)
+ refresh_open_prompt()
+ return true
+ end
+
+ if key == "enter" then
+ local path = open_prompt.text
+ hide_open_prompt()
+ if path ~= "" then
+ editor.open_file(path)
+ end
+ return true
+ end
+
+ return open_prompt.active
+end)
+
+lemacs.hooks.add("intercept_text", function(text)
+ if not open_prompt.active then
+ return false
+ end
+
+ open_prompt.text = open_prompt.text .. text
+ refresh_open_prompt()
+ return true
+end)
+
+lemacs.hooks.add("after_layout", function()
+ if not open_prompt.active then
+ return
+ end
+
+ local width = editor.screen_width()
+ local y = editor.screen_height() - 24
+ lemacs.ui.add_view(1, open_prompt.buffer, 0, y, width, 24, false, 42, 44, 54, 245, 232, 232, 236, 255)
+end)
diff --git a/runtime/init.lua b/runtime/init.lua
new file mode 100644
index 0000000..5e6bd71
--- /dev/null
+++ b/runtime/init.lua
@@ -0,0 +1,42 @@
+local app = require("core.app")
+
+lemacs = lemacs or {}
+lemacs.hooks = lemacs.hooks or {}
+
+function lemacs.hooks.add(event_name, fn)
+ return app.add_hook(event_name, fn)
+end
+
+lemacs.keys = lemacs.keys or {}
+
+function lemacs.keys.bind(key, target)
+ return app.bind_key(key, target)
+end
+
+function lemacs.keys.bind_leader(key, target)
+ return app.bind_leader(key, target)
+end
+
+lemacs.ui = lemacs.ui or {}
+
+function lemacs.ui.add_view(source_type, source_index, x, y, width, height, show_cursor, bg_r, bg_g, bg_b, bg_a, fg_r, fg_g, fg_b, fg_a)
+ return editor.layout_add_view(source_type, source_index, x, y, width, height, show_cursor, bg_r, bg_g, bg_b, bg_a, fg_r, fg_g, fg_b, fg_a)
+end
+
+app.bootstrap()
+
+function on_open_file(path)
+ return app.on_open_file(path)
+end
+
+function on_key(key)
+ return app.on_key(key)
+end
+
+function on_text(text)
+ return app.on_text(text)
+end
+
+function on_tick()
+ app.on_tick()
+end
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
diff --git a/runtime/lua/themes/base16-default-light.lua b/runtime/lua/themes/base16-default-light.lua
new file mode 100644
index 0000000..8f1bcb8
--- /dev/null
+++ b/runtime/lua/themes/base16-default-light.lua
@@ -0,0 +1,19 @@
+return {
+ name = "base16-default-light",
+ base00 = "#f8f8f8",
+ base01 = "#e8e8e8",
+ base02 = "#d8d8d8",
+ base03 = "#b8b8b8",
+ base04 = "#585858",
+ base05 = "#383838",
+ base06 = "#282828",
+ base07 = "#181818",
+ base08 = "#ab4642",
+ base09 = "#dc9656",
+ base0A = "#f7ca88",
+ base0B = "#a1b56c",
+ base0C = "#86c1b9",
+ base0D = "#7cafc2",
+ base0E = "#ba8baf",
+ base0F = "#a16946",
+}
diff --git a/runtime/lua/themes/base16-gruvbox-dark-hard.lua b/runtime/lua/themes/base16-gruvbox-dark-hard.lua
new file mode 100644
index 0000000..390304d
--- /dev/null
+++ b/runtime/lua/themes/base16-gruvbox-dark-hard.lua
@@ -0,0 +1,19 @@
+return {
+ name = "base16-gruvbox-dark-hard",
+ base00 = "#1d2021",
+ base01 = "#3c3836",
+ base02 = "#504945",
+ base03 = "#665c54",
+ base04 = "#bdae93",
+ base05 = "#d5c4a1",
+ base06 = "#ebdbb2",
+ base07 = "#fbf1c7",
+ base08 = "#fb4934",
+ base09 = "#fe8019",
+ base0A = "#fabd2f",
+ base0B = "#b8bb26",
+ base0C = "#8ec07c",
+ base0D = "#83a598",
+ base0E = "#d3869b",
+ base0F = "#d65d0e",
+}
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..79ba02a
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,13 @@
+{pkgs ? import <nixpkgs> {}}:
+pkgs.mkShell {
+ packages = with pkgs; [
+ gnumake
+ pkg-config
+ gcc
+ raylib
+ glfw
+ lua5_4
+ fontconfig
+ expat
+ ];
+}
diff --git a/src/core/buffer.c b/src/core/buffer.c
new file mode 100644
index 0000000..16a9dfe
--- /dev/null
+++ b/src/core/buffer.c
@@ -0,0 +1,296 @@
+#include "core/buffer.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define INITIAL_CAPACITY 1024
+
+static size_t gap_size(const Buffer *buffer) {
+ return buffer->gap_end - buffer->gap_start;
+}
+
+static size_t physical_index(const Buffer *buffer, size_t logical) {
+ if (logical < buffer->gap_start) {
+ return logical;
+ }
+ return logical + gap_size(buffer);
+}
+
+static bool ensure_gap(Buffer *buffer, size_t needed) {
+ if (gap_size(buffer) >= needed) {
+ return true;
+ }
+
+ size_t old_gap = gap_size(buffer);
+ size_t old_capacity = buffer->capacity;
+ size_t new_capacity = old_capacity;
+ while ((new_capacity - buffer->gap_start - (old_capacity - buffer->gap_end)) < needed) {
+ new_capacity *= 2;
+ }
+
+ char *new_data = (char *)malloc(new_capacity);
+ if (!new_data) {
+ return false;
+ }
+
+ memcpy(new_data, buffer->data, buffer->gap_start);
+ size_t tail_size = old_capacity - buffer->gap_end;
+ size_t new_gap_end = new_capacity - tail_size;
+ memcpy(new_data + new_gap_end, buffer->data + buffer->gap_end, tail_size);
+
+ free(buffer->data);
+ buffer->data = new_data;
+ buffer->capacity = new_capacity;
+ buffer->gap_end = new_gap_end;
+
+ (void)old_gap;
+ return true;
+}
+
+static void move_gap(Buffer *buffer, size_t target) {
+ if (target == buffer->gap_start) {
+ return;
+ }
+
+ if (target < buffer->gap_start) {
+ size_t move = buffer->gap_start - target;
+ memmove(buffer->data + buffer->gap_end - move, buffer->data + target, move);
+ buffer->gap_start = target;
+ buffer->gap_end -= move;
+ return;
+ }
+
+ size_t move = target - buffer->gap_start;
+ memmove(buffer->data + buffer->gap_start, buffer->data + buffer->gap_end, move);
+ buffer->gap_start += move;
+ buffer->gap_end += move;
+}
+
+bool buffer_init(Buffer *buffer) {
+ buffer->data = (char *)malloc(INITIAL_CAPACITY);
+ if (!buffer->data) {
+ return false;
+ }
+ buffer->capacity = INITIAL_CAPACITY;
+ buffer->gap_start = 0;
+ buffer->gap_end = INITIAL_CAPACITY;
+ buffer->path[0] = '\0';
+ buffer->modified = false;
+ return true;
+}
+
+void buffer_free(Buffer *buffer) {
+ free(buffer->data);
+ buffer->data = NULL;
+ buffer->capacity = 0;
+ buffer->gap_start = 0;
+ buffer->gap_end = 0;
+}
+
+size_t buffer_length(const Buffer *buffer) {
+ return buffer->capacity - gap_size(buffer);
+}
+
+size_t buffer_point(const Buffer *buffer) {
+ return buffer->gap_start;
+}
+
+void buffer_set_point(Buffer *buffer, size_t point) {
+ size_t len = buffer_length(buffer);
+ if (point > len) {
+ point = len;
+ }
+ move_gap(buffer, point);
+}
+
+bool buffer_insert_char(Buffer *buffer, char c) {
+ if (!ensure_gap(buffer, 1)) {
+ return false;
+ }
+ buffer->data[buffer->gap_start++] = c;
+ buffer->modified = true;
+ return true;
+}
+
+bool buffer_insert_cstr(Buffer *buffer, const char *text) {
+ size_t len = strlen(text);
+ if (!ensure_gap(buffer, len)) {
+ return false;
+ }
+ memcpy(buffer->data + buffer->gap_start, text, len);
+ buffer->gap_start += len;
+ if (len > 0) {
+ buffer->modified = true;
+ }
+ return true;
+}
+
+bool buffer_backspace(Buffer *buffer) {
+ if (buffer->gap_start == 0) {
+ return false;
+ }
+ buffer->gap_start--;
+ buffer->modified = true;
+ return true;
+}
+
+bool buffer_delete_forward(Buffer *buffer) {
+ if (buffer->gap_end >= buffer->capacity) {
+ return false;
+ }
+ buffer->gap_end++;
+ buffer->modified = true;
+ return true;
+}
+
+char buffer_char_at(const Buffer *buffer, size_t index) {
+ size_t len = buffer_length(buffer);
+ if (index >= len) {
+ return '\0';
+ }
+ return buffer->data[physical_index(buffer, index)];
+}
+
+bool buffer_load_file(Buffer *buffer, const char *path) {
+ FILE *fp = fopen(path, "rb");
+ if (!fp) {
+ return false;
+ }
+
+ if (fseek(fp, 0, SEEK_END) != 0) {
+ fclose(fp);
+ return false;
+ }
+
+ long file_size = ftell(fp);
+ if (file_size < 0) {
+ fclose(fp);
+ return false;
+ }
+ rewind(fp);
+
+ buffer->gap_start = 0;
+ buffer->gap_end = buffer->capacity;
+
+ if (!ensure_gap(buffer, (size_t)file_size + 1)) {
+ fclose(fp);
+ return false;
+ }
+
+ size_t read = fread(buffer->data, 1, (size_t)file_size, fp);
+ fclose(fp);
+ if (read != (size_t)file_size) {
+ return false;
+ }
+
+ buffer->gap_start = (size_t)file_size;
+ buffer->gap_end = buffer->capacity;
+
+ strncpy(buffer->path, path, sizeof(buffer->path) - 1);
+ buffer->path[sizeof(buffer->path) - 1] = '\0';
+ buffer->modified = false;
+ return true;
+}
+
+bool buffer_save_file(Buffer *buffer, const char *path) {
+ const char *out_path = path;
+ if (!out_path || out_path[0] == '\0') {
+ out_path = buffer->path;
+ }
+
+ if (!out_path || out_path[0] == '\0') {
+ return false;
+ }
+
+ FILE *fp = fopen(out_path, "wb");
+ if (!fp) {
+ return false;
+ }
+
+ size_t before = buffer->gap_start;
+ size_t after = buffer->capacity - buffer->gap_end;
+
+ if (before > 0 && fwrite(buffer->data, 1, before, fp) != before) {
+ fclose(fp);
+ return false;
+ }
+
+ if (after > 0 && fwrite(buffer->data + buffer->gap_end, 1, after, fp) != after) {
+ fclose(fp);
+ return false;
+ }
+
+ fclose(fp);
+
+ if (path && path[0] != '\0') {
+ strncpy(buffer->path, path, sizeof(buffer->path) - 1);
+ buffer->path[sizeof(buffer->path) - 1] = '\0';
+ }
+
+ buffer->modified = false;
+
+ return true;
+}
+
+const char *buffer_path(const Buffer *buffer) {
+ return buffer->path;
+}
+
+bool buffer_modified(const Buffer *buffer) {
+ return buffer->modified;
+}
+
+void buffer_index_to_line_col(const Buffer *buffer, size_t index, int *line, int *col) {
+ size_t len = buffer_length(buffer);
+ if (index > len) {
+ index = len;
+ }
+
+ int l = 0;
+ int c = 0;
+ for (size_t i = 0; i < index; ++i) {
+ char ch = buffer_char_at(buffer, i);
+ if (ch == '\n') {
+ l++;
+ c = 0;
+ } else {
+ c++;
+ }
+ }
+
+ *line = l;
+ *col = c;
+}
+
+size_t buffer_line_col_to_index(const Buffer *buffer, int target_line, int target_col) {
+ if (target_line < 0) {
+ target_line = 0;
+ }
+ if (target_col < 0) {
+ target_col = 0;
+ }
+
+ size_t len = buffer_length(buffer);
+ int line = 0;
+ int col = 0;
+
+ for (size_t i = 0; i < len; ++i) {
+ if (line == target_line && col == target_col) {
+ return i;
+ }
+
+ char ch = buffer_char_at(buffer, i);
+ if (ch == '\n') {
+ if (line == target_line) {
+ return i;
+ }
+ line++;
+ col = 0;
+ } else {
+ col++;
+ }
+ }
+
+ return len;
+}
diff --git a/src/core/buffer.h b/src/core/buffer.h
new file mode 100644
index 0000000..8fac8ab
--- /dev/null
+++ b/src/core/buffer.h
@@ -0,0 +1,38 @@
+#ifndef LEMACS_BUFFER_H
+#define LEMACS_BUFFER_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+typedef struct {
+ char *data;
+ size_t capacity;
+ size_t gap_start;
+ size_t gap_end;
+ char path[1024];
+ bool modified;
+} Buffer;
+
+bool buffer_init(Buffer *buffer);
+void buffer_free(Buffer *buffer);
+
+size_t buffer_length(const Buffer *buffer);
+size_t buffer_point(const Buffer *buffer);
+void buffer_set_point(Buffer *buffer, size_t point);
+
+bool buffer_insert_char(Buffer *buffer, char c);
+bool buffer_insert_cstr(Buffer *buffer, const char *text);
+bool buffer_backspace(Buffer *buffer);
+bool buffer_delete_forward(Buffer *buffer);
+
+char buffer_char_at(const Buffer *buffer, size_t index);
+
+bool buffer_load_file(Buffer *buffer, const char *path);
+bool buffer_save_file(Buffer *buffer, const char *path);
+const char *buffer_path(const Buffer *buffer);
+bool buffer_modified(const Buffer *buffer);
+
+void buffer_index_to_line_col(const Buffer *buffer, size_t index, int *line, int *col);
+size_t buffer_line_col_to_index(const Buffer *buffer, int target_line, int target_col);
+
+#endif
diff --git a/src/core/editor.c b/src/core/editor.c
new file mode 100644
index 0000000..43c28c7
--- /dev/null
+++ b/src/core/editor.c
@@ -0,0 +1,466 @@
+#include "core/editor.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+
+static EditorColor make_color(int r, int g, int b, int a) {
+ if (r < 0) r = 0;
+ if (g < 0) g = 0;
+ if (b < 0) b = 0;
+ if (a < 0) a = 0;
+ if (r > 255) r = 255;
+ if (g > 255) g = 255;
+ if (b > 255) b = 255;
+ if (a > 255) a = 255;
+
+ EditorColor c;
+ c.r = (uint8_t)r;
+ c.g = (uint8_t)g;
+ c.b = (uint8_t)b;
+ c.a = (uint8_t)a;
+ return c;
+}
+
+static Buffer *active_buffer(Editor *editor) {
+ if (editor->current_buffer < 0 || editor->current_buffer >= editor->buffer_count) {
+ return NULL;
+ }
+ return &editor->buffers[editor->current_buffer];
+}
+
+static const Buffer *active_buffer_const(const Editor *editor) {
+ if (editor->current_buffer < 0 || editor->current_buffer >= editor->buffer_count) {
+ return NULL;
+ }
+ return &editor->buffers[editor->current_buffer];
+}
+
+static void clamp_point(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ size_t len = buffer_length(buffer);
+ if (point > len) {
+ buffer_set_point(buffer, len);
+ }
+}
+
+static bool valid_aux_index(const Editor *editor, int aux_index) {
+ return aux_index >= 0 && aux_index < editor->aux_buffer_count;
+}
+
+static bool valid_buffer_index(const Editor *editor, int index) {
+ return index >= 0 && index < editor->buffer_count;
+}
+
+bool editor_init(Editor *editor) {
+ editor->buffer_count = 0;
+ editor->current_buffer = 0;
+
+ if (!buffer_init(&editor->buffers[0])) {
+ return false;
+ }
+
+ editor->buffer_count = 1;
+ editor->aux_buffer_count = 0;
+ editor->desired_col = -1;
+ editor->scroll_line = 0;
+ snprintf(editor->font_query, sizeof(editor->font_query), "%s", "monospace:size=14");
+ editor->font_dirty = true;
+ editor->bg_top = make_color(248, 248, 248, 255);
+ editor->bg_bottom = make_color(238, 238, 238, 255);
+ editor->text = make_color(56, 60, 67, 255);
+ editor->cursor = make_color(176, 65, 11, 255);
+ editor->cursor_shape = LEMACS_CURSOR_BAR;
+ editor->cursor_thickness = 2;
+ editor->view_count = 0;
+ editor->should_quit = false;
+ return true;
+}
+
+void editor_free(Editor *editor) {
+ for (int i = 0; i < editor->buffer_count; ++i) {
+ buffer_free(&editor->buffers[i]);
+ }
+
+ for (int i = 0; i < editor->aux_buffer_count; ++i) {
+ buffer_free(&editor->aux_buffers[i]);
+ }
+
+ editor->buffer_count = 0;
+ editor->current_buffer = 0;
+ editor->aux_buffer_count = 0;
+ editor->view_count = 0;
+}
+
+void editor_set_font_query(Editor *editor, const char *query) {
+ snprintf(editor->font_query, sizeof(editor->font_query), "%s", (query && query[0] != '\0') ? query : "monospace:size=14");
+ editor->font_dirty = true;
+}
+
+const char *editor_font_query(const Editor *editor) {
+ return editor->font_query;
+}
+
+bool editor_consume_font_dirty(Editor *editor) {
+ bool dirty = editor->font_dirty;
+ editor->font_dirty = false;
+ return dirty;
+}
+
+bool editor_set_theme_color(Editor *editor, const char *name, int r, int g, int b, int a) {
+ EditorColor c = make_color(r, g, b, a);
+
+ if (strcmp(name, "bg_top") == 0) {
+ editor->bg_top = c;
+ return true;
+ }
+ if (strcmp(name, "bg_bottom") == 0) {
+ editor->bg_bottom = c;
+ return true;
+ }
+ if (strcmp(name, "text") == 0) {
+ editor->text = c;
+ return true;
+ }
+ if (strcmp(name, "cursor") == 0) {
+ editor->cursor = c;
+ return true;
+ }
+ if (strcmp(name, "accent") == 0) {
+ editor->cursor = c;
+ return true;
+ }
+
+ return false;
+}
+
+bool editor_set_cursor_shape(Editor *editor, int shape) {
+ if (shape < LEMACS_CURSOR_BAR || shape > LEMACS_CURSOR_UNDERLINE) {
+ return false;
+ }
+ editor->cursor_shape = shape;
+ return true;
+}
+
+void editor_set_cursor_thickness(Editor *editor, int thickness) {
+ if (thickness < 1) {
+ thickness = 1;
+ }
+ if (thickness > 24) {
+ thickness = 24;
+ }
+ editor->cursor_thickness = thickness;
+}
+
+int editor_cursor_shape(const Editor *editor) {
+ return editor->cursor_shape;
+}
+
+int editor_cursor_thickness(const Editor *editor) {
+ return editor->cursor_thickness;
+}
+
+int editor_aux_buffer_new(Editor *editor) {
+ if (editor->aux_buffer_count >= LEMACS_MAX_AUX_BUFFERS) {
+ return -1;
+ }
+
+ int index = editor->aux_buffer_count;
+ if (!buffer_init(&editor->aux_buffers[index])) {
+ return -1;
+ }
+
+ editor->aux_buffer_count++;
+ return index;
+}
+
+bool editor_aux_buffer_set_text(Editor *editor, int aux_index, const char *text) {
+ if (!valid_aux_index(editor, aux_index)) {
+ return false;
+ }
+
+ Buffer *buffer = &editor->aux_buffers[aux_index];
+ buffer->gap_start = 0;
+ buffer->gap_end = buffer->capacity;
+ buffer->path[0] = '\0';
+ buffer->modified = false;
+
+ if (!text || text[0] == '\0') {
+ return true;
+ }
+
+ return buffer_insert_cstr(buffer, text);
+}
+
+Buffer *editor_aux_buffer_get(Editor *editor, int aux_index) {
+ if (!valid_aux_index(editor, aux_index)) {
+ return NULL;
+ }
+ return &editor->aux_buffers[aux_index];
+}
+
+void editor_layout_reset(Editor *editor) {
+ editor->view_count = 0;
+}
+
+bool editor_layout_add_view(Editor *editor, int source_type, int source_index, int x, int y, int width, int height, bool show_cursor, EditorColor bg, EditorColor fg) {
+ if (editor->view_count >= LEMACS_MAX_VIEWS) {
+ return false;
+ }
+
+ if (width <= 0 || height <= 0) {
+ return false;
+ }
+
+ if (source_type == 1 && !valid_aux_index(editor, source_index)) {
+ return false;
+ }
+
+ if (source_type != 0 && source_type != 1) {
+ return false;
+ }
+
+ EditorView *view = &editor->views[editor->view_count++];
+ view->x = x;
+ view->y = y;
+ view->width = width;
+ view->height = height;
+ view->source_type = source_type;
+ view->source_index = source_index;
+ view->show_cursor = show_cursor;
+ view->bg = bg;
+ view->fg = fg;
+ return true;
+}
+
+void editor_move_left(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ if (point > 0) {
+ buffer_set_point(buffer, point - 1);
+ }
+ editor->desired_col = -1;
+}
+
+void editor_move_right(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ size_t len = buffer_length(buffer);
+ if (point < len) {
+ buffer_set_point(buffer, point + 1);
+ }
+ editor->desired_col = -1;
+}
+
+void editor_move_up(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ int line = 0;
+ int col = 0;
+ buffer_index_to_line_col(buffer, point, &line, &col);
+ if (editor->desired_col < 0) {
+ editor->desired_col = col;
+ }
+ if (line > 0) {
+ size_t new_point = buffer_line_col_to_index(buffer, line - 1, editor->desired_col);
+ buffer_set_point(buffer, new_point);
+ }
+ clamp_point(editor);
+}
+
+void editor_move_down(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ int line = 0;
+ int col = 0;
+ buffer_index_to_line_col(buffer, point, &line, &col);
+ if (editor->desired_col < 0) {
+ editor->desired_col = col;
+ }
+
+ size_t len = buffer_length(buffer);
+ int last_line = 0;
+ int last_col = 0;
+ buffer_index_to_line_col(buffer, len, &last_line, &last_col);
+ if (line < last_line) {
+ size_t new_point = buffer_line_col_to_index(buffer, line + 1, editor->desired_col);
+ buffer_set_point(buffer, new_point);
+ }
+ clamp_point(editor);
+}
+
+void editor_move_beginning_of_line(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ while (point > 0) {
+ char ch = buffer_char_at(buffer, point - 1);
+ if (ch == '\n') {
+ break;
+ }
+ point--;
+ }
+ buffer_set_point(buffer, point);
+ editor->desired_col = -1;
+}
+
+void editor_move_end_of_line(Editor *editor) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ size_t point = buffer_point(buffer);
+ size_t len = buffer_length(buffer);
+ while (point < len) {
+ char ch = buffer_char_at(buffer, point);
+ if (ch == '\n') {
+ break;
+ }
+ point++;
+ }
+ buffer_set_point(buffer, point);
+ editor->desired_col = -1;
+}
+
+void editor_set_point(Editor *editor, size_t point) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ buffer_set_point(buffer, point);
+ editor->desired_col = -1;
+}
+
+char editor_char_at(const Editor *editor, size_t index) {
+ const Buffer *buffer = active_buffer_const(editor);
+ if (!buffer) {
+ return '\0';
+ }
+ return buffer_char_at(buffer, index);
+}
+
+bool editor_open_file(Editor *editor, const char *path) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return false;
+ }
+
+ if (!path || path[0] == '\0') {
+ return false;
+ }
+
+ if (!buffer_load_file(buffer, path)) {
+ if (errno != ENOENT) {
+ return false;
+ }
+
+ buffer->gap_start = 0;
+ buffer->gap_end = buffer->capacity;
+ buffer->modified = false;
+ strncpy(buffer->path, path, sizeof(buffer->path) - 1);
+ buffer->path[sizeof(buffer->path) - 1] = '\0';
+ }
+
+ buffer_set_point(buffer, 0);
+ editor->desired_col = -1;
+ editor->scroll_line = 0;
+
+ return true;
+}
+
+bool editor_save_file(Editor *editor, const char *path) {
+ Buffer *buffer = active_buffer(editor);
+ if (!buffer) {
+ return false;
+ }
+ return buffer_save_file(buffer, path);
+}
+
+Buffer *editor_current_buffer(Editor *editor) {
+ return active_buffer(editor);
+}
+
+const Buffer *editor_current_buffer_const(const Editor *editor) {
+ return active_buffer_const(editor);
+}
+
+int editor_buffer_count(const Editor *editor) {
+ return editor->buffer_count;
+}
+
+int editor_current_buffer_index(const Editor *editor) {
+ return editor->current_buffer;
+}
+
+bool editor_switch_buffer(Editor *editor, int index) {
+ if (!valid_buffer_index(editor, index)) {
+ return false;
+ }
+
+ editor->current_buffer = index;
+ editor->desired_col = -1;
+ editor->scroll_line = 0;
+ return true;
+}
+
+int editor_new_buffer(Editor *editor) {
+ if (editor->buffer_count >= LEMACS_MAX_BUFFERS) {
+ return -1;
+ }
+
+ int index = editor->buffer_count;
+ if (!buffer_init(&editor->buffers[index])) {
+ return -1;
+ }
+
+ editor->buffer_count++;
+ editor->current_buffer = index;
+ editor->desired_col = -1;
+ editor->scroll_line = 0;
+ return index;
+}
+
+const char *editor_buffer_path_at(const Editor *editor, int index) {
+ if (!valid_buffer_index(editor, index)) {
+ return NULL;
+ }
+
+ const char *path = buffer_path(&editor->buffers[index]);
+ if (!path || path[0] == '\0') {
+ return NULL;
+ }
+ return path;
+}
+
+bool editor_buffer_modified_at(const Editor *editor, int index) {
+ if (!valid_buffer_index(editor, index)) {
+ return false;
+ }
+ return buffer_modified(&editor->buffers[index]);
+}
diff --git a/src/core/editor.h b/src/core/editor.h
new file mode 100644
index 0000000..5388197
--- /dev/null
+++ b/src/core/editor.h
@@ -0,0 +1,96 @@
+#ifndef LEMACS_EDITOR_H
+#define LEMACS_EDITOR_H
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include "core/buffer.h"
+
+#define LEMACS_MAX_AUX_BUFFERS 8
+#define LEMACS_MAX_VIEWS 16
+#define LEMACS_MAX_BUFFERS 32
+
+#define LEMACS_CURSOR_BAR 0
+#define LEMACS_CURSOR_BLOCK 1
+#define LEMACS_CURSOR_UNDERLINE 2
+
+typedef struct {
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+ uint8_t a;
+} EditorColor;
+
+typedef struct {
+ int x;
+ int y;
+ int width;
+ int height;
+ int source_type;
+ int source_index;
+ bool show_cursor;
+ EditorColor bg;
+ EditorColor fg;
+} EditorView;
+
+typedef struct {
+ Buffer buffers[LEMACS_MAX_BUFFERS];
+ int buffer_count;
+ int current_buffer;
+ Buffer aux_buffers[LEMACS_MAX_AUX_BUFFERS];
+ int aux_buffer_count;
+ int desired_col;
+ int scroll_line;
+ char font_query[256];
+ bool font_dirty;
+ EditorView views[LEMACS_MAX_VIEWS];
+ int view_count;
+ EditorColor bg_top;
+ EditorColor bg_bottom;
+ EditorColor text;
+ EditorColor cursor;
+ int cursor_shape;
+ int cursor_thickness;
+ bool should_quit;
+} Editor;
+
+bool editor_init(Editor *editor);
+void editor_free(Editor *editor);
+
+void editor_set_font_query(Editor *editor, const char *query);
+const char *editor_font_query(const Editor *editor);
+bool editor_consume_font_dirty(Editor *editor);
+bool editor_set_theme_color(Editor *editor, const char *name, int r, int g, int b, int a);
+bool editor_set_cursor_shape(Editor *editor, int shape);
+void editor_set_cursor_thickness(Editor *editor, int thickness);
+int editor_cursor_shape(const Editor *editor);
+int editor_cursor_thickness(const Editor *editor);
+
+int editor_aux_buffer_new(Editor *editor);
+bool editor_aux_buffer_set_text(Editor *editor, int aux_index, const char *text);
+Buffer *editor_aux_buffer_get(Editor *editor, int aux_index);
+
+void editor_layout_reset(Editor *editor);
+bool editor_layout_add_view(Editor *editor, int source_type, int source_index, int x, int y, int width, int height, bool show_cursor, EditorColor bg, EditorColor fg);
+
+void editor_move_left(Editor *editor);
+void editor_move_right(Editor *editor);
+void editor_move_up(Editor *editor);
+void editor_move_down(Editor *editor);
+void editor_move_beginning_of_line(Editor *editor);
+void editor_move_end_of_line(Editor *editor);
+void editor_set_point(Editor *editor, size_t point);
+char editor_char_at(const Editor *editor, size_t index);
+
+bool editor_open_file(Editor *editor, const char *path);
+bool editor_save_file(Editor *editor, const char *path);
+Buffer *editor_current_buffer(Editor *editor);
+const Buffer *editor_current_buffer_const(const Editor *editor);
+int editor_buffer_count(const Editor *editor);
+int editor_current_buffer_index(const Editor *editor);
+bool editor_switch_buffer(Editor *editor, int index);
+int editor_new_buffer(Editor *editor);
+const char *editor_buffer_path_at(const Editor *editor, int index);
+bool editor_buffer_modified_at(const Editor *editor, int index);
+
+#endif
diff --git a/src/core/lua_bridge.c b/src/core/lua_bridge.c
new file mode 100644
index 0000000..f1bf16c
--- /dev/null
+++ b/src/core/lua_bridge.c
@@ -0,0 +1,611 @@
+#include "core/lua_bridge.h"
+
+#include <lauxlib.h>
+#include <lualib.h>
+
+#include <raylib.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static const char *EDITOR_REG_KEY = "lemacs.editor";
+
+static Editor *get_editor(lua_State *L) {
+ lua_getfield(L, LUA_REGISTRYINDEX, EDITOR_REG_KEY);
+ Editor *editor = (Editor *)lua_touserdata(L, -1);
+ lua_pop(L, 1);
+ return editor;
+}
+
+static int l_insert(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ if (!buffer) {
+ lua_pushboolean(L, 0);
+ return 1;
+ }
+
+ size_t len = 0;
+ const char *text = luaL_checklstring(L, 1, &len);
+ for (size_t i = 0; i < len; ++i) {
+ if (!buffer_insert_char(buffer, text[i])) {
+ lua_pushboolean(L, 0);
+ return 1;
+ }
+ }
+ lua_pushboolean(L, 1);
+ return 1;
+}
+
+static int l_backspace(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ lua_pushboolean(L, buffer ? buffer_backspace(buffer) : false);
+ return 1;
+}
+
+static int l_delete_forward(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ lua_pushboolean(L, buffer ? buffer_delete_forward(buffer) : false);
+ return 1;
+}
+
+static int l_move_left(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_left(editor);
+ return 0;
+}
+
+static int l_move_right(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_right(editor);
+ return 0;
+}
+
+static int l_move_up(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_up(editor);
+ return 0;
+}
+
+static int l_move_down(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_down(editor);
+ return 0;
+}
+
+static int l_move_beginning_of_line(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_beginning_of_line(editor);
+ return 0;
+}
+
+static int l_move_end_of_line(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_move_end_of_line(editor);
+ return 0;
+}
+
+static int l_open_file(lua_State *L) {
+ Editor *editor = get_editor(L);
+ const char *path = luaL_checkstring(L, 1);
+ lua_pushboolean(L, editor_open_file(editor, path));
+ return 1;
+}
+
+static int l_save_file(lua_State *L) {
+ Editor *editor = get_editor(L);
+ const char *path = NULL;
+ if (!lua_isnoneornil(L, 1)) {
+ path = luaL_checkstring(L, 1);
+ }
+ lua_pushboolean(L, editor_save_file(editor, path));
+ return 1;
+}
+
+static int l_get_point(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ lua_pushinteger(L, (lua_Integer)(buffer ? buffer_point(buffer) : 0));
+ return 1;
+}
+
+static int l_set_point(lua_State *L) {
+ Editor *editor = get_editor(L);
+ lua_Integer point = luaL_checkinteger(L, 1);
+ if (point < 0) {
+ point = 0;
+ }
+ editor_set_point(editor, (size_t)point);
+ return 0;
+}
+
+static int l_char_at(lua_State *L) {
+ Editor *editor = get_editor(L);
+ lua_Integer idx = luaL_checkinteger(L, 1);
+ if (idx < 0) {
+ lua_pushstring(L, "");
+ return 1;
+ }
+
+ char ch = editor_char_at(editor, (size_t)idx);
+ if (ch == '\0') {
+ lua_pushstring(L, "");
+ return 1;
+ }
+
+ char text[2] = {ch, '\0'};
+ lua_pushstring(L, text);
+ return 1;
+}
+
+static int l_set_font(lua_State *L) {
+ Editor *editor = get_editor(L);
+ const char *query = luaL_checkstring(L, 1);
+ editor_set_font_query(editor, query);
+ return 0;
+}
+
+static int l_set_theme_color(lua_State *L) {
+ Editor *editor = get_editor(L);
+ const char *name = luaL_checkstring(L, 1);
+ int r = (int)luaL_checkinteger(L, 2);
+ int g = (int)luaL_checkinteger(L, 3);
+ int b = (int)luaL_checkinteger(L, 4);
+ int a = 255;
+ if (!lua_isnoneornil(L, 5)) {
+ a = (int)luaL_checkinteger(L, 5);
+ }
+
+ lua_pushboolean(L, editor_set_theme_color(editor, name, r, g, b, a));
+ return 1;
+}
+
+static int l_set_cursor_shape(lua_State *L) {
+ Editor *editor = get_editor(L);
+ const char *shape = luaL_checkstring(L, 1);
+
+ int cursor_shape = -1;
+ if (strcmp(shape, "bar") == 0) {
+ cursor_shape = LEMACS_CURSOR_BAR;
+ } else if (strcmp(shape, "block") == 0) {
+ cursor_shape = LEMACS_CURSOR_BLOCK;
+ } else if (strcmp(shape, "underline") == 0) {
+ cursor_shape = LEMACS_CURSOR_UNDERLINE;
+ } else {
+ lua_pushboolean(L, 0);
+ return 1;
+ }
+
+ lua_pushboolean(L, editor_set_cursor_shape(editor, cursor_shape));
+ return 1;
+}
+
+static int l_set_cursor_thickness(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int thickness = (int)luaL_checkinteger(L, 1);
+ editor_set_cursor_thickness(editor, thickness);
+ return 0;
+}
+
+static int l_aux_buffer_new(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int index = editor_aux_buffer_new(editor);
+ if (index < 0) {
+ lua_pushnil(L);
+ return 1;
+ }
+ lua_pushinteger(L, index);
+ return 1;
+}
+
+static int l_aux_buffer_set_text(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int aux_index = (int)luaL_checkinteger(L, 1);
+ const char *text = luaL_checkstring(L, 2);
+ lua_pushboolean(L, editor_aux_buffer_set_text(editor, aux_index, text));
+ return 1;
+}
+
+static int l_layout_reset(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor_layout_reset(editor);
+ return 0;
+}
+
+static int l_layout_add_view(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int source_type = (int)luaL_checkinteger(L, 1);
+ int source_index = (int)luaL_checkinteger(L, 2);
+ int x = (int)luaL_checkinteger(L, 3);
+ int y = (int)luaL_checkinteger(L, 4);
+ int width = (int)luaL_checkinteger(L, 5);
+ int height = (int)luaL_checkinteger(L, 6);
+ bool show_cursor = lua_toboolean(L, 7) != 0;
+ int bg_r = (int)luaL_checkinteger(L, 8);
+ int bg_g = (int)luaL_checkinteger(L, 9);
+ int bg_b = (int)luaL_checkinteger(L, 10);
+ int bg_a = (int)luaL_optinteger(L, 11, 255);
+ int fg_r = (int)luaL_checkinteger(L, 12);
+ int fg_g = (int)luaL_checkinteger(L, 13);
+ int fg_b = (int)luaL_checkinteger(L, 14);
+ int fg_a = (int)luaL_optinteger(L, 15, 255);
+
+ EditorColor bg = {(uint8_t)bg_r, (uint8_t)bg_g, (uint8_t)bg_b, (uint8_t)bg_a};
+ EditorColor fg = {(uint8_t)fg_r, (uint8_t)fg_g, (uint8_t)fg_b, (uint8_t)fg_a};
+
+ lua_pushboolean(L, editor_layout_add_view(editor, source_type, source_index, x, y, width, height, show_cursor, bg, fg));
+ return 1;
+}
+
+static int l_screen_width(lua_State *L) {
+ lua_pushinteger(L, GetScreenWidth());
+ return 1;
+}
+
+static int l_screen_height(lua_State *L) {
+ lua_pushinteger(L, GetScreenHeight());
+ return 1;
+}
+
+static int l_buffer_length(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ lua_pushinteger(L, (lua_Integer)(buffer ? buffer_length(buffer) : 0));
+ return 1;
+}
+
+static int l_request_quit(lua_State *L) {
+ Editor *editor = get_editor(L);
+ editor->should_quit = true;
+ return 0;
+}
+
+static int l_buffer_path(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ const char *path = buffer ? buffer_path(buffer) : NULL;
+ if (path && path[0] != '\0') {
+ lua_pushstring(L, path);
+ } else {
+ lua_pushnil(L);
+ }
+ return 1;
+}
+
+static int l_buffer_modified(lua_State *L) {
+ Editor *editor = get_editor(L);
+ Buffer *buffer = editor_current_buffer(editor);
+ lua_pushboolean(L, buffer ? buffer_modified(buffer) : false);
+ return 1;
+}
+
+static int l_buffer_count(lua_State *L) {
+ Editor *editor = get_editor(L);
+ lua_pushinteger(L, (lua_Integer)editor_buffer_count(editor));
+ return 1;
+}
+
+static int l_current_buffer_index(lua_State *L) {
+ Editor *editor = get_editor(L);
+ lua_pushinteger(L, (lua_Integer)editor_current_buffer_index(editor));
+ return 1;
+}
+
+static int l_switch_buffer(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int index = (int)luaL_checkinteger(L, 1);
+ lua_pushboolean(L, editor_switch_buffer(editor, index));
+ return 1;
+}
+
+static int l_new_buffer(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int index = editor_new_buffer(editor);
+ if (index < 0) {
+ lua_pushnil(L);
+ } else {
+ lua_pushinteger(L, index);
+ }
+ return 1;
+}
+
+static int l_buffer_path_at(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int index = (int)luaL_checkinteger(L, 1);
+ const char *path = editor_buffer_path_at(editor, index);
+ if (!path) {
+ lua_pushnil(L);
+ } else {
+ lua_pushstring(L, path);
+ }
+ return 1;
+}
+
+static int l_buffer_modified_at(lua_State *L) {
+ Editor *editor = get_editor(L);
+ int index = (int)luaL_checkinteger(L, 1);
+ lua_pushboolean(L, editor_buffer_modified_at(editor, index));
+ return 1;
+}
+
+static void register_editor_api(lua_State *L) {
+ lua_newtable(L);
+
+ lua_pushcfunction(L, l_insert);
+ lua_setfield(L, -2, "insert");
+
+ lua_pushcfunction(L, l_backspace);
+ lua_setfield(L, -2, "backspace");
+
+ lua_pushcfunction(L, l_delete_forward);
+ lua_setfield(L, -2, "delete_forward");
+
+ lua_pushcfunction(L, l_move_left);
+ lua_setfield(L, -2, "move_left");
+
+ lua_pushcfunction(L, l_move_right);
+ lua_setfield(L, -2, "move_right");
+
+ lua_pushcfunction(L, l_move_up);
+ lua_setfield(L, -2, "move_up");
+
+ lua_pushcfunction(L, l_move_down);
+ lua_setfield(L, -2, "move_down");
+
+ lua_pushcfunction(L, l_move_beginning_of_line);
+ lua_setfield(L, -2, "move_beginning_of_line");
+
+ lua_pushcfunction(L, l_move_end_of_line);
+ lua_setfield(L, -2, "move_end_of_line");
+
+ lua_pushcfunction(L, l_open_file);
+ lua_setfield(L, -2, "open_file");
+
+ lua_pushcfunction(L, l_save_file);
+ lua_setfield(L, -2, "save_file");
+
+ lua_pushcfunction(L, l_aux_buffer_new);
+ lua_setfield(L, -2, "aux_buffer_new");
+
+ lua_pushcfunction(L, l_aux_buffer_set_text);
+ lua_setfield(L, -2, "aux_buffer_set_text");
+
+ lua_pushcfunction(L, l_layout_reset);
+ lua_setfield(L, -2, "layout_reset");
+
+ lua_pushcfunction(L, l_layout_add_view);
+ lua_setfield(L, -2, "layout_add_view");
+
+ lua_pushcfunction(L, l_screen_width);
+ lua_setfield(L, -2, "screen_width");
+
+ lua_pushcfunction(L, l_screen_height);
+ lua_setfield(L, -2, "screen_height");
+
+ lua_pushcfunction(L, l_get_point);
+ lua_setfield(L, -2, "point");
+
+ lua_pushcfunction(L, l_set_point);
+ lua_setfield(L, -2, "set_point");
+
+ lua_pushcfunction(L, l_char_at);
+ lua_setfield(L, -2, "char_at");
+
+ lua_pushcfunction(L, l_set_font);
+ lua_setfield(L, -2, "set_font");
+
+ lua_pushcfunction(L, l_set_theme_color);
+ lua_setfield(L, -2, "set_theme_color");
+
+ lua_pushcfunction(L, l_set_cursor_shape);
+ lua_setfield(L, -2, "set_cursor_shape");
+
+ lua_pushcfunction(L, l_set_cursor_thickness);
+ lua_setfield(L, -2, "set_cursor_thickness");
+
+ lua_pushcfunction(L, l_buffer_length);
+ lua_setfield(L, -2, "buffer_length");
+
+ lua_pushcfunction(L, l_buffer_path);
+ lua_setfield(L, -2, "buffer_path");
+
+ lua_pushcfunction(L, l_buffer_modified);
+ lua_setfield(L, -2, "buffer_modified");
+
+ lua_pushcfunction(L, l_buffer_count);
+ lua_setfield(L, -2, "buffer_count");
+
+ lua_pushcfunction(L, l_current_buffer_index);
+ lua_setfield(L, -2, "current_buffer_index");
+
+ lua_pushcfunction(L, l_switch_buffer);
+ lua_setfield(L, -2, "switch_buffer");
+
+ lua_pushcfunction(L, l_new_buffer);
+ lua_setfield(L, -2, "new_buffer");
+
+ lua_pushcfunction(L, l_buffer_path_at);
+ lua_setfield(L, -2, "buffer_path_at");
+
+ lua_pushcfunction(L, l_buffer_modified_at);
+ lua_setfield(L, -2, "buffer_modified_at");
+
+ lua_pushcfunction(L, l_request_quit);
+ lua_setfield(L, -2, "request_quit");
+
+ lua_setglobal(L, "editor");
+}
+
+static bool call_global_with_string(lua_State *L, const char *name, const char *arg) {
+ lua_getglobal(L, name);
+ if (!lua_isfunction(L, -1)) {
+ lua_pop(L, 1);
+ return false;
+ }
+
+ lua_pushstring(L, arg);
+ if (lua_pcall(L, 1, 1, 0) != LUA_OK) {
+ fprintf(stderr, "lua error in %s: %s\n", name, lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return false;
+ }
+
+ bool handled = lua_toboolean(L, -1) != 0;
+ lua_pop(L, 1);
+ return handled;
+}
+
+static bool call_global_noargs(lua_State *L, const char *name) {
+ lua_getglobal(L, name);
+ if (!lua_isfunction(L, -1)) {
+ lua_pop(L, 1);
+ return false;
+ }
+
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK) {
+ fprintf(stderr, "lua error in %s: %s\n", name, lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return false;
+ }
+
+ return true;
+}
+
+static void configure_lua_package_path(lua_State *L, const char *init_script_path) {
+ char runtime_lua_path[2048] = {0};
+ snprintf(runtime_lua_path, sizeof(runtime_lua_path), "%s", init_script_path ? init_script_path : "runtime/init.lua");
+
+ char *last_slash = strrchr(runtime_lua_path, '/');
+ if (!last_slash) {
+ return;
+ }
+ *last_slash = '\0';
+
+ char add_path[2300] = {0};
+ snprintf(add_path, sizeof(add_path), "%s/lua/?.lua;%s/lua/?/init.lua", runtime_lua_path, runtime_lua_path);
+
+ lua_getglobal(L, "package");
+ if (!lua_istable(L, -1)) {
+ lua_pop(L, 1);
+ return;
+ }
+
+ lua_getfield(L, -1, "path");
+ const char *current = lua_tostring(L, -1);
+ if (!current) {
+ current = "";
+ }
+ lua_pop(L, 1);
+
+ char merged[4096] = {0};
+ if (current[0] != '\0') {
+ snprintf(merged, sizeof(merged), "%s;%s", add_path, current);
+ } else {
+ snprintf(merged, sizeof(merged), "%s", add_path);
+ }
+
+ lua_pushstring(L, merged);
+ lua_setfield(L, -2, "path");
+ lua_pop(L, 1);
+}
+
+bool lua_bridge_init(LuaBridge *bridge, Editor *editor) {
+ bridge->editor = editor;
+ bridge->runtime_init_path[0] = '\0';
+ bridge->L = luaL_newstate();
+ if (!bridge->L) {
+ return false;
+ }
+
+ luaL_openlibs(bridge->L);
+
+ lua_pushlightuserdata(bridge->L, editor);
+ lua_setfield(bridge->L, LUA_REGISTRYINDEX, EDITOR_REG_KEY);
+
+ register_editor_api(bridge->L);
+ return true;
+}
+
+void lua_bridge_free(LuaBridge *bridge) {
+ if (bridge->L) {
+ lua_close(bridge->L);
+ bridge->L = NULL;
+ }
+}
+
+bool lua_bridge_load_runtime(LuaBridge *bridge, const char *init_script_path) {
+ configure_lua_package_path(bridge->L, init_script_path);
+ snprintf(bridge->runtime_init_path, sizeof(bridge->runtime_init_path), "%s", init_script_path ? init_script_path : "");
+
+ if (luaL_dofile(bridge->L, init_script_path) != LUA_OK) {
+ const char *err = lua_tostring(bridge->L, -1);
+ fprintf(stderr, "failed to load %s: %s\n", init_script_path, err ? err : "unknown");
+ lua_pop(bridge->L, 1);
+ return false;
+ }
+ return true;
+}
+
+bool lua_bridge_load_optional_init(LuaBridge *bridge, const char *script_path) {
+ if (!script_path || script_path[0] == '\0') {
+ return false;
+ }
+
+ lua_getglobal(bridge->L, "dofile");
+ if (!lua_isfunction(bridge->L, -1)) {
+ lua_pop(bridge->L, 1);
+ return false;
+ }
+
+ lua_pushstring(bridge->L, script_path);
+ if (lua_pcall(bridge->L, 1, 0, 0) != LUA_OK) {
+ fprintf(stderr, "optional init load error (%s): %s\n", script_path, lua_tostring(bridge->L, -1));
+ lua_pop(bridge->L, 1);
+ return false;
+ }
+
+ return true;
+}
+
+static bool call_global_with_optional_string(lua_State *L, const char *name, const char *arg) {
+ lua_getglobal(L, name);
+ if (!lua_isfunction(L, -1)) {
+ lua_pop(L, 1);
+ return false;
+ }
+
+ if (arg && arg[0] != '\0') {
+ lua_pushstring(L, arg);
+ } else {
+ lua_pushnil(L);
+ }
+
+ if (lua_pcall(L, 1, 1, 0) != LUA_OK) {
+ fprintf(stderr, "lua error in %s: %s\n", name, lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return false;
+ }
+
+ bool ok = lua_toboolean(L, -1) != 0;
+ lua_pop(L, 1);
+ return ok;
+}
+
+bool lua_bridge_call_open_file(LuaBridge *bridge, const char *path) {
+ return call_global_with_optional_string(bridge->L, "on_open_file", path);
+}
+
+bool lua_bridge_call_text(LuaBridge *bridge, const char *text) {
+ return call_global_with_string(bridge->L, "on_text", text ? text : "");
+}
+
+bool lua_bridge_call_key(LuaBridge *bridge, const char *key_name) {
+ return call_global_with_string(bridge->L, "on_key", key_name);
+}
+
+bool lua_bridge_call_tick(LuaBridge *bridge) {
+ return call_global_noargs(bridge->L, "on_tick");
+}
diff --git a/src/core/lua_bridge.h b/src/core/lua_bridge.h
new file mode 100644
index 0000000..9ff9123
--- /dev/null
+++ b/src/core/lua_bridge.h
@@ -0,0 +1,26 @@
+#ifndef LEMACS_LUA_BRIDGE_H
+#define LEMACS_LUA_BRIDGE_H
+
+#include <stdbool.h>
+
+#include <lua.h>
+
+#include "core/editor.h"
+
+typedef struct {
+ lua_State *L;
+ Editor *editor;
+ char runtime_init_path[1024];
+} LuaBridge;
+
+bool lua_bridge_init(LuaBridge *bridge, Editor *editor);
+void lua_bridge_free(LuaBridge *bridge);
+
+bool lua_bridge_load_runtime(LuaBridge *bridge, const char *init_script_path);
+bool lua_bridge_load_optional_init(LuaBridge *bridge, const char *script_path);
+bool lua_bridge_call_key(LuaBridge *bridge, const char *key_name);
+bool lua_bridge_call_tick(LuaBridge *bridge);
+bool lua_bridge_call_open_file(LuaBridge *bridge, const char *path);
+bool lua_bridge_call_text(LuaBridge *bridge, const char *text);
+
+#endif
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..fd7590c
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,429 @@
+#include <raylib.h>
+
+#include <fontconfig/fontconfig.h>
+
+#include <stdbool.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "core/buffer.h"
+#include "core/editor.h"
+#include "core/lua_bridge.h"
+
+#ifndef LEMACS_RUNTIME_INIT
+#define LEMACS_RUNTIME_INIT "runtime/init.lua"
+#endif
+
+static const int WINDOW_WIDTH = 1100;
+static const int WINDOW_HEIGHT = 760;
+static const int DEFAULT_FONT_SIZE = 20;
+
+typedef struct {
+ Font text;
+ bool custom;
+ int text_size;
+ float cell_width;
+} LoadedFonts;
+
+static Color color_from_editor(EditorColor c) {
+ return (Color){c.r, c.g, c.b, c.a};
+}
+
+static bool resolve_font(const char *font_query, char *out_path, size_t out_size, int *out_size_px) {
+ if (!FcInit()) {
+ return false;
+ }
+
+ FcPattern *pattern = FcNameParse((const FcChar8 *)font_query);
+ if (!pattern) {
+ return false;
+ }
+
+ FcConfigSubstitute(NULL, pattern, FcMatchPattern);
+ FcDefaultSubstitute(pattern);
+
+ FcResult result;
+ FcPattern *match = FcFontMatch(NULL, pattern, &result);
+ FcPatternDestroy(pattern);
+ if (!match) {
+ return false;
+ }
+
+ FcChar8 *file = NULL;
+ if (FcPatternGetString(match, FC_FILE, 0, &file) != FcResultMatch || !file) {
+ FcPatternDestroy(match);
+ return false;
+ }
+
+ snprintf(out_path, out_size, "%s", (const char *)file);
+
+ double pixel_size = 0.0;
+ if (FcPatternGetDouble(match, FC_PIXEL_SIZE, 0, &pixel_size) == FcResultMatch && pixel_size > 0.0) {
+ *out_size_px = (int)lround(pixel_size);
+ } else {
+ double point_size = 0.0;
+ if (FcPatternGetDouble(match, FC_SIZE, 0, &point_size) == FcResultMatch && point_size > 0.0) {
+ *out_size_px = (int)lround(point_size);
+ }
+ }
+
+ FcPatternDestroy(match);
+ return true;
+}
+
+static bool load_single_font(const char *path, int size, Font *out_font) {
+ if (!path || path[0] == '\0' || size <= 0) {
+ return false;
+ }
+
+ Font loaded = LoadFontEx(path, size, NULL, 0);
+ if (loaded.texture.id == 0) {
+ return false;
+ }
+
+ GenTextureMipmaps(&loaded.texture);
+ SetTextureFilter(loaded.texture, TEXTURE_FILTER_TRILINEAR);
+ *out_font = loaded;
+ return true;
+}
+
+static LoadedFonts load_fonts_from_query(const char *font_query) {
+ LoadedFonts fonts = {0};
+ fonts.text = GetFontDefault();
+ fonts.custom = false;
+ fonts.text_size = DEFAULT_FONT_SIZE;
+ fonts.cell_width = (float)DEFAULT_FONT_SIZE * 0.5f;
+
+ if (!font_query || font_query[0] == '\0') {
+ font_query = "monospace:size=14";
+ }
+
+ int query_size = DEFAULT_FONT_SIZE;
+ char font_path[1024] = {0};
+ if (resolve_font(font_query, font_path, sizeof(font_path), &query_size)) {
+ if (query_size < 10) {
+ query_size = 10;
+ }
+
+ Font text_font = {0};
+ if (load_single_font(font_path, query_size, &text_font)) {
+ fonts.text = text_font;
+ fonts.custom = true;
+ fonts.text_size = query_size;
+ fprintf(stderr, "lemacs: loaded font '%s' from %s\n", font_query, font_path);
+ } else {
+ fprintf(stderr, "lemacs: failed to load font file %s, using default\n", font_path);
+ }
+ } else {
+ fprintf(stderr, "lemacs: fontconfig could not resolve '%s', using default\n", font_query);
+ }
+
+ Vector2 measure = MeasureTextEx(fonts.text, "M", (float)fonts.text_size, 0.0f);
+ fonts.cell_width = measure.x > 1.0f ? measure.x : (float)fonts.text_size * 0.5f;
+ return fonts;
+}
+
+static void unload_loaded_fonts(LoadedFonts *fonts) {
+ if (fonts->custom) {
+ UnloadFont(fonts->text);
+ }
+ fonts->text = GetFontDefault();
+ fonts->custom = false;
+ fonts->text_size = DEFAULT_FONT_SIZE;
+ fonts->cell_width = (float)DEFAULT_FONT_SIZE * 0.5f;
+}
+
+static void reload_font_if_needed(Editor *editor, LoadedFonts *fonts) {
+ if (!editor_consume_font_dirty(editor)) {
+ return;
+ }
+
+ unload_loaded_fonts(fonts);
+ *fonts = load_fonts_from_query(editor_font_query(editor));
+}
+
+static const char *key_name_from_input(int key, bool ctrl, bool shift) {
+ if (ctrl) {
+ if (key == KEY_Q) return "C-q";
+ if (key == KEY_S) return "C-s";
+ if (key == KEY_O) return "C-o";
+ if (key == KEY_H) return "C-h";
+ if (key == KEY_T) return "C-t";
+ if (key == KEY_A) return "C-a";
+ if (key == KEY_E) return "C-e";
+ if (key == KEY_F) return "C-f";
+ if (key == KEY_B) return "C-b";
+ return NULL;
+ }
+
+ if (shift) {
+ if (key == KEY_FOUR) return "$";
+ if (key == KEY_SIX) return "^";
+ if (key == KEY_SEMICOLON) return ":";
+ }
+
+ switch (key) {
+ case KEY_H: return "h";
+ case KEY_J: return "j";
+ case KEY_K: return "k";
+ case KEY_L: return "l";
+ case KEY_I: return "i";
+ case KEY_A: return "a";
+ case KEY_O: return "o";
+ case KEY_D: return "d";
+ case KEY_F: return "f";
+ case KEY_BACKSLASH: return "\\";
+ case KEY_SPACE: return "space";
+ case KEY_W: return "w";
+ case KEY_B: return "b";
+ case KEY_LEFT: return "left";
+ case KEY_RIGHT: return "right";
+ case KEY_UP: return "up";
+ case KEY_DOWN: return "down";
+ case KEY_BACKSPACE: return "backspace";
+ case KEY_DELETE: return "delete";
+ case KEY_ENTER: return "enter";
+ case KEY_TAB: return "tab";
+ case KEY_ESCAPE: return "escape";
+ default: return NULL;
+ }
+}
+
+static void draw_cursor_shape(int x, int y, int cell_width, int font_size, Color cursor_color, int shape, int thickness) {
+ if (shape == LEMACS_CURSOR_BLOCK) {
+ DrawRectangle(x, y, cell_width > 1 ? cell_width : 1, font_size + 2, cursor_color);
+ return;
+ }
+
+ if (shape == LEMACS_CURSOR_UNDERLINE) {
+ DrawRectangle(x, y + font_size + 1, cell_width > 1 ? cell_width : 1, thickness, cursor_color);
+ return;
+ }
+
+ DrawRectangle(x, y, thickness, font_size + 2, cursor_color);
+}
+
+static void draw_buffer_in_view(Buffer *buffer, size_t point, int scroll_line, Rectangle rect, Font font, int font_size, float cell_width, Color fg, bool show_cursor, Color cursor_color, int cursor_shape, int cursor_thickness) {
+ int line_height = font_size + 4;
+ int max_lines = (int)(rect.height / (float)line_height);
+ if (max_lines <= 0) {
+ return;
+ }
+
+ BeginScissorMode((int)rect.x, (int)rect.y, (int)rect.width, (int)rect.height);
+
+ size_t len = buffer_length(buffer);
+ size_t line_start = 0;
+ int current_line = 0;
+ while (line_start < len && current_line < scroll_line) {
+ if (buffer_char_at(buffer, line_start) == '\n') {
+ current_line++;
+ }
+ line_start++;
+ }
+
+ for (int draw_line = 0; draw_line < max_lines && line_start <= len; ++draw_line) {
+ int col = 0;
+ size_t i = line_start;
+ while (i < len) {
+ char ch = buffer_char_at(buffer, i);
+ if (ch == '\n') {
+ i++;
+ break;
+ }
+
+ char text[2] = {ch, '\0'};
+ int x = (int)rect.x + (int)((float)col * cell_width);
+ int y = (int)rect.y + draw_line * line_height;
+ DrawTextEx(font, text, (Vector2){(float)x, (float)y}, (float)font_size, 0.0f, fg);
+ col++;
+ i++;
+ }
+
+ if (i == line_start) {
+ break;
+ }
+ line_start = i;
+ }
+
+ if (show_cursor) {
+ int cursor_line = 0;
+ int cursor_col = 0;
+ buffer_index_to_line_col(buffer, point, &cursor_line, &cursor_col);
+ int draw_line = cursor_line - scroll_line;
+ if (draw_line >= 0 && draw_line < max_lines) {
+ int x = (int)rect.x + (int)((float)cursor_col * cell_width);
+ int y = (int)rect.y + draw_line * line_height;
+ draw_cursor_shape(x, y, (int)cell_width, font_size, cursor_color, cursor_shape, cursor_thickness);
+ }
+ }
+
+ EndScissorMode();
+}
+
+static Buffer *resolve_view_buffer(Editor *editor, const EditorView *view) {
+ if (view->source_type == 0) {
+ return editor_current_buffer(editor);
+ }
+
+ if (view->source_type == 1) {
+ return editor_aux_buffer_get(editor, view->source_index);
+ }
+
+ return NULL;
+}
+
+static void ensure_primary_cursor_visible(Editor *editor, const EditorView *main_view, int font_size) {
+ if (!main_view || main_view->source_type != 0) {
+ return;
+ }
+
+ int line_height = font_size + 4;
+ int max_lines = main_view->height / line_height;
+ if (max_lines <= 0) {
+ return;
+ }
+
+ int line = 0;
+ int col = 0;
+ Buffer *buffer = editor_current_buffer(editor);
+ if (!buffer) {
+ return;
+ }
+
+ buffer_index_to_line_col(buffer, buffer_point(buffer), &line, &col);
+ (void)col;
+
+ if (line < editor->scroll_line) {
+ editor->scroll_line = line;
+ } else if (line >= editor->scroll_line + max_lines) {
+ editor->scroll_line = line - max_lines + 1;
+ }
+
+ if (editor->scroll_line < 0) {
+ editor->scroll_line = 0;
+ }
+}
+
+int main(int argc, char **argv) {
+ SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT);
+ InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, "lemacs");
+ SetExitKey(KEY_NULL);
+ SetTargetFPS(60);
+
+ Editor editor;
+ if (!editor_init(&editor)) {
+ fprintf(stderr, "failed to initialize editor\n");
+ CloseWindow();
+ return 1;
+ }
+
+ LuaBridge bridge;
+ if (!lua_bridge_init(&bridge, &editor)) {
+ fprintf(stderr, "failed to initialize lua\n");
+ editor_free(&editor);
+ CloseWindow();
+ return 1;
+ }
+
+ if (!lua_bridge_load_runtime(&bridge, LEMACS_RUNTIME_INIT)) {
+ lua_bridge_free(&bridge);
+ editor_free(&editor);
+ CloseWindow();
+ return 1;
+ }
+
+ lua_bridge_load_optional_init(&bridge, "init.lua");
+
+ LoadedFonts fonts = load_fonts_from_query(editor_font_query(&editor));
+
+ if (argc > 1) {
+ lua_bridge_call_open_file(&bridge, argv[1]);
+ }
+
+ while (!WindowShouldClose() && !editor.should_quit) {
+ reload_font_if_needed(&editor, &fonts);
+
+ bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
+ bool shift = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
+
+ int key = GetKeyPressed();
+ while (key != 0) {
+ const char *name = key_name_from_input(key, ctrl, shift);
+ if (name) {
+ lua_bridge_call_key(&bridge, name);
+ }
+ key = GetKeyPressed();
+ }
+
+ if (!ctrl) {
+ int ch = GetCharPressed();
+ while (ch > 0) {
+ if (ch >= 32 && ch != 127) {
+ char utf8[5] = {0};
+ if (ch < 0x80) {
+ utf8[0] = (char)ch;
+ } else {
+ utf8[0] = '?';
+ }
+ lua_bridge_call_text(&bridge, utf8);
+ }
+ ch = GetCharPressed();
+ }
+ }
+
+ lua_bridge_call_tick(&bridge);
+
+ const EditorView *main_view = NULL;
+ for (int i = 0; i < editor.view_count; ++i) {
+ if (editor.views[i].source_type == 0 && editor.views[i].show_cursor) {
+ main_view = &editor.views[i];
+ break;
+ }
+ }
+ ensure_primary_cursor_visible(&editor, main_view, fonts.text_size);
+
+ BeginDrawing();
+ Color bg_top = color_from_editor(editor.bg_top);
+ Color bg_bottom = color_from_editor(editor.bg_bottom);
+ DrawRectangleGradientV(0, 0, GetScreenWidth(), GetScreenHeight(), bg_top, bg_bottom);
+
+ for (int i = 0; i < editor.view_count; ++i) {
+ EditorView *view = &editor.views[i];
+ Buffer *buffer = resolve_view_buffer(&editor, view);
+ if (!buffer) {
+ continue;
+ }
+
+ Rectangle rect = {(float)view->x, (float)view->y, (float)view->width, (float)view->height};
+ DrawRectangle(view->x, view->y, view->width, view->height, color_from_editor(view->bg));
+
+ size_t point = view->source_type == 0 ? buffer_point(buffer) : 0;
+ int scroll_line = view->source_type == 0 ? editor.scroll_line : 0;
+ draw_buffer_in_view(
+ buffer,
+ point,
+ scroll_line,
+ rect,
+ fonts.text,
+ fonts.text_size,
+ fonts.cell_width,
+ color_from_editor(view->fg),
+ view->show_cursor && view->source_type == 0,
+ color_from_editor(editor.cursor),
+ editor_cursor_shape(&editor),
+ editor_cursor_thickness(&editor)
+ );
+ }
+
+ EndDrawing();
+ }
+
+ lua_bridge_free(&bridge);
+ editor_free(&editor);
+ unload_loaded_fonts(&fonts);
+ CloseWindow();
+ return 0;
+}
diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..87dfb18
--- /dev/null
+++ b/test.txt
@@ -0,0 +1,5 @@
+# Sad machine
+hello? is any body there? quiting.
+
+
+