Initial commit: Odin editor client + Kotlin daemon prototype

Native SDL3 editor written in Odin with a piece-table buffer core,
backed by a Kotlin/JVM daemon speaking newline-delimited JSON over
localhost TCP for Gradle import, Kotlin/Java diagnostics, and
heuristic completion/hover/definition/references/rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pavel 2026-07-03 08:37:41 +02:00
commit 4a15350860
21 changed files with 8112 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Gradle / Kotlin build output
daemon/.gradle/
daemon/.kotlin/
daemon/build/
# Stray kotlinc output from ad-hoc compiles into the module root
daemon/dev/
daemon/META-INF/
# Compiled shaders
client/odin/shaders/compiled/
# Odin build output
/odin
client/odin/odin
# Autopilot control file
.opencode-autopilot-stop

121
README.md Normal file
View file

@ -0,0 +1,121 @@
# Native Kotlin Editor Prototype
This is the first slice of an Odin-native editor architecture backed by a small Kotlin/JVM daemon.
The boundary is intentionally simple: newline-delimited JSON over localhost TCP.
The native client tracks daemon request ids for interactive requests so concurrent popups and panels consume only their matching responses.
Text edits are debounced before sending `text/change` to the daemon; file switches, saves, navigation, and daemon-backed requests flush the active buffer immediately.
## Layout
- `client/odin/` - Odin client smoke test for the future native editor.
- `daemon/` - Kotlin/JVM TCP daemon for Gradle/Kotlin intelligence.
- `protocol.md` - current TCP message shape.
## Run
Start the daemon:
```sh
gradle -p daemon run
```
It prints a line like:
```text
PORT 49321
```
In another terminal, run the Odin client with that port:
```sh
odin run client/odin -- 49321 /path/to/workspace
```
The client sends `ping` and `workspace/open` requests and prints daemon responses.
Run the SDL editor window:
```sh
odin run client/odin -- --sdl /path/to/workspace /path/to/file.kt
```
The SDL mode starts the daemon automatically when no port is passed. You can still connect to an already-running daemon by passing its port before the file path:
Auto-started daemons are restarted automatically if the process exits or the TCP connection drops. Explicit-port mode does not restart external daemons.
```sh
odin run client/odin -- --sdl /path/to/workspace <port> /path/to/file.kt
```
The file path is optional. If no file is passed, the editor restores the last open files, active tab, and cursor positions for the workspace. If there is no saved state, it opens the current prototype default file under the workspace.
SDL mode shows a simple workspace file tree in the left sidebar. Click a file to open or focus it.
Open files appear in the editor tab strip; click a tab to switch active buffers or drag tabs to reorder them.
Use `Ctrl+W` to close the active buffer, or click a tab's `x`. Dirty buffers are not closed until saved.
SDL mode creates an SDL3 `GPUDevice` and uses a direct SDL3 GPU command-buffer renderer when a GPU backend is available. It batches editor rectangles, selections, cursor lines, panels, and `stb_truetype` font-atlas text into GPU vertices. It falls back to the regular SDL renderer in unsupported environments such as `SDL_VIDEODRIVER=dummy` smoke tests.
The GPU font loader checks `client/odin/assets/fonts/EditorMono.ttf`, `client/odin/assets/fonts/NotoSansMono-Regular.ttf`, then common system monospace font locations. The repo includes a bundled Noto Sans Mono font for the default path.
The GPU editor path includes lightweight Kotlin/Java syntax highlighting for comments, block comments, strings, character literals, triple-quoted Kotlin strings, keywords, numbers, and type-like identifiers.
Kotlin and Java diagnostics are shown in the gutter/status area with active-file error/warning/info counts, and as red dashed underlines in the GPU editor path. Kotlin diagnostics include Java source files from the same Gradle source roots for mixed-source projects, and unsaved Kotlin/Java buffers are compiled under their declared package path.
Use `F8` and `Shift+F8` to move to the next/previous diagnostic in the active file.
Use `Ctrl+Shift+M` to toggle the diagnostics panel. Use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to navigate issues.
Use `Ctrl+Shift+P` to open the command palette. Type to filter commands, use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to run actions. The palette includes Reload Workspace, which refreshes the file tree, reopens the Gradle workspace, clears stale diagnostics, and resyncs the active file.
Gradle workspace import records module source roots, test source roots, resource roots, classpath entries, tasks, and common generated source directories when they already exist under `build/generated/`. Diagnostics also include existing module output directories from `build/classes/` and `build/resources/` on their compiler classpath.
Use `Ctrl+Shift+T` to toggle Gradle tasks in the right sidebar. Use Up/Down, PageUp/PageDown, Home/End, mouse wheel, or row clicks to select tasks, and Enter to run the selected task; the sidebar shows the loaded task count and reports started/finished/failed events, the final response, and recent stdout/stderr output.
Compile starter SDL3 GPU shaders with:
```sh
scripts/compile-shaders.sh
```
The script uses `glslc` from shaderc and emits Vulkan/SPIR-V files under `client/odin/shaders/compiled/`. On Fedora, install it with `sudo dnf install glslc`.
Run autonomous opencode continuation loops with:
```sh
scripts/autopilot-opencode.sh
```
By default it runs `opencode run --dir "$PWD" --continue --dangerously-skip-permissions` repeatedly with an instruction to keep working through the task list and choose new tasks when the list is complete. Configure it with environment variables, for example:
```sh
OPENCODE_SESSION=<session-id> AUTOPILOT_MAX_RUNS=5 scripts/autopilot-opencode.sh
```
Create `.opencode-autopilot-stop` to stop the loop cleanly between iterations.
Set `AUTOPILOT_ALLOW_PERMISSIONS=0` to disable automatic permission approval.
The editor supports basic editing keys: arrows, `Ctrl+Left`/`Ctrl+Right` word movement, PageUp/PageDown, Home/End, Shift+movement selection, Backspace, Delete, Tab as four spaces, Shift+Tab outdent, auto-indent on Enter, mouse click/drag cursor selection, `Ctrl+C`/`Ctrl+X`/`Ctrl+V`, `Ctrl+F` find, `Ctrl+Z`/`Ctrl+Y` undo/redo, `Ctrl+S` save, and `Ctrl+Shift+S` save all. Replacing a selection with typed text, Enter, Tab, or a completion is one undo step, and undo/redo updates the dirty marker when returning to the saved version. Use `Ctrl+Shift+E` to toggle the Explorer sidebar. The editor and sidebars scroll independently based on mouse position.
Window size, sidebar widths, sidebar visibility, open files, active tab, and cursor positions are saved under the user cache directory and restored on startup.
In the find panel, Enter finds next and Shift+Enter finds previous. Visible matches are highlighted in the GPU editor path.
Press `Ctrl+M` in SDL GPU mode to toggle a font metrics overlay for debugging cursor/text alignment.
Press `Ctrl+Space` to open the completion popup. It sends `kotlin/completion` to the daemon and renders returned item labels with kind hints. Use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to choose and apply a completion by replacing the identifier prefix before the cursor. Completion returns Kotlin/Java keywords plus heuristic identifiers/declarations from the current buffer and workspace.
Press `Ctrl+H` to request hover information at the cursor. Hover describes language keywords and shows heuristic Kotlin/Java declaration lines/locations for identifiers when found in the current buffer or workspace.
Press `Ctrl+B` to go to definition. Definition currently uses a heuristic source scan for simple Kotlin/Java declarations and includes the current unsaved buffer.
Use `Alt+Left` and `Alt+Right` to navigate backward and forward through definition jumps.
Press `Ctrl+R` to find references for the identifier at the cursor. References currently use a heuristic source scan and are shown in a popup; use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to jump to a result.
Press `Ctrl+Shift+R` to open the rename preview panel. Enter a new name and press Enter to request preview edits. Rename remains preview-only and does not modify files yet.

133
ROADMAP.md Normal file
View file

@ -0,0 +1,133 @@
# Native Kotlin Editor Roadmap
## Current State
- Odin editor core with piece-table storage.
- File-backed editor buffers.
- Cursor movement and line/column mapping.
- SDL3 native window mode.
- SDL3 GPU command-buffer renderer path when a GPU backend is available.
- Basic text input, Enter with auto-indent, Backspace, Delete, Tab indent, arrows, Ctrl+Left/Ctrl+Right, selection, mouse click/drag cursor placement, and Ctrl+S save.
- JVM daemon over TCP JSON-lines.
- Gradle workspace import via Tooling API.
- Kotlin diagnostics using compiler embeddable.
- Async `diagnostics/publish` events rendered in the SDL gutter/status area and stored by file path for open buffers.
## Phase 1: Usable Editor
- Open arbitrary files in SDL mode.
- Add project file tree. Done.
- Support multiple open buffers.
- Add proper viewport scrolling independent of cursor.
- Add PageUp/PageDown/Home/End.
- Add Ctrl+Left/Ctrl+Right word movement. Done.
- Add mouse wheel scrolling.
- Add click-to-move-cursor. Done.
- Add mouse drag selection. Done.
- Add undo/redo.
- Add selection. Done.
- Add copy/cut/paste. Done.
- Add Delete key. Done.
- Add Tab/indent/outdent. Done: Tab inserts four spaces and Shift+Tab outdents the current or selected lines.
- Add auto-indent on Enter. Done.
- Add search in file. Started: Ctrl+F find panel with next/previous navigation and visible match highlights.
- Add Save All. Done.
- Replace SDL debug text with proper font rendering. Started: direct GPU path uses an `stb_truetype` baked Noto Sans Mono atlas.
- Replace renderer-backed drawing with a direct SDL3 GPU command-buffer quad/text pipeline. Started: rectangles, lines, panels, selection, cursor, and temporary text render through SDL3 GPU with SDL renderer fallback.
- Add shader compilation pipeline. Started: GLSL sources plus `scripts/compile-shaders.sh` using `glslc` to emit SPIR-V.
- Add syntax highlighting. Started: lightweight visible-line Kotlin/Java tokenizer in the GPU renderer.
## Phase 2: Daemon Integration
- Start JVM daemon automatically from Odin. Done.
- Read daemon `PORT` from stdout. Done.
- Stop daemon on editor exit. Done.
- Restart daemon if it crashes. Done for auto-started SDL daemons; explicit external-port mode reports disconnects without spawning a replacement.
- Replace ad-hoc JSON parsing in Odin. Done.
- Track request IDs and pending responses. Done.
- Distinguish responses and events cleanly. Done.
- Store diagnostics per file, not only active file. Done: diagnostics events are queued and applied to the matching open buffer path.
- Clear stale diagnostics on workspace reload.
- Render diagnostic underlines/squiggles. Started: GPU renderer draws red dashed underlines on diagnostic lines.
- Add next/previous diagnostic navigation. Done: F8 and Shift+F8 navigate active-file diagnostics.
- Add diagnostics panel. Done: Ctrl+Shift+M opens active-file diagnostics with keyboard navigation.
- Show Gradle tasks in UI. Started: Ctrl+Shift+T requests and displays tasks in a docked GPU right sidebar; responses are matched by request id.
- Run selected Gradle task. Started: Enter in the Gradle tasks panel runs the selected task and reports success/failure.
- Stream Gradle task output. Started: Gradle run lifecycle and stdout/stderr line events are surfaced in the tasks sidebar with recent output retained.
- Add workspace reload/import action. Started: command palette Reload Workspace refreshes the file tree, sends `workspace/open`, clears stale diagnostics, and resyncs the active buffer.
## Phase 3: Kotlin And Java Intelligence
- Implement `kotlin/completion` in the daemon. Started: prefix-filtered Kotlin keywords plus heuristic current-buffer/workspace identifiers and declarations.
- Add completion popup in SDL UI. Done: popup renders returned items and applies the selected completion with Enter or row click, using a single undoable replace edit.
- Add keyboard navigation in completion popup. Done.
- Implement `kotlin/hover`. Started: keyword hover plus heuristic declaration line/location lookup from the current buffer and workspace.
- Render hover tooltip. Done.
- Implement `kotlin/definition`. Started: heuristic simple declaration scan.
- Open target file and jump to line/column. Done.
- Add back/forward navigation stack. Done.
- Add find references. Started: heuristic source scan with SDL references popup.
- Add rename/refactor support. Started: daemon preview-only rename edits with SDL preview panel.
- Add Java diagnostics/completion/navigation. Started: `.java` diagnostics use the JDK compiler with Gradle module classpath/sourcepath; completion/hover/definition/references scan Java source declarations heuristically.
## Phase 4: Backend Quality
- Replace compiler-per-request diagnostics with long-lived analysis state.
- Cache Gradle module classpaths and source roots.
- Move toward Kotlin Analysis API where feasible.
- Correctly model multi-module projects. Started: diagnostics choose the module with the most specific matching source root.
- Separate main/test source-set analysis. Started: diagnostics use main roots for main files and main+test roots for test files.
- Include generated sources. Started: common existing `build/generated/` Kotlin/Java roots, including KSP/Kotlin Multiplatform source-set layouts, are added to Gradle module source roots.
- Improve classpath and project dependency handling. Started: diagnostics include existing module output directories from `build/classes/` and `build/resources/`, including common JVM/KMP layouts; Kotlin diagnostics include Java sources from the matching Gradle source roots.
## Phase 5: Product Shape
- Add sidebar file tree.
- Add editor tabs. Done: GPU tab strip shows open buffers and supports click-to-switch, close buttons, and drag reorder.
- Add bottom panel for diagnostics/tasks/output.
- Add command palette. Started: Ctrl+Shift+P opens a filterable GPU command palette for existing editor actions.
- Add configurable keybindings.
- Add theme settings.
- Add font settings.
- Persist last workspace, open files, cursor positions, and window layout. Started: window size, sidebars, open files, active tab, and cursor offsets are cached per workspace.
## Performance Work
- Incrementally update line index.
- Compact piece table after many edits.
- Cache rendered text/glyphs.
- Avoid full-buffer `text/change` sync on every edit. Started: edit-triggered sync is debounced.
- Debounce native-to-daemon text sync. Done: text edits schedule a delayed sync; file open/switch/navigation and daemon-backed commands still flush immediately.
## Next Work Queue
1. Open arbitrary file from SDL mode. Done.
2. Add scrolling/PageUp/PageDown/Home/End. Done.
3. Add undo/redo. Done.
4. Add daemon auto-start. Done.
5. Add robust protocol parser. Done.
6. Add project file tree. Done.
7. Add completion popup skeleton. Done.
8. Wire completion responses into popup. Done.
9. Implement real `kotlin/completion` in the daemon. Started: prefix-filtered Kotlin keywords plus heuristic current-buffer/workspace identifiers and declarations.
10. Implement `kotlin/hover` and render hover tooltip. Started: keyword hover plus heuristic declaration line/location lookup from the current buffer and workspace.
11. Implement `kotlin/definition` and Ctrl+B navigation. Started: heuristic simple declaration scan.
12. Add back/forward navigation stack. Done.
13. Add find references. Started: heuristic source scan with SDL references popup.
14. Add rename/refactor support. Started: daemon preview-only rename edits with SDL preview panel.
15. Add Ctrl+Left/Ctrl+Right word movement. Done.
16. Add click-to-move-cursor. Done.
17. Add Delete key. Done.
18. Add Tab indentation and auto-indent. Done: Tab inserts four spaces, Shift+Tab outdents, and Enter preserves leading whitespace.
19. Improve mouse support. Done: sidebar/editor wheel routing, sidebar scrolling, click-to-place-cursor, drag selection.
20. Add selection. Done: mouse drag and Shift+movement selection with replacement on edit.
## Immediate Stabilization Goals
1. Text metrics correctness. In progress: GPU text uses explicit fixed-cell atlas placement, widened cell advance, GPU-aware hit-testing, and `Ctrl+M` metrics overlay.
2. Font portability. Done for the prototype: bundled Noto Sans Mono is loaded from `client/odin/assets/fonts/` with system fallback paths.
3. Proper font shaping baseline. Replace baked ASCII-only atlas with a more deliberate atlas/SDF pipeline.
4. Renderer abstraction cleanup. Split UI drawing from SDL fallback/GPU backend details.
5. Input consistency. Started: cursor, hit-testing, syntax spans, and selection use shared metrics in the GPU path; command palette, completion, references, diagnostics, and Gradle panels now keep keyboard selection visible, support row clicks, route wheel events when hovered, and handle PageUp/PageDown plus Home/End.
6. Syntax quality. Started: visible-line tokenizer now carries `/* ... */` block comment and Kotlin triple-quoted string state across rendered lines, with Kotlin/Java keywords and character literals. Still needs semantic tokens.
7. Editor essentials. Done for the current prototype: clipboard copy/cut/paste, Save All, richer Ctrl+F search, clickable GPU tabs, safe Ctrl+W tab close, tab close buttons, drag reorder, stale overlay cleanup after edits, dirty tracking by saved buffer version, robust open-buffer diagnostic matching, and single-step/no-op-aware undo for selection/full-buffer replacement are implemented.

Binary file not shown.

538
client/odin/buffer.odin Normal file
View file

@ -0,0 +1,538 @@
package main
Piece_Source :: enum {
Original,
Add,
}
Piece :: struct {
source: Piece_Source,
start: int,
len: int,
}
Line_Index :: struct {
starts: [dynamic]int,
dirty: bool,
}
Buffer_Edit_Kind :: enum {
Insert,
Delete,
Replace,
}
Buffer_Edit :: struct {
kind: Buffer_Edit_Kind,
offset: int,
text: [dynamic]u8,
replacement: [dynamic]u8,
}
Buffer :: struct {
original: string,
add: [dynamic]u8,
pieces: [dynamic]Piece,
undo: [dynamic]Buffer_Edit,
redo: [dynamic]Buffer_Edit,
line_index: Line_Index,
version: int,
}
Cursor :: struct {
offset: int,
wanted_column: int,
}
buffer_make :: proc(original: string) -> Buffer {
buffer: Buffer
buffer.original = original
buffer.line_index.dirty = true
if len(original) > 0 {
append(&buffer.pieces, Piece{.Original, 0, len(original)})
}
return buffer
}
buffer_len :: proc(buffer: ^Buffer) -> int {
total := 0
for piece in buffer.pieces {
total += piece.len
}
return total
}
buffer_insert :: proc(buffer: ^Buffer, offset: int, text: string) {
if len(text) == 0 do return
edit_text := clone_bytes(transmute([]u8)text)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Insert, offset = clamp_int(offset, 0, buffer_len(buffer)), text = edit_text})
buffer_clear_edits(&buffer.redo)
buffer_insert_raw(buffer, offset, text)
}
buffer_insert_raw :: proc(buffer: ^Buffer, offset: int, text: string) {
buffer_insert_raw_internal(buffer, offset, text, true)
}
buffer_insert_raw_internal :: proc(buffer: ^Buffer, offset: int, text: string, bump_version: bool) {
if len(text) == 0 do return
insert_at := clamp_int(offset, 0, buffer_len(buffer))
add_start := len(buffer.add)
text_bytes := transmute([]u8)text
for b in text_bytes {
append(&buffer.add, b)
}
new_piece := Piece{.Add, add_start, len(text_bytes)}
new_pieces: [dynamic]Piece
inserted := false
cursor := 0
for piece in buffer.pieces {
piece_end := cursor + piece.len
if !inserted && insert_at <= piece_end {
inner := insert_at - cursor
if inner > 0 {
append(&new_pieces, Piece{piece.source, piece.start, inner})
}
append(&new_pieces, new_piece)
if inner < piece.len {
append(&new_pieces, Piece{piece.source, piece.start + inner, piece.len - inner})
}
inserted = true
} else {
append(&new_pieces, piece)
}
cursor = piece_end
}
if !inserted {
append(&new_pieces, new_piece)
}
delete(buffer.pieces)
buffer.pieces = new_pieces
if bump_version do buffer.version += 1
buffer.line_index.dirty = true
}
buffer_delete_range :: proc(buffer: ^Buffer, offset: int, count: int) {
if count <= 0 do return
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
if start == end do return
deleted := buffer_range_bytes(buffer, start, end - start)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Delete, offset = start, text = deleted})
buffer_clear_edits(&buffer.redo)
buffer_delete_range_raw(buffer, start, end - start)
}
buffer_replace_range :: proc(buffer: ^Buffer, offset: int, count: int, replacement: string) {
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + max_int(count, 0), start, buffer_len(buffer))
if start == end && len(replacement) == 0 do return
deleted := buffer_range_bytes(buffer, start, end - start)
if bytes_equal(deleted[:], transmute([]u8)replacement) {
delete(deleted)
return
}
replacement_bytes := clone_bytes(transmute([]u8)replacement)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Replace, offset = start, text = deleted, replacement = replacement_bytes})
buffer_clear_edits(&buffer.redo)
buffer_delete_range_raw_internal(buffer, start, end - start, false)
buffer_insert_raw_internal(buffer, start, replacement, false)
buffer.version += 1
buffer.line_index.dirty = true
}
buffer_delete_range_raw :: proc(buffer: ^Buffer, offset: int, count: int) {
buffer_delete_range_raw_internal(buffer, offset, count, true)
}
buffer_delete_range_raw_internal :: proc(buffer: ^Buffer, offset: int, count: int, bump_version: bool) {
if count <= 0 do return
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
if start == end do return
new_pieces: [dynamic]Piece
cursor := 0
for piece in buffer.pieces {
piece_start := cursor
piece_end := cursor + piece.len
if piece_end <= start || piece_start >= end {
append(&new_pieces, piece)
} else {
keep_left := max_int(0, start - piece_start)
keep_right := max_int(0, piece_end - end)
if keep_left > 0 {
append(&new_pieces, Piece{piece.source, piece.start, keep_left})
}
if keep_right > 0 {
right_start := piece.start + piece.len - keep_right
append(&new_pieces, Piece{piece.source, right_start, keep_right})
}
}
cursor = piece_end
}
delete(buffer.pieces)
buffer.pieces = new_pieces
if bump_version do buffer.version += 1
buffer.line_index.dirty = true
}
buffer_undo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
edit, ok := buffer_pop_edit(&buffer.undo)
if !ok do return false
switch edit.kind {
case .Insert:
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
cursor.offset = edit.offset
case .Delete:
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
cursor.offset = edit.offset + len(edit.text)
case .Replace:
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.replacement), false)
buffer_insert_raw_internal(buffer, edit.offset, string(edit.text[:]), false)
buffer.version += 1
buffer.line_index.dirty = true
cursor.offset = edit.offset + len(edit.text)
}
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
buffer_push_edit(&buffer.redo, edit)
return true
}
buffer_redo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
edit, ok := buffer_pop_edit(&buffer.redo)
if !ok do return false
switch edit.kind {
case .Insert:
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
cursor.offset = edit.offset + len(edit.text)
case .Delete:
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
cursor.offset = edit.offset
case .Replace:
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.text), false)
buffer_insert_raw_internal(buffer, edit.offset, string(edit.replacement[:]), false)
buffer.version += 1
buffer.line_index.dirty = true
cursor.offset = edit.offset + len(edit.replacement)
}
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
buffer_push_edit(&buffer.undo, edit)
return true
}
buffer_bytes :: proc(buffer: ^Buffer) -> [dynamic]u8 {
out: [dynamic]u8
for piece in buffer.pieces {
switch piece.source {
case .Original:
chunk := buffer.original[piece.start:piece.start + piece.len]
chunk_bytes := transmute([]u8)chunk
for b in chunk_bytes {
append(&out, b)
}
case .Add:
for b in buffer.add[piece.start:piece.start + piece.len] {
append(&out, b)
}
}
}
return out
}
buffer_rebuild_line_index :: proc(buffer: ^Buffer) {
delete(buffer.line_index.starts)
buffer.line_index.starts = make([dynamic]int)
append(&buffer.line_index.starts, 0)
text := buffer_bytes(buffer)
defer delete(text)
for b, i in text {
if b == '\n' {
append(&buffer.line_index.starts, i + 1)
}
}
buffer.line_index.dirty = false
}
buffer_line_count :: proc(buffer: ^Buffer) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
return len(buffer.line_index.starts)
}
buffer_line_start :: proc(buffer: ^Buffer, line: int) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
if len(buffer.line_index.starts) == 0 do return 0
index := clamp_int(line, 0, len(buffer.line_index.starts) - 1)
return buffer.line_index.starts[index]
}
buffer_line_end :: proc(buffer: ^Buffer, line: int) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
line_count := len(buffer.line_index.starts)
if line_count == 0 do return 0
index := clamp_int(line, 0, line_count - 1)
if index + 1 < line_count {
return max_int(buffer.line_index.starts[index], buffer.line_index.starts[index + 1] - 1)
}
return buffer_len(buffer)
}
buffer_offset_to_line_col :: proc(buffer: ^Buffer, offset: int) -> (line: int, column: int) {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
target := clamp_int(offset, 0, buffer_len(buffer))
result := 0
for start, i in buffer.line_index.starts {
if start > target do break
result = i
}
return result, target - buffer.line_index.starts[result]
}
buffer_line_col_to_offset :: proc(buffer: ^Buffer, line: int, column: int) -> int {
start := buffer_line_start(buffer, line)
end := buffer_line_end(buffer, line)
return clamp_int(start + max_int(column, 0), start, end)
}
buffer_line_bytes :: proc(buffer: ^Buffer, line: int) -> [dynamic]u8 {
start := buffer_line_start(buffer, line)
end := buffer_line_end(buffer, line)
return buffer_range_bytes(buffer, start, end - start)
}
buffer_range_bytes :: proc(buffer: ^Buffer, offset: int, count: int) -> [dynamic]u8 {
out: [dynamic]u8
if count <= 0 do return out
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
cursor := 0
for piece in buffer.pieces {
piece_start := cursor
piece_end := cursor + piece.len
if piece_end > start && piece_start < end {
local_start := max_int(start - piece_start, 0)
local_end := piece.len - max_int(piece_end - end, 0)
switch piece.source {
case .Original:
chunk := buffer.original[piece.start + local_start:piece.start + local_end]
chunk_bytes := transmute([]u8)chunk
for b in chunk_bytes {
append(&out, b)
}
case .Add:
for b in buffer.add[piece.start + local_start:piece.start + local_end] {
append(&out, b)
}
}
}
cursor = piece_end
}
return out
}
cursor_insert :: proc(buffer: ^Buffer, cursor: ^Cursor, text: string) {
buffer_insert(buffer, cursor.offset, text)
cursor.offset += len(text)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_backspace :: proc(buffer: ^Buffer, cursor: ^Cursor) {
if cursor.offset == 0 do return
buffer_delete_range(buffer, cursor.offset - 1, 1)
cursor.offset -= 1
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_delete_forward :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
if cursor.offset >= buffer_len(buffer) do return false
buffer_delete_range(buffer, cursor.offset, 1)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
return true
}
cursor_insert_newline_auto_indent :: proc(buffer: ^Buffer, cursor: ^Cursor) {
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
line_bytes := buffer_line_bytes(buffer, line)
defer delete(line_bytes)
indent_len := 0
for b in line_bytes {
if b == ' ' || b == '\t' {
indent_len += 1
} else {
break
}
}
text: [dynamic]u8
defer delete(text)
append(&text, '\n')
for b in line_bytes[:indent_len] {
append(&text, b)
}
cursor_insert(buffer, cursor, string(text[:]))
}
cursor_move_word_left :: proc(buffer: ^Buffer, cursor: ^Cursor) {
text := buffer_bytes(buffer)
defer delete(text)
offset := clamp_int(cursor.offset, 0, len(text))
for offset > 0 && is_space_byte(text[offset - 1]) {
offset -= 1
}
if offset > 0 && is_identifier_byte(text[offset - 1]) {
for offset > 0 && is_identifier_byte(text[offset - 1]) {
offset -= 1
}
} else if offset > 0 {
offset -= 1
}
cursor.offset = offset
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_word_right :: proc(buffer: ^Buffer, cursor: ^Cursor) {
text := buffer_bytes(buffer)
defer delete(text)
offset := clamp_int(cursor.offset, 0, len(text))
if offset < len(text) && is_identifier_byte(text[offset]) {
for offset < len(text) && is_identifier_byte(text[offset]) {
offset += 1
}
} else if offset < len(text) {
offset += 1
}
for offset < len(text) && is_space_byte(text[offset]) {
offset += 1
}
cursor.offset = offset
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_to_line_col :: proc(buffer: ^Buffer, cursor: ^Cursor, line: int, column: int) {
cursor.offset = buffer_line_col_to_offset(buffer, line, column)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_vertical :: proc(buffer: ^Buffer, cursor: ^Cursor, delta: int) {
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
target_line := clamp_int(line + delta, 0, buffer_line_count(buffer) - 1)
cursor.offset = buffer_line_col_to_offset(buffer, target_line, cursor.wanted_column)
}
is_identifier_byte :: proc(b: u8) -> bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
is_space_byte :: proc(b: u8) -> bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
buffer_destroy :: proc(buffer: ^Buffer) {
buffer_clear_edits(&buffer.undo)
buffer_clear_edits(&buffer.redo)
delete(buffer.add)
delete(buffer.pieces)
delete(buffer.line_index.starts)
}
buffer_push_edit :: proc(stack: ^[dynamic]Buffer_Edit, edit: Buffer_Edit) {
append(stack, edit)
}
buffer_pop_edit :: proc(stack: ^[dynamic]Buffer_Edit) -> (edit: Buffer_Edit, ok: bool) {
if len(stack^) == 0 do return {}, false
index := len(stack^) - 1
edit = stack^[index]
resize(stack, index)
return edit, true
}
buffer_clear_edits :: proc(stack: ^[dynamic]Buffer_Edit) {
for edit in stack^ {
delete(edit.text)
delete(edit.replacement)
}
clear(stack)
}
clone_bytes :: proc(bytes: []u8) -> [dynamic]u8 {
out: [dynamic]u8
for b in bytes {
append(&out, b)
}
return out
}
bytes_equal :: proc(a, b: []u8) -> bool {
if len(a) != len(b) do return false
for value, index in a {
if value != b[index] do return false
}
return true
}
clamp_int :: proc(value, low, high: int) -> int {
if value < low do return low
if value > high do return high
return value
}
max_int :: proc(a, b: int) -> int {
if a > b do return a
return b
}

View file

@ -0,0 +1,615 @@
package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strconv"
import "core:strings"
import "core:sync"
import "core:thread"
import "core:time"
import json "core:encoding/json"
Protocol_Message_Kind :: enum {
Invalid,
Response,
Event,
}
Protocol_Message :: struct {
kind: Protocol_Message_Kind,
id: int,
ok: bool,
event: string,
}
Daemon_Response :: struct {
id: int,
line: [dynamic]u8,
}
Daemon_Event :: struct {
line: [dynamic]u8,
}
Daemon_Process :: struct {
process: os.Process,
stdout: ^os.File,
running: bool,
}
Daemon_Client :: struct {
socket: net.TCP_Socket,
connected: bool,
next_id: int,
reader: ^thread.Thread,
event_mutex: sync.Mutex,
response_mutex: sync.Mutex,
diagnostic_events: [dynamic]Daemon_Event,
gradle_events: [dynamic]Daemon_Event,
pending_response_ids: [dynamic]int,
responses: [dynamic]Daemon_Response,
}
daemon_connect :: proc(port: int) -> Daemon_Client {
client := Daemon_Client{next_id = 1}
if port <= 0 do return client
socket, err := net.dial_tcp("127.0.0.1", port)
if err != nil {
fmt.println("daemon connect failed:", err)
return client
}
client.socket = socket
client.connected = true
return client
}
daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) {
child, ok := daemon_start_process_begin(workspace)
if !ok {
return child, 0, false
}
port, port_ok := daemon_read_port(&child)
if !port_ok {
daemon_stop_process(&child)
return child, 0, false
}
return child, port, true
}
daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) {
child := Daemon_Process{}
stdout_r, stdout_w, pipe_err := os.pipe()
if pipe_err != nil {
fmt.println("daemon pipe failed:", pipe_err)
return child, false
}
command := []string{"gradle", "-p", workspace, "run", "--quiet"}
process, start_err := os.process_start(os.Process_Desc{
command = command,
stdout = stdout_w,
})
_ = os.close(stdout_w)
if start_err != nil {
fmt.println("daemon start failed:", start_err)
_ = os.close(stdout_r)
return child, false
}
child = Daemon_Process{process = process, stdout = stdout_r, running = true}
return child, true
}
daemon_stop_process :: proc(child: ^Daemon_Process) {
if child.stdout != nil {
_ = os.close(child.stdout)
child.stdout = nil
}
if child.running {
_ = os.process_terminate(child.process)
state, wait_err := os.process_wait(child.process, 2 * time.Second)
if wait_err != nil || !state.exited {
_ = os.process_kill(child.process)
_, _ = os.process_wait(child.process)
}
child.running = false
}
}
daemon_read_port :: proc(child: ^Daemon_Process) -> (int, bool) {
line: [dynamic]u8
defer delete(line)
start := time.now()
buf: [1024]u8
for time.diff(start, time.now()) < 30 * time.Second {
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false
}
if !has_data {
time.sleep(100 * time.Millisecond)
continue
}
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
if ok do return port, true
clear(&line)
} else {
append(&line, b)
}
}
}
fmt.println("daemon start timed out")
return 0, false
}
daemon_poll_port :: proc(child: ^Daemon_Process, line: ^[dynamic]u8) -> (int, bool, bool) {
if child == nil || !child.running || child.stdout == nil do return 0, false, true
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false, true
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false, true
}
if !has_data do return 0, false, false
buf: [1024]u8
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false, true
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
clear(line)
if ok do return port, true, false
} else {
append(line, b)
}
}
return 0, false, false
}
daemon_parse_port_line :: proc(line: string) -> (int, bool) {
if !strings.has_prefix(line, "PORT ") do return 0, false
port, ok := strconv.parse_int(line[len("PORT "):])
if !ok do return 0, false
return int(port), true
}
daemon_start_reader :: proc(client: ^Daemon_Client) {
if client.connected && client.reader == nil {
client.reader = thread.create_and_start_with_data(rawptr(client), daemon_reader_thread)
}
}
daemon_close :: proc(client: ^Daemon_Client) {
if client.connected {
net.close(client.socket)
client.connected = false
}
if client.reader != nil && thread.is_done(client.reader) {
thread.destroy(client.reader)
client.reader = nil
}
for &event in client.diagnostic_events {
delete(event.line)
}
delete(client.diagnostic_events)
client.diagnostic_events = nil
for &event in client.gradle_events {
delete(event.line)
}
delete(client.gradle_events)
client.gradle_events = nil
delete(client.pending_response_ids)
client.pending_response_ids = nil
for &response in client.responses {
delete(response.line)
}
delete(client.responses)
client.responses = nil
}
daemon_send :: proc(client: ^Daemon_Client, message: string) {
if !client.connected do return
bytes := transmute([]byte)message
_, err := net.send_tcp(client.socket, bytes)
if err != nil {
fmt.println("daemon send failed:", err)
daemon_close(client)
}
}
daemon_track_response :: proc(client: ^Daemon_Client, id: int) {
if id == 0 do return
if sync.mutex_guard(&client.response_mutex) {
append(&client.pending_response_ids, id)
}
}
daemon_send_workspace_open :: proc(client: ^Daemon_Client, workspace: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"workspace/open\",\"params\":{{\"root\":%q}}}}\n", id, workspace)
daemon_send(client, request)
}
daemon_send_text_open :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/open\",\"params\":{{\"path\":%q,\"version\":%d,\"text\":%q}}}}\n", id, path, version, text)
daemon_send(client, request)
}
daemon_send_text_change :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/change\",\"params\":{{\"path\":%q,\"version\":%d,\"text\":%q}}}}\n", id, path, version, text)
daemon_send(client, request)
}
daemon_send_completion :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_hover :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_definition :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_references :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/references\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_rename :: proc(client: ^Daemon_Client, path: string, line, column: int, new_name: string) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d,\"newName\":%q}}}}\n", id, path, line + 1, column + 1, new_name)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_gradle_tasks :: proc(client: ^Daemon_Client) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/tasks\",\"params\":{{}}}}\n", id)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/run\",\"params\":{{\"task\":%q}}}}\n", id, task)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) {
active := editor_active_buffer(editor)
if active == nil do return
text := editor_active_text(editor)
defer delete(text)
if open {
daemon_send_text_open(client, active.path, active.buffer.version, string(text[:]))
} else {
daemon_send_text_change(client, active.path, active.buffer.version, string(text[:]))
}
}
daemon_reader_thread :: proc(data: rawptr) {
client := (^Daemon_Client)(data)
buf: [4096]u8
line: [dynamic]u8
defer delete(line)
for client.connected {
n, err := net.recv_tcp(client.socket, buf[:])
if err != nil || n == 0 {
client.connected = false
break
}
for b in buf[:n] {
if b == '\n' {
daemon_store_line(client, string(line[:]))
clear(&line)
} else {
append(&line, b)
}
}
}
}
daemon_store_line :: proc(client: ^Daemon_Client, line: string) {
message, value, ok := parse_protocol_message(line)
if !ok do return
defer json.destroy_value(value)
if message.kind == .Response {
if sync.mutex_guard(&client.response_mutex) {
tracked := false
for pending_id, index in client.pending_response_ids {
if pending_id == message.id {
ordered_remove(&client.pending_response_ids, index)
tracked = true
break
}
}
if !tracked do return
response := Daemon_Response{id = message.id}
for b in transmute([]u8)line {
append(&response.line, b)
}
append(&client.responses, response)
}
return
}
if message.kind != .Event do return
if message.event == "gradle/run" {
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.gradle_events, event)
}
return
}
if message.event != "diagnostics/publish" do return
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.diagnostic_events, event)
}
}
daemon_take_response :: proc(client: ^Daemon_Client, expected_id: int) -> [dynamic]u8 {
out: [dynamic]u8
if expected_id == 0 do return out
if sync.mutex_guard(&client.response_mutex) {
for &response, index in client.responses {
if response.id != expected_id do continue
for b in response.line {
append(&out, b)
}
delete(response.line)
ordered_remove(&client.responses, index)
break
}
}
return out
}
daemon_take_diagnostic_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.diagnostic_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.diagnostic_events) == 0 do return out
event := client.diagnostic_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.diagnostic_events, 0)
}
return out
}
daemon_take_gradle_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.gradle_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.gradle_events) == 0 do return out
event := client.gradle_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.gradle_events, 0)
}
return out
}
daemon_apply_latest_diagnostics :: proc(client: ^Daemon_Client, editor: ^Editor) {
for {
event := daemon_take_diagnostic_event(client)
if len(event) == 0 {
delete(event)
return
}
path, diagnostics := parse_diagnostics_event(string(event[:]))
if len(path) == 0 || !editor_set_diagnostics_for_path(editor, path, diagnostics[:]) {
diagnostics_destroy(diagnostics[:])
}
delete(path)
delete(diagnostics)
delete(event)
}
}
diagnostics_destroy :: proc(diagnostics: []Diagnostic) {
for diagnostic in diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
}
parse_diagnostics_event :: proc(event: string) -> (string, [dynamic]Diagnostic) {
diagnostics: [dynamic]Diagnostic
message, value, parsed := parse_protocol_message(event)
if !parsed do return "", diagnostics
defer json.destroy_value(value)
if message.kind != .Event || message.event != "diagnostics/publish" do return "", diagnostics
params, has_params := json_object_get(value, "params")
if !has_params do return "", diagnostics
path, _ := json_get_string(params, "path")
diagnostics_value, has_diagnostics := json_object_get(params, "diagnostics")
if !has_diagnostics do return strings.clone(path), diagnostics
#partial switch items in diagnostics_value {
case json.Array:
for item in items {
severity, _ := json_get_string(item, "severity")
diagnostic_message, _ := json_get_string(item, "message")
line, _ := json_get_int(item, "line")
column, _ := json_get_int(item, "column")
append(&diagnostics, Diagnostic{
line = max_int(line - 1, 0),
column = max_int(column - 1, 0),
severity = strings.clone(severity),
message = strings.clone(diagnostic_message),
})
}
}
return strings.clone(path), diagnostics
}
parse_protocol_message :: proc(line: string) -> (Protocol_Message, json.Value, bool) {
value, err := json.parse_string(line, .JSON, true)
if err != nil {
return Protocol_Message{}, value, false
}
message := Protocol_Message{}
if event, ok := json_get_string(value, "event"); ok {
message.kind = .Event
message.event = event
return message, value, true
}
if id, ok := json_get_int(value, "id"); ok {
message.kind = .Response
message.id = id
message.ok, _ = json_get_bool(value, "ok")
return message, value, true
}
return message, value, false
}
json_object_get :: proc(value: json.Value, key: string) -> (json.Value, bool) {
#partial switch object in value {
case json.Object:
if item, ok := object[key]; ok {
return item, true
}
}
return json.Value{}, false
}
json_get_string :: proc(value: json.Value, key: string) -> (string, bool) {
item, ok := json_object_get(value, key)
if !ok do return "", false
#partial switch s in item {
case json.String:
return string(s), true
}
return "", false
}
json_get_int :: proc(value: json.Value, key: string) -> (int, bool) {
item, ok := json_object_get(value, key)
if !ok do return 0, false
#partial switch n in item {
case json.Integer:
return int(n), true
case json.Float:
return int(n), true
}
return 0, false
}
json_get_bool :: proc(value: json.Value, key: string) -> (bool, bool) {
item, ok := json_object_get(value, key)
if !ok do return false, false
#partial switch b in item {
case json.Boolean:
return bool(b), true
}
return false, false
}

449
client/odin/editor.odin Normal file
View file

@ -0,0 +1,449 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
Diagnostic :: struct {
line: int,
column: int,
severity: string,
message: string,
}
Editor_Buffer :: struct {
path: string,
daemon_path: string,
original_storage: []u8,
buffer: Buffer,
cursor: Cursor,
dirty: bool,
saved_version: int,
selection_active: bool,
selection_anchor: int,
diagnostics: [dynamic]Diagnostic,
}
Editor :: struct {
buffers: [dynamic]Editor_Buffer,
active: int,
status: string,
}
editor_open_file :: proc(editor: ^Editor, path: string) -> bool {
data, err := os.read_entire_file(path, context.allocator)
if err != nil {
fmt.println("open file failed:", err)
return false
}
editor_buffer := Editor_Buffer{
path = strings.clone(path),
daemon_path = strings.clone(""),
original_storage = data,
buffer = buffer_make(string(data)),
saved_version = 0,
}
append(&editor.buffers, editor_buffer)
editor.active = len(editor.buffers) - 1
return true
}
editor_open_or_focus_file :: proc(editor: ^Editor, path: string) -> bool {
for &buffer, index in editor.buffers {
if buffer.path == path {
editor.active = index
return true
}
}
return editor_open_file(editor, path)
}
editor_replace_active_file :: proc(editor: ^Editor, path: string) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
if active.path == path do return true
for &buffer, index in editor.buffers {
if buffer.path == path {
if index != editor.active {
editor.buffers[editor.active], editor.buffers[index] = editor.buffers[index], editor.buffers[editor.active]
}
return true
}
}
if active.dirty {
editor_set_status(editor, "Save current buffer before navigating to another file")
return false
}
data, err := os.read_entire_file(path, context.allocator)
if err != nil {
fmt.println("open file failed:", err)
return false
}
editor_buffer_destroy(active)
active.path = strings.clone(path)
active.daemon_path = strings.clone("")
active.original_storage = data
active.buffer = buffer_make(string(data))
active.cursor = Cursor{}
active.dirty = false
active.saved_version = active.buffer.version
active.selection_active = false
active.selection_anchor = 0
active.diagnostics = make([dynamic]Diagnostic)
return true
}
editor_active_buffer :: proc(editor: ^Editor) -> ^Editor_Buffer {
if len(editor.buffers) == 0 do return nil
return &editor.buffers[editor.active]
}
editor_replace_active_text :: proc(editor: ^Editor, text: string) {
active := editor_active_buffer(editor)
if active == nil do return
buffer_replace_range(&active.buffer, 0, buffer_len(&active.buffer), text)
active.cursor.offset = len(text)
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_active_text :: proc(editor: ^Editor) -> [dynamic]u8 {
active := editor_active_buffer(editor)
if active == nil {
empty: [dynamic]u8
return empty
}
return buffer_bytes(&active.buffer)
}
editor_clear_selection :: proc(active: ^Editor_Buffer) {
if active == nil do return
active.selection_active = false
active.selection_anchor = active.cursor.offset
}
editor_start_selection :: proc(active: ^Editor_Buffer) {
if active == nil do return
if !active.selection_active {
active.selection_active = true
active.selection_anchor = active.cursor.offset
}
}
editor_selection_range :: proc(active: ^Editor_Buffer) -> (start: int, end: int, ok: bool) {
if active == nil || !active.selection_active do return 0, 0, false
start = min_int(active.selection_anchor, active.cursor.offset)
end = max_int(active.selection_anchor, active.cursor.offset)
return start, end, start < end
}
editor_delete_selection :: proc(active: ^Editor_Buffer) -> bool {
start, end, ok := editor_selection_range(active)
if !ok do return false
buffer_delete_range(&active.buffer, start, end - start)
active.cursor.offset = start
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
return true
}
editor_insert_text :: proc(active: ^Editor_Buffer, text: string) {
if active == nil do return
selection_start, selection_end, has_selection := editor_selection_range(active)
if has_selection {
buffer_replace_range(&active.buffer, selection_start, selection_end - selection_start, text)
active.cursor.offset = selection_start + len(text)
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
editor_update_dirty(active)
return
}
cursor_insert(&active.buffer, &active.cursor, text)
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_update_dirty :: proc(active: ^Editor_Buffer) {
if active == nil do return
active.dirty = active.buffer.version != active.saved_version
}
editor_outdent :: proc(active: ^Editor_Buffer) -> bool {
if active == nil do return false
selection_start, selection_end, has_selection := editor_selection_range(active)
start_line, _ := buffer_offset_to_line_col(&active.buffer, selection_start if has_selection else active.cursor.offset)
end_line, end_col := buffer_offset_to_line_col(&active.buffer, selection_end if has_selection else active.cursor.offset)
if has_selection && end_col == 0 && end_line > start_line {
end_line -= 1
}
total_removed := 0
cursor_removed_before := 0
anchor_removed_before := 0
cursor_offset := active.cursor.offset
anchor_offset := active.selection_anchor
for line := end_line; line >= start_line; line -= 1 {
line_start := buffer_line_start(&active.buffer, line)
line_end := buffer_line_end(&active.buffer, line)
remove_count := editor_line_outdent_count(&active.buffer, line_start, line_end)
if remove_count == 0 do continue
buffer_delete_range(&active.buffer, line_start, remove_count)
total_removed += remove_count
if line_start < cursor_offset {
cursor_removed_before += min_int(remove_count, cursor_offset - line_start)
}
if line_start < anchor_offset {
anchor_removed_before += min_int(remove_count, anchor_offset - line_start)
}
}
if total_removed == 0 do return false
active.cursor.offset = clamp_int(cursor_offset - cursor_removed_before, 0, buffer_len(&active.buffer))
if has_selection {
active.selection_anchor = clamp_int(anchor_offset - anchor_removed_before, 0, buffer_len(&active.buffer))
} else {
editor_clear_selection(active)
}
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_update_dirty(active)
return true
}
editor_line_outdent_count :: proc(buffer: ^Buffer, line_start, line_end: int) -> int {
if line_start >= line_end do return 0
bytes := buffer_range_bytes(buffer, line_start, min_int(4, line_end - line_start))
defer delete(bytes)
remove_count := 0
for b in bytes {
if b == ' ' && remove_count < 4 {
remove_count += 1
} else if b == '\t' && remove_count == 0 {
return 1
} else {
break
}
}
return remove_count
}
editor_insert_newline_auto_indent :: proc(active: ^Editor_Buffer) {
if active == nil do return
selection_start, selection_end, has_selection := editor_selection_range(active)
if has_selection {
buffer_replace_range(&active.buffer, selection_start, selection_end - selection_start, "\n")
active.cursor.offset = selection_start + 1
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
} else {
cursor_insert_newline_auto_indent(&active.buffer, &active.cursor)
}
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_save_active :: proc(editor: ^Editor) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
text := buffer_bytes(&active.buffer)
defer delete(text)
err := os.write_entire_file(active.path, text[:])
if err != nil {
fmt.println("save failed:", err)
return false
}
active.saved_version = active.buffer.version
active.dirty = false
return true
}
editor_save_all :: proc(editor: ^Editor) -> bool {
ok := true
for &buffer in editor.buffers {
if !buffer.dirty do continue
text := buffer_bytes(&buffer.buffer)
err := os.write_entire_file(buffer.path, text[:])
delete(text)
if err != nil {
fmt.println("save failed:", buffer.path, err)
ok = false
} else {
buffer.saved_version = buffer.buffer.version
buffer.dirty = false
}
}
return ok
}
editor_close_active :: proc(editor: ^Editor) -> bool {
return editor_close_buffer(editor, editor.active)
}
editor_close_buffer :: proc(editor: ^Editor, index: int) -> bool {
if len(editor.buffers) <= 1 {
editor_set_status(editor, "Cannot close the last buffer")
return false
}
if index < 0 || index >= len(editor.buffers) do return false
active := &editor.buffers[index]
if active.dirty {
editor_set_status(editor, "Buffer has unsaved changes; save before closing")
return false
}
editor_buffer_destroy(active)
ordered_remove(&editor.buffers, index)
if editor.active > index {
editor.active -= 1
}
editor.active = clamp_int(editor.active, 0, len(editor.buffers) - 1)
editor_set_status(editor, "Closed buffer")
return true
}
editor_set_status :: proc(editor: ^Editor, message: string) {
delete(editor.status)
editor.status = strings.clone(message)
}
editor_set_active_diagnostics :: proc(editor: ^Editor, diagnostics: []Diagnostic) {
active := editor_active_buffer(editor)
if active == nil do return
editor_buffer_set_diagnostics(active, diagnostics)
}
editor_set_diagnostics_for_path :: proc(editor: ^Editor, path: string, diagnostics: []Diagnostic) -> bool {
for &buffer in editor.buffers {
if buffer.path == path || buffer.daemon_path == path {
editor_buffer_set_diagnostics(&buffer, diagnostics)
if buffer.daemon_path != path {
delete(buffer.daemon_path)
buffer.daemon_path = strings.clone(path)
}
return true
}
}
_, target_name := os.split_path(path)
match_index := -1
match_count := 0
for &buffer, index in editor.buffers {
_, buffer_name := os.split_path(buffer.path)
if buffer_name == target_name {
match_index = index
match_count += 1
}
}
if match_count == 1 {
editor_buffer_set_diagnostics(&editor.buffers[match_index], diagnostics)
delete(editor.buffers[match_index].daemon_path)
editor.buffers[match_index].daemon_path = strings.clone(path)
return true
}
return false
}
editor_clear_all_diagnostics :: proc(editor: ^Editor) {
for &buffer in editor.buffers {
editor_buffer_set_diagnostics(&buffer, []Diagnostic{})
}
}
editor_buffer_set_diagnostics :: proc(buffer: ^Editor_Buffer, diagnostics: []Diagnostic) {
if buffer == nil do return
for diagnostic in buffer.diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
delete(buffer.diagnostics)
buffer.diagnostics = make([dynamic]Diagnostic)
for diagnostic in diagnostics {
append(&buffer.diagnostics, diagnostic)
}
}
editor_diagnostic_on_line :: proc(editor: ^Editor, line: int) -> (diagnostic: Diagnostic, ok: bool) {
active := editor_active_buffer(editor)
if active == nil do return {}, false
for diagnostic in active.diagnostics {
if diagnostic.line == line {
return diagnostic, true
}
}
return {}, false
}
editor_diagnostic_counts :: proc(buffer: ^Editor_Buffer) -> (errors, warnings, infos: int) {
if buffer == nil do return 0, 0, 0
for diagnostic in buffer.diagnostics {
switch diagnostic.severity {
case "error":
errors += 1
case "warning":
warnings += 1
case:
infos += 1
}
}
return
}
editor_print_visible :: proc(editor: ^Editor, first_line, line_count: int) {
active := editor_active_buffer(editor)
if active == nil do return
last_line := min_int(first_line + line_count, buffer_line_count(&active.buffer))
for line := first_line; line < last_line; line += 1 {
bytes := buffer_line_bytes(&active.buffer, line)
fmt.printf("%4d | %s\n", line + 1, string(bytes[:]))
delete(bytes)
}
}
editor_destroy :: proc(editor: ^Editor) {
for &editor_buffer in editor.buffers {
editor_buffer_destroy(&editor_buffer)
}
delete(editor.buffers)
delete(editor.status)
}
editor_buffer_destroy :: proc(editor_buffer: ^Editor_Buffer) {
delete(editor_buffer.path)
delete(editor_buffer.daemon_path)
buffer_destroy(&editor_buffer.buffer)
delete(editor_buffer.original_storage)
for diagnostic in editor_buffer.diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
delete(editor_buffer.diagnostics)
}
min_int :: proc(a, b: int) -> int {
if a < b do return a
return b
}

View file

@ -0,0 +1,581 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
import stbtt "vendor:stb/truetype"
import SDL "vendor:sdl3"
GPU_Vertex :: struct {
pos: [2]f32,
color: [4]f32,
}
GPU_Text_Vertex :: struct {
pos: [2]f32,
uv: [2]f32,
color: [4]f32,
}
GPU_Uniforms :: struct {
viewport: [2]f32,
}
GPU_Renderer :: struct {
available: bool,
device: ^SDL.GPUDevice,
window: ^SDL.Window,
pipeline: ^SDL.GPUGraphicsPipeline,
text_pipeline: ^SDL.GPUGraphicsPipeline,
vertex_shader: ^SDL.GPUShader,
fragment_shader: ^SDL.GPUShader,
text_vertex_shader: ^SDL.GPUShader,
text_fragment_shader: ^SDL.GPUShader,
vertex_buffer: ^SDL.GPUBuffer,
transfer_buffer: ^SDL.GPUTransferBuffer,
text_vertex_buffer: ^SDL.GPUBuffer,
text_transfer_buffer: ^SDL.GPUTransferBuffer,
font_texture: ^SDL.GPUTexture,
font_sampler: ^SDL.GPUSampler,
font_chars: [95]stbtt.bakedchar,
font_advance: f32,
vertices: [dynamic]GPU_Vertex,
text_vertices: [dynamic]GPU_Text_Vertex,
max_vertices: int,
max_text_vertices: int,
width: int,
height: int,
}
GPU_MAX_VERTICES :: 240000
GPU_MAX_TEXT_VERTICES :: 120000
GPU_FONT_ATLAS_SIZE :: 512
GPU_FONT_PIXEL_HEIGHT :: 15.0
GPU_FONT_BASELINE_OFFSET :: 11.0
GPU_FONT_CELL_PADDING :: 1.0
GPU_FONT_PATHS := [?]string{
"client/odin/assets/fonts/EditorMono.ttf",
"client/odin/assets/fonts/NotoSansMono-Regular.ttf",
"/usr/share/fonts/google-noto/NotoSansMono-Regular.ttf",
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf",
"/usr/share/fonts/dejavu/DejaVuSansMono.ttf",
}
gpu_renderer_make :: proc(window: ^SDL.Window) -> GPU_Renderer {
gpu: GPU_Renderer
gpu.window = window
gpu.max_vertices = GPU_MAX_VERTICES
gpu.max_text_vertices = GPU_MAX_TEXT_VERTICES
gpu.width = SDL_WINDOW_WIDTH
gpu.height = SDL_WINDOW_HEIGHT
gpu.vertices = make([dynamic]GPU_Vertex, 0, gpu.max_vertices)
gpu.text_vertices = make([dynamic]GPU_Text_Vertex, 0, gpu.max_text_vertices)
gpu.device = SDL.CreateGPUDevice(SDL.GPUShaderFormat{.SPIRV}, true, nil)
if gpu.device == nil {
fmt.println("SDL GPU device failed, falling back:", SDL.GetError())
return gpu
}
if !SDL.ClaimWindowForGPUDevice(gpu.device, window) {
fmt.println("SDL GPU window claim failed, falling back:", SDL.GetError())
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
gpu.vertex_shader = gpu_create_shader(gpu.device, "client/odin/shaders/compiled/rect.vert.spv", .VERTEX)
gpu.fragment_shader = gpu_create_shader(gpu.device, "client/odin/shaders/compiled/rect.frag.spv", .FRAGMENT)
gpu.text_vertex_shader = gpu_create_shader(gpu.device, "client/odin/shaders/compiled/text.vert.spv", .VERTEX)
gpu.text_fragment_shader = gpu_create_shader(gpu.device, "client/odin/shaders/compiled/text.frag.spv", .FRAGMENT)
if gpu.vertex_shader == nil || gpu.fragment_shader == nil || gpu.text_vertex_shader == nil || gpu.text_fragment_shader == nil {
fmt.println("SDL GPU shaders unavailable; run scripts/compile-shaders.sh")
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
color_format := SDL.GetGPUSwapchainTextureFormat(gpu.device, window)
if color_format == .INVALID {
fmt.println("SDL GPU swapchain format failed, falling back:", SDL.GetError())
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
vb_desc := SDL.GPUVertexBufferDescription{
slot = 0,
pitch = size_of(GPU_Vertex),
input_rate = .VERTEX,
instance_step_rate = 0,
}
attrs := [?]SDL.GPUVertexAttribute{
{location = 0, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(GPU_Vertex, pos))},
{location = 1, buffer_slot = 0, format = .FLOAT4, offset = u32(offset_of(GPU_Vertex, color))},
}
blend := SDL.GPUColorTargetBlendState{
src_color_blendfactor = .SRC_ALPHA,
dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA,
color_blend_op = .ADD,
src_alpha_blendfactor = .ONE,
dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA,
alpha_blend_op = .ADD,
color_write_mask = SDL.GPUColorComponentFlags{.R, .G, .B, .A},
enable_blend = true,
enable_color_write_mask = true,
}
color_target := SDL.GPUColorTargetDescription{format = color_format, blend_state = blend}
pipeline_info := SDL.GPUGraphicsPipelineCreateInfo{
vertex_shader = gpu.vertex_shader,
fragment_shader = gpu.fragment_shader,
vertex_input_state = {
vertex_buffer_descriptions = &vb_desc,
num_vertex_buffers = 1,
vertex_attributes = &attrs[0],
num_vertex_attributes = len(attrs),
},
primitive_type = .TRIANGLELIST,
rasterizer_state = {fill_mode = .FILL, cull_mode = .NONE, front_face = .COUNTER_CLOCKWISE},
multisample_state = {sample_count = ._1},
depth_stencil_state = {},
target_info = {
color_target_descriptions = &color_target,
num_color_targets = 1,
has_depth_stencil_target = false,
},
}
gpu.pipeline = SDL.CreateGPUGraphicsPipeline(gpu.device, pipeline_info)
if gpu.pipeline == nil {
fmt.println("SDL GPU pipeline failed, falling back:", SDL.GetError())
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
text_vb_desc := SDL.GPUVertexBufferDescription{
slot = 0,
pitch = size_of(GPU_Text_Vertex),
input_rate = .VERTEX,
instance_step_rate = 0,
}
text_attrs := [?]SDL.GPUVertexAttribute{
{location = 0, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(GPU_Text_Vertex, pos))},
{location = 1, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(GPU_Text_Vertex, uv))},
{location = 2, buffer_slot = 0, format = .FLOAT4, offset = u32(offset_of(GPU_Text_Vertex, color))},
}
text_pipeline_info := pipeline_info
text_pipeline_info.vertex_shader = gpu.text_vertex_shader
text_pipeline_info.fragment_shader = gpu.text_fragment_shader
text_pipeline_info.vertex_input_state = {
vertex_buffer_descriptions = &text_vb_desc,
num_vertex_buffers = 1,
vertex_attributes = &text_attrs[0],
num_vertex_attributes = len(text_attrs),
}
gpu.text_pipeline = SDL.CreateGPUGraphicsPipeline(gpu.device, text_pipeline_info)
if gpu.text_pipeline == nil {
fmt.println("SDL GPU text pipeline failed, falling back:", SDL.GetError())
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
buffer_size := u32(gpu.max_vertices * size_of(GPU_Vertex))
gpu.vertex_buffer = SDL.CreateGPUBuffer(gpu.device, {usage = {.VERTEX}, size = buffer_size})
gpu.transfer_buffer = SDL.CreateGPUTransferBuffer(gpu.device, {usage = .UPLOAD, size = buffer_size})
text_buffer_size := u32(gpu.max_text_vertices * size_of(GPU_Text_Vertex))
gpu.text_vertex_buffer = SDL.CreateGPUBuffer(gpu.device, {usage = {.VERTEX}, size = text_buffer_size})
gpu.text_transfer_buffer = SDL.CreateGPUTransferBuffer(gpu.device, {usage = .UPLOAD, size = text_buffer_size})
if gpu.vertex_buffer == nil || gpu.transfer_buffer == nil || gpu.text_vertex_buffer == nil || gpu.text_transfer_buffer == nil {
fmt.println("SDL GPU buffers failed, falling back:", SDL.GetError())
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
if !gpu_create_font_atlas(&gpu) {
gpu_renderer_destroy(&gpu)
return GPU_Renderer{}
}
gpu.available = true
return gpu
}
gpu_renderer_destroy :: proc(gpu: ^GPU_Renderer) {
if gpu.device != nil {
_ = SDL.WaitForGPUIdle(gpu.device)
if gpu.window != nil {
SDL.ReleaseWindowFromGPUDevice(gpu.device, gpu.window)
}
if gpu.transfer_buffer != nil do SDL.ReleaseGPUTransferBuffer(gpu.device, gpu.transfer_buffer)
if gpu.text_transfer_buffer != nil do SDL.ReleaseGPUTransferBuffer(gpu.device, gpu.text_transfer_buffer)
if gpu.vertex_buffer != nil do SDL.ReleaseGPUBuffer(gpu.device, gpu.vertex_buffer)
if gpu.text_vertex_buffer != nil do SDL.ReleaseGPUBuffer(gpu.device, gpu.text_vertex_buffer)
if gpu.font_sampler != nil do SDL.ReleaseGPUSampler(gpu.device, gpu.font_sampler)
if gpu.font_texture != nil do SDL.ReleaseGPUTexture(gpu.device, gpu.font_texture)
if gpu.pipeline != nil do SDL.ReleaseGPUGraphicsPipeline(gpu.device, gpu.pipeline)
if gpu.text_pipeline != nil do SDL.ReleaseGPUGraphicsPipeline(gpu.device, gpu.text_pipeline)
if gpu.fragment_shader != nil do SDL.ReleaseGPUShader(gpu.device, gpu.fragment_shader)
if gpu.vertex_shader != nil do SDL.ReleaseGPUShader(gpu.device, gpu.vertex_shader)
if gpu.text_fragment_shader != nil do SDL.ReleaseGPUShader(gpu.device, gpu.text_fragment_shader)
if gpu.text_vertex_shader != nil do SDL.ReleaseGPUShader(gpu.device, gpu.text_vertex_shader)
SDL.DestroyGPUDevice(gpu.device)
}
delete(gpu.vertices)
delete(gpu.text_vertices)
gpu^ = {}
}
gpu_create_shader :: proc(device: ^SDL.GPUDevice, path: string, stage: SDL.GPUShaderStage) -> ^SDL.GPUShader {
bytes, err := os.read_entire_file(path, context.allocator)
if err != nil {
fmt.println("read shader failed:", path, err)
return nil
}
defer delete(bytes)
shader := SDL.CreateGPUShader(device, {
code_size = len(bytes),
code = raw_data(bytes),
entrypoint = "main",
format = {.SPIRV},
stage = stage,
num_uniform_buffers = 1 if stage == .VERTEX else 0,
num_samplers = 1 if stage == .FRAGMENT && strings_has_suffix(path, "text.frag.spv") else 0,
})
if shader == nil {
fmt.println("CreateGPUShader failed:", path, SDL.GetError())
}
return shader
}
gpu_begin :: proc(gpu: ^GPU_Renderer) {
clear(&gpu.vertices)
clear(&gpu.text_vertices)
w, h: i32
if SDL.GetWindowSize(gpu.window, &w, &h) {
gpu.width = int(w)
gpu.height = int(h)
}
}
gpu_rect :: proc(gpu: ^GPU_Renderer, x, y, w, h: f32, r, g, b, a: u8) {
if w <= 0 || h <= 0 do return
c := color_f32(r, g, b, a)
gpu_push_quad(gpu, x, y, x + w, y + h, c)
}
gpu_line :: proc(gpu: ^GPU_Renderer, x1, y1, x2, y2: f32, r, g, b, a: u8) {
if abs_f32(x2 - x1) < 1 {
x := min_f32(x1, x2)
y := min_f32(y1, y2)
gpu_rect(gpu, x, y, 1, abs_f32(y2 - y1), r, g, b, a)
} else if abs_f32(y2 - y1) < 1 {
x := min_f32(x1, x2)
y := min_f32(y1, y2)
gpu_rect(gpu, x, y, abs_f32(x2 - x1), 1, r, g, b, a)
} else {
gpu_rect(gpu, x1, y1, max_f32(abs_f32(x2 - x1), 1), 1, r, g, b, a)
}
}
gpu_text :: proc(gpu: ^GPU_Renderer, x, y: f32, text: string, r, g, b, a: u8) {
if len(text) == 0 do return
xpos := round_f32(x)
ypos := y + GPU_FONT_BASELINE_OFFSET
color := color_f32(r, g, b, a)
for raw_ch in transmute([]u8)text {
ch := raw_ch
if ch == '\n' {
xpos = x
ypos += SDL_LINE_HEIGHT
continue
}
if ch < 32 || ch > 126 {
ch = '?'
}
if ch == ' ' {
xpos += gpu.font_advance
continue
}
glyph := gpu.font_chars[ch - 32]
glyph_w := f32(glyph.x1 - glyph.x0)
glyph_h := f32(glyph.y1 - glyph.y0)
x0 := round_f32(xpos + glyph.xoff)
y0 := round_f32(ypos + glyph.yoff)
x1 := x0 + glyph_w
y1 := y0 + glyph_h
s0 := f32(glyph.x0) / GPU_FONT_ATLAS_SIZE
t0 := f32(glyph.y0) / GPU_FONT_ATLAS_SIZE
s1 := f32(glyph.x1) / GPU_FONT_ATLAS_SIZE
t1 := f32(glyph.y1) / GPU_FONT_ATLAS_SIZE
gpu_push_text_quad(gpu, x0, y0, x1, y1, s0, t0, s1, t1, color)
xpos += gpu.font_advance
}
}
gpu_text_limited :: proc(gpu: ^GPU_Renderer, x, y: f32, text: string, max_chars: int, r, g, b, a: u8) {
if max_chars <= 0 do return
if len(text) <= max_chars {
gpu_text(gpu, x, y, text, r, g, b, a)
} else if max_chars <= 3 {
gpu_text(gpu, x, y, text[:max_chars], r, g, b, a)
} else {
clipped := fmt.tprintf("%s...", text[:max_chars - 3])
gpu_text(gpu, x, y, clipped, r, g, b, a)
}
}
gpu_text_width :: proc(text: string) -> int {
if len(text) == 0 do return 0
width: f32 = 0
max_width: f32 = 0
for raw_ch in transmute([]u8)text {
ch := raw_ch
if ch == '\n' {
if width > max_width do max_width = width
width = 0
continue
}
if ch < 32 || ch > 126 do ch = '?'
width += gpu_global_font_advance(ch)
}
if width > max_width do max_width = width
return int(max_width + 0.5)
}
gpu_font_text_advance :: proc(gpu: ^GPU_Renderer, text: string) -> f32 {
if len(text) == 0 do return 0
start_x: f32 = 0
x: f32 = start_x
y: f32 = GPU_FONT_BASELINE_OFFSET
max_width: f32 = 0
for raw_ch in transmute([]u8)text {
ch := raw_ch
if ch == '\n' {
if x - start_x > max_width do max_width = x - start_x
x = start_x
y += SDL_LINE_HEIGHT
continue
}
if ch < 32 || ch > 126 do ch = '?'
quad: stbtt.aligned_quad
x += gpu.font_advance
}
if x - start_x > max_width do max_width = x - start_x
return max_width
}
gpu_font_text_width :: proc(gpu: ^GPU_Renderer, text: string) -> int {
return int(gpu_font_text_advance(gpu, text) + 0.5)
}
gpu_present :: proc(gpu: ^GPU_Renderer) {
if !gpu.available do return
command := SDL.AcquireGPUCommandBuffer(gpu.device)
if command == nil do return
texture: ^SDL.GPUTexture
width, height: u32
if !SDL.WaitAndAcquireGPUSwapchainTexture(command, gpu.window, &texture, &width, &height) || texture == nil {
_ = SDL.CancelGPUCommandBuffer(command)
return
}
vertex_count := len(gpu.vertices)
text_vertex_count := len(gpu.text_vertices)
if vertex_count > 0 {
size := vertex_count * size_of(GPU_Vertex)
mapped := SDL.MapGPUTransferBuffer(gpu.device, gpu.transfer_buffer, true)
if mapped != nil {
mem.copy(transmute([^]u8)mapped, raw_data(gpu.vertices[:]), size)
SDL.UnmapGPUTransferBuffer(gpu.device, gpu.transfer_buffer)
copy_pass := SDL.BeginGPUCopyPass(command)
SDL.UploadToGPUBuffer(copy_pass, {transfer_buffer = gpu.transfer_buffer}, {buffer = gpu.vertex_buffer, size = u32(size)}, true)
SDL.EndGPUCopyPass(copy_pass)
}
}
if text_vertex_count > 0 {
size := text_vertex_count * size_of(GPU_Text_Vertex)
mapped := SDL.MapGPUTransferBuffer(gpu.device, gpu.text_transfer_buffer, true)
if mapped != nil {
mem.copy(transmute([^]u8)mapped, raw_data(gpu.text_vertices[:]), size)
SDL.UnmapGPUTransferBuffer(gpu.device, gpu.text_transfer_buffer)
copy_pass := SDL.BeginGPUCopyPass(command)
SDL.UploadToGPUBuffer(copy_pass, {transfer_buffer = gpu.text_transfer_buffer}, {buffer = gpu.text_vertex_buffer, size = u32(size)}, true)
SDL.EndGPUCopyPass(copy_pass)
}
}
target := SDL.GPUColorTargetInfo{
texture = texture,
clear_color = SDL.FColor{15.0 / 255.0, 17.0 / 255.0, 22.0 / 255.0, 1},
load_op = .CLEAR,
store_op = .STORE,
}
pass := SDL.BeginGPURenderPass(command, &target, 1, nil)
if vertex_count > 0 {
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1)
SDL.DrawGPUPrimitives(pass, u32(vertex_count), 1, 0, 0)
}
if text_vertex_count > 0 {
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
binding := SDL.GPUTextureSamplerBinding{texture = gpu.font_texture, sampler = gpu.font_sampler}
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
SDL.BindGPUGraphicsPipeline(pass, gpu.text_pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1)
SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1)
SDL.DrawGPUPrimitives(pass, u32(text_vertex_count), 1, 0, 0)
}
SDL.EndGPURenderPass(pass)
_ = SDL.SubmitGPUCommandBuffer(command)
}
gpu_create_font_atlas :: proc(gpu: ^GPU_Renderer) -> bool {
font_bytes, font_path, ok := gpu_read_font_file()
if !ok {
fmt.println("read font failed: no configured monospace font found")
return false
}
defer delete(font_bytes)
atlas_size := GPU_FONT_ATLAS_SIZE * GPU_FONT_ATLAS_SIZE
atlas := make([]u8, atlas_size)
defer delete(atlas)
baked := stbtt.BakeFontBitmap(raw_data(font_bytes), 0, GPU_FONT_PIXEL_HEIGHT, raw_data(atlas), GPU_FONT_ATLAS_SIZE, GPU_FONT_ATLAS_SIZE, 32, len(gpu.font_chars), &gpu.font_chars[0])
if baked <= 0 {
fmt.println("font bake failed:", font_path)
return false
}
widest_advance: f32 = 0
for char in gpu.font_chars {
glyph_width := char.xoff + f32(char.x1 - char.x0)
widest_advance = max_f32(widest_advance, max_f32(char.xadvance, glyph_width))
}
gpu.font_advance = max_f32(ceil_f32(widest_advance + GPU_FONT_CELL_PADDING), 1)
gpu.font_texture = SDL.CreateGPUTexture(gpu.device, {
type = .D2,
format = .R8_UNORM,
usage = {.SAMPLER},
width = GPU_FONT_ATLAS_SIZE,
height = GPU_FONT_ATLAS_SIZE,
layer_count_or_depth = 1,
num_levels = 1,
sample_count = ._1,
})
gpu.font_sampler = SDL.CreateGPUSampler(gpu.device, {
min_filter = .NEAREST,
mag_filter = .NEAREST,
mipmap_mode = .NEAREST,
address_mode_u = .CLAMP_TO_EDGE,
address_mode_v = .CLAMP_TO_EDGE,
address_mode_w = .CLAMP_TO_EDGE,
})
font_transfer := SDL.CreateGPUTransferBuffer(gpu.device, {usage = .UPLOAD, size = u32(atlas_size)})
if gpu.font_texture == nil || gpu.font_sampler == nil || font_transfer == nil {
fmt.println("font GPU resources failed:", SDL.GetError())
if font_transfer != nil do SDL.ReleaseGPUTransferBuffer(gpu.device, font_transfer)
return false
}
defer SDL.ReleaseGPUTransferBuffer(gpu.device, font_transfer)
mapped := SDL.MapGPUTransferBuffer(gpu.device, font_transfer, false)
if mapped == nil {
fmt.println("font transfer map failed:", SDL.GetError())
return false
}
mem.copy(transmute([^]u8)mapped, raw_data(atlas), atlas_size)
SDL.UnmapGPUTransferBuffer(gpu.device, font_transfer)
command := SDL.AcquireGPUCommandBuffer(gpu.device)
copy_pass := SDL.BeginGPUCopyPass(command)
SDL.UploadToGPUTexture(copy_pass, {
transfer_buffer = font_transfer,
pixels_per_row = GPU_FONT_ATLAS_SIZE,
rows_per_layer = GPU_FONT_ATLAS_SIZE,
}, {
texture = gpu.font_texture,
w = GPU_FONT_ATLAS_SIZE,
h = GPU_FONT_ATLAS_SIZE,
d = 1,
}, false)
SDL.EndGPUCopyPass(copy_pass)
return SDL.SubmitGPUCommandBuffer(command)
}
gpu_read_font_file :: proc() -> ([]u8, string, bool) {
for path in GPU_FONT_PATHS {
bytes, err := os.read_entire_file(path, context.allocator)
if err == nil && len(bytes) > 0 {
return bytes, path, true
}
if err == nil {
delete(bytes)
}
}
return nil, "", false
}
gpu_push_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1: f32, c: [4]f32) {
if len(gpu.vertices) + 6 > gpu.max_vertices do return
append(&gpu.vertices, GPU_Vertex{{x0, y0}, c})
append(&gpu.vertices, GPU_Vertex{{x1, y0}, c})
append(&gpu.vertices, GPU_Vertex{{x0, y1}, c})
append(&gpu.vertices, GPU_Vertex{{x0, y1}, c})
append(&gpu.vertices, GPU_Vertex{{x1, y0}, c})
append(&gpu.vertices, GPU_Vertex{{x1, y1}, c})
}
gpu_push_text_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1, s0, t0, s1, t1: f32, c: [4]f32) {
if len(gpu.text_vertices) + 6 > gpu.max_text_vertices do return
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y0}, {s0, t0}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y0}, {s1, t0}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y1}, {s0, t1}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y1}, {s0, t1}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y0}, {s1, t0}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y1}, {s1, t1}, c})
}
gpu_global_font_advance :: proc(ch: u8) -> f32 {
return 8
}
strings_has_suffix :: proc(s, suffix: string) -> bool {
if len(suffix) > len(s) do return false
return s[len(s) - len(suffix):] == suffix
}
color_f32 :: proc(r, g, b, a: u8) -> [4]f32 {
return {f32(r) / 255.0, f32(g) / 255.0, f32(b) / 255.0, f32(a) / 255.0}
}
abs_f32 :: proc(v: f32) -> f32 {
if v < 0 do return -v
return v
}
min_f32 :: proc(a, b: f32) -> f32 {
if a < b do return a
return b
}
max_f32 :: proc(a, b: f32) -> f32 {
if a > b do return a
return b
}
round_f32 :: proc(v: f32) -> f32 {
if v >= 0 do return f32(int(v + 0.5))
return f32(int(v - 0.5))
}
ceil_f32 :: proc(v: f32) -> f32 {
i := int(v)
if f32(i) < v do return f32(i + 1)
return f32(i)
}

184
client/odin/main.odin Normal file
View file

@ -0,0 +1,184 @@
package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strconv"
main :: proc() {
args := os.args
if len(args) < 2 {
fmt.println("usage: odin run client/odin -- <port> [workspace]")
fmt.println(" or: odin run client/odin -- --sdl [workspace] [port] [file]")
return
}
if args[1] == "--sdl" {
workspace := "."
if len(args) >= 3 {
workspace = args[2]
}
port := 0
file := ""
if len(args) >= 4 {
parsed_port, parsed_ok := strconv.parse_int(args[3])
if parsed_ok {
port = int(parsed_port)
} else {
file = args[3]
}
}
if len(args) >= 5 {
file = args[4]
}
run_sdl_editor(workspace, port, file)
return
}
port, ok := strconv.parse_int(args[1])
if !ok {
fmt.println("invalid port")
return
}
workspace := "."
if len(args) >= 3 {
workspace = args[2]
}
run_buffer_smoke()
editor := run_editor_smoke(workspace)
defer editor_destroy(&editor)
socket, err := net.dial_tcp("127.0.0.1", int(port))
if err != nil {
fmt.println("connect failed:", err)
return
}
defer net.close(socket)
send_line(socket, "{\"id\":1,\"method\":\"ping\",\"params\":{}}\n")
read_messages(socket, 1)
open_workspace := fmt.tprintf("{{\"id\":2,\"method\":\"workspace/open\",\"params\":{{\"root\":%q}}}}\n", workspace)
send_line(socket, open_workspace)
read_messages(socket, 3)
send_line(socket, "{\"id\":3,\"method\":\"gradle/tasks\",\"params\":{}}\n")
read_messages(socket, 1)
active := editor_active_buffer(&editor)
diagnostics_path := active.path
active_text := editor_active_text(&editor)
defer delete(active_text)
text_change_request := fmt.tprintf("{{\"id\":4,\"method\":\"text/change\",\"params\":{{\"path\":%q,\"version\":1,\"text\":%q}}}}\n", diagnostics_path, string(active_text[:]))
send_line(socket, text_change_request)
read_messages(socket, 2)
diagnostics_request := fmt.tprintf("{{\"id\":5,\"method\":\"kotlin/diagnostics\",\"params\":{{\"path\":%q}}}}\n", diagnostics_path)
send_line(socket, diagnostics_request)
read_messages(socket, 1)
completion_request := fmt.tprintf("{{\"id\":6,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%q,\"line\":1,\"column\":1}}}}\n", diagnostics_path)
send_line(socket, completion_request)
read_messages(socket, 1)
hover_request := fmt.tprintf("{{\"id\":7,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%q,\"line\":1,\"column\":1}}}}\n", diagnostics_path)
send_line(socket, hover_request)
read_messages(socket, 1)
definition_request := fmt.tprintf("{{\"id\":8,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5}}}}\n", diagnostics_path)
send_line(socket, definition_request)
read_messages(socket, 1)
references_request := fmt.tprintf("{{\"id\":9,\"method\":\"kotlin/references\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5}}}}\n", diagnostics_path)
send_line(socket, references_request)
read_messages(socket, 1)
rename_request := fmt.tprintf("{{\"id\":10,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5,\"newName\":\"renamedBroken\"}}}}\n", diagnostics_path)
send_line(socket, rename_request)
read_messages(socket, 1)
}
run_editor_smoke :: proc(workspace: string) -> Editor {
editor := Editor{}
path := fmt.tprintf("%s/src/main/kotlin/dev/nativeeditor/daemon/Main.kt", workspace)
if !editor_open_file(&editor, path) {
return editor
}
fmt.println("editor-visible-before:")
editor_print_visible(&editor, 0, 2)
editor_replace_active_text(&editor, "fun broken( {")
fmt.println("editor-visible-after:")
editor_print_visible(&editor, 0, 2)
return editor
}
run_buffer_smoke :: proc() {
buffer := buffer_make("hello world\nsecond line\n")
defer buffer_destroy(&buffer)
buffer_insert(&buffer, 6, "native ")
buffer_delete_range(&buffer, 0, 6)
cursor := Cursor{}
cursor_move_to_line_col(&buffer, &cursor, 1, 6)
cursor_insert(&buffer, &cursor, " edited")
cursor_backspace(&buffer, &cursor)
_ = buffer_undo(&buffer, &cursor)
_ = buffer_redo(&buffer, &cursor)
cursor_move_vertical(&buffer, &cursor, -1)
line, col := buffer_offset_to_line_col(&buffer, cursor.offset)
second_line := buffer_line_bytes(&buffer, 1)
defer delete(second_line)
text := buffer_bytes(&buffer)
defer delete(text)
fmt.printf("buffer-smoke: %s", string(text[:]))
fmt.printf("buffer-lines: %d\n", buffer_line_count(&buffer))
fmt.printf("buffer-line-1: %s\n", string(second_line[:]))
fmt.printf("buffer-cursor: %d:%d\n", line, col)
}
send_line :: proc(socket: net.TCP_Socket, line: string) {
bytes := transmute([]byte)line
_, err := net.send_tcp(socket, bytes)
if err != nil {
fmt.println("send failed:", err)
}
}
read_messages :: proc(socket: net.TCP_Socket, expected: int) {
seen := 0
for seen < expected {
seen += read_some(socket)
}
}
read_some :: proc(socket: net.TCP_Socket) -> int {
buf: [4096]byte
n, err := net.recv_tcp(socket, buf[:])
if err != nil {
fmt.println("recv failed:", err)
return 0
}
if n == 0 {
fmt.println("connection closed")
return 0
}
lines := 0
for b in buf[:n] {
if b == '\n' {
lines += 1
}
}
fmt.print(string(buf[:n]))
return lines
}

3847
client/odin/sdl_app.odin Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
#version 450
layout(location = 0) in vec4 v_color;
layout(location = 0) out vec4 out_color;
void main() {
out_color = v_color;
}

View file

@ -0,0 +1,17 @@
#version 450
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec4 a_color;
layout(set = 1, binding = 0) uniform VertexUniforms {
vec2 u_viewport;
};
layout(location = 0) out vec4 v_color;
void main() {
vec2 ndc = vec2((a_pos.x / u_viewport.x) * 2.0 - 1.0,
1.0 - (a_pos.y / u_viewport.y) * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
v_color = a_color;
}

View file

@ -0,0 +1,12 @@
#version 450
layout(location = 0) in vec2 v_uv;
layout(location = 1) in vec4 v_color;
layout(location = 0) out vec4 out_color;
layout(set = 2, binding = 0) uniform sampler2D u_font;
void main() {
float alpha = texture(u_font, v_uv).r;
out_color = vec4(v_color.rgb, v_color.a * alpha);
}

View file

@ -0,0 +1,20 @@
#version 450
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec2 a_uv;
layout(location = 2) in vec4 a_color;
layout(set = 1, binding = 0) uniform VertexUniforms {
vec2 u_viewport;
};
layout(location = 0) out vec2 v_uv;
layout(location = 1) out vec4 v_color;
void main() {
vec2 ndc = vec2((a_pos.x / u_viewport.x) * 2.0 - 1.0,
1.0 - (a_pos.y / u_viewport.y) * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
v_uv = a_uv;
v_color = a_color;
}

26
daemon/build.gradle.kts Normal file
View file

@ -0,0 +1,26 @@
plugins {
kotlin("jvm") version "2.2.21"
application
}
import org.gradle.api.file.DuplicatesStrategy
group = "dev.nativeeditor"
version = "0.1.0"
kotlin {
jvmToolchain(21)
}
dependencies {
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21")
}
application {
mainClass.set("dev.nativeeditor.daemon.MainKt")
}
tasks.withType<AbstractArchiveTask>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

View file

@ -0,0 +1,15 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "native-editor-daemon"

File diff suppressed because it is too large Load diff

40
docs/shader-toolchain.md Normal file
View file

@ -0,0 +1,40 @@
# SDL3 GPU Shader Toolchain
SDL3 GPU accepts different shader formats depending on the selected backend:
- Vulkan: SPIR-V
- D3D12: DXBC or DXIL
- Metal: MSL or metallib
For this Linux-first prototype, use GLSL compiled to SPIR-V with `glslc` from shaderc.
```sh
scripts/compile-shaders.sh
```
Outputs are written to `client/odin/shaders/compiled/` and intentionally ignored by git.
Fedora install:
```sh
sudo dnf install glslc
```
Cross-platform path later:
- Use SDL_shadercross for offline conversion from SPIR-V or HLSL to backend-specific shader formats.
- Keep source shaders in git.
- Generate SPIR-V/DXIL/MSL in CI or release packaging.
- At runtime, choose the shader blob matching `SDL.GetGPUDeviceDriver(device)` or `SDL.GetGPUShaderFormats(device)`.
Current recommendation:
- Build Linux/Vulkan first with checked-in GLSL sources and generated SPIR-V as build artifacts.
- Add SDL_shadercross only when Windows/macOS backends matter.
Current renderer state:
- The editor loads `rect.vert.spv` and `rect.frag.spv` for the direct SDL3 GPU path.
- The editor loads `text.vert.spv` and `text.frag.spv` for font-atlas text.
- Text is rendered from a baked `stb_truetype` atlas using `/usr/share/fonts/google-noto/NotoSansMono-Regular.ttf`.
- A later portability step should bundle a project font or add configurable font discovery.

208
protocol.md Normal file
View file

@ -0,0 +1,208 @@
# TCP Protocol
Transport: UTF-8 JSON objects separated by `\n`.
File paths in text and intelligence requests are normalized to absolute daemon-side paths before being used as document keys.
## Request
```json
{"id":1,"method":"workspace/open","params":{"root":"/home/me/project"}}
```
## Response
```json
{"id":1,"ok":true,"result":{"root":"/home/me/project","gradle":true,"modules":[],"sourceRoots":[],"tasks":[]}}
```
## Error
```json
{"id":1,"ok":false,"error":{"code":"UNKNOWN_METHOD","message":"No handler for method"}}
```
## Event
```json
{"event":"workspace/indexing","params":{"state":"started"}}
```
Diagnostics are also published asynchronously after `text/open` and `text/change`:
```json
{"event":"diagnostics/publish","params":{"path":"/home/me/project/src/main/kotlin/App.kt","version":7,"diagnostics":[]}}
```
## Current Methods
The `kotlin/*` names are historical protocol names. Diagnostics and heuristic intelligence currently also handle `.java` files where noted.
- `ping`
- `workspace/open`
- `workspace/close`
- `text/open`
- `text/change`
- `text/close`
- `kotlin/diagnostics`
- `kotlin/completion`
- `kotlin/definition`
- `kotlin/references`
- `kotlin/rename`
- `kotlin/hover`
- `gradle/tasks`
- `gradle/run`
## `workspace/open` Result
Module `sourceRoots` and `testSourceRoots` include Gradle IdeaModel roots plus common generated Kotlin/Java source directories under `build/generated/` when those directories already exist.
```json
{
"root": "/home/me/project",
"gradle": true,
"modules": [
{
"name": "app",
"gradlePath": ":app",
"directory": "/home/me/project/app",
"sourceRoots": [],
"testSourceRoots": [],
"resourceRoots": [],
"testResourceRoots": []
}
],
"sourceRoots": [],
"tasks": []
}
```
## `gradle/run` Request
```json
{"id":2,"method":"gradle/run","params":{"task":":app:test"}}
```
## `kotlin/diagnostics` Request
Supports Kotlin and Java files. Kotlin diagnostics use the embedded Kotlin compiler; Java diagnostics use the JDK compiler. Main files use main source roots, while test files use main plus test source roots. Existing module output directories under `build/classes/` and `build/resources/` are added to the compiler classpath.
```json
{"id":3,"method":"kotlin/diagnostics","params":{"path":"/home/me/project/src/main/kotlin/App.kt"}}
```
## `kotlin/diagnostics` Result
```json
{
"diagnostics": [
{
"severity": "error",
"message": "Syntax error",
"path": "/home/me/project/src/main/kotlin/App.kt",
"line": 1,
"column": 12
}
]
}
```
## `kotlin/completion` Request
Returns prefix-filtered Kotlin/Java keywords plus heuristic declarations and identifiers from the current buffer and workspace.
```json
{"id":4,"method":"kotlin/completion","params":{"path":"/home/me/project/src/main/kotlin/App.kt","line":10,"column":8}}
```
`line` and `column` are 1-based.
## `kotlin/completion` Result
```json
{
"items": [
{"label": "fun", "kind": "keyword"}
]
}
```
## `kotlin/hover` Request
Returns keyword or heuristic Kotlin/Java declaration information for the identifier at the requested position.
```json
{"id":5,"method":"kotlin/hover","params":{"path":"/home/me/project/src/main/kotlin/App.kt","line":10,"column":8}}
```
`line` and `column` are 1-based.
## `kotlin/hover` Result
```json
{"contents":"Kotlin keyword `fun`"}
```
`contents` is `null` when no hover information is available.
## `kotlin/definition` Request
Returns heuristic Kotlin/Java declaration locations for the identifier at the requested position.
```json
{"id":6,"method":"kotlin/definition","params":{"path":"/home/me/project/src/main/kotlin/App.kt","line":10,"column":8}}
```
`line` and `column` are 1-based.
## `kotlin/definition` Result
```json
{
"locations": [
{"path": "/home/me/project/src/main/kotlin/App.kt", "line": 4, "column": 7}
]
}
```
## `kotlin/references` Request
Returns heuristic Kotlin/Java text references for the identifier at the requested position.
```json
{"id":7,"method":"kotlin/references","params":{"path":"/home/me/project/src/main/kotlin/App.kt","line":10,"column":8}}
```
`line` and `column` are 1-based.
## `kotlin/references` Result
```json
{
"locations": [
{"path": "/home/me/project/src/main/kotlin/App.kt", "line": 4, "column": 7}
]
}
```
## `kotlin/rename` Request
```json
{"id":8,"method":"kotlin/rename","params":{"path":"/home/me/project/src/main/kotlin/App.kt","line":10,"column":8,"newName":"newSymbol"}}
```
This is currently preview-only. It does not modify files.
`newName` must be a valid identifier and must not be a Kotlin or Java keyword.
## `kotlin/rename` Result
```json
{
"applied": false,
"oldName": "oldSymbol",
"newName": "newSymbol",
"edits": [
{"path": "/home/me/project/src/main/kotlin/App.kt", "line": 4, "column": 7, "oldText": "oldSymbol", "newText": "newSymbol"}
]
}
```

73
scripts/autopilot-opencode.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
# Repeatedly asks opencode to continue autonomous project work.
#
# Configuration:
# OPENCODE_BIN Command to run. Default: opencode
# OPENCODE_PROJECT Project/workspace path. Default: current directory
# OPENCODE_SESSION Session id to continue. If unset, uses --continue
# OPENCODE_MODEL Optional model, e.g. openai/gpt-5.5
# OPENCODE_AGENT Optional agent name
# OPENCODE_EXTRA_ARGS Optional extra args inserted before the message, shell-split
# AUTOPILOT_MESSAGE Message sent each iteration. Default: keep working prompt below
# AUTOPILOT_INTERVAL Seconds to sleep between successful iterations. Default: 10
# AUTOPILOT_MAX_RUNS Max iterations. 0 means forever. Default: 0
# AUTOPILOT_STOP_FILE Stop when this file exists. Default: .opencode-autopilot-stop
# AUTOPILOT_ALLOW_PERMISSIONS Automatically approve tool permissions. Default: 1
OPENCODE_BIN=${OPENCODE_BIN:-opencode}
OPENCODE_PROJECT=${OPENCODE_PROJECT:-$(pwd)}
AUTOPILOT_INTERVAL=${AUTOPILOT_INTERVAL:-10}
AUTOPILOT_MAX_RUNS=${AUTOPILOT_MAX_RUNS:-0}
AUTOPILOT_STOP_FILE=${AUTOPILOT_STOP_FILE:-.opencode-autopilot-stop}
AUTOPILOT_ALLOW_PERMISSIONS=${AUTOPILOT_ALLOW_PERMISSIONS:-1}
AUTOPILOT_MESSAGE=${AUTOPILOT_MESSAGE:-Continue working autonomously in this configured session. Keep working on the current task list. If every task is finished, inspect the project, decide the next highest-value tasks, add or update the task list, implement them, verify them, and continue until blocked. Do not stop just because one task is complete; stop only for a real blocker that requires user input.}
run_count=0
while true; do
if [[ -e "$AUTOPILOT_STOP_FILE" ]]; then
printf 'stop file exists: %s\n' "$AUTOPILOT_STOP_FILE"
exit 0
fi
if [[ "$AUTOPILOT_MAX_RUNS" != "0" && "$run_count" -ge "$AUTOPILOT_MAX_RUNS" ]]; then
printf 'reached AUTOPILOT_MAX_RUNS=%s\n' "$AUTOPILOT_MAX_RUNS"
exit 0
fi
args=(run --dir "$OPENCODE_PROJECT")
if [[ "$AUTOPILOT_ALLOW_PERMISSIONS" != "0" ]]; then
args+=(--dangerously-skip-permissions)
fi
if [[ -n "${OPENCODE_SESSION:-}" ]]; then
args+=(--session "$OPENCODE_SESSION")
else
args+=(--continue)
fi
if [[ -n "${OPENCODE_MODEL:-}" ]]; then
args+=(--model "$OPENCODE_MODEL")
fi
if [[ -n "${OPENCODE_AGENT:-}" ]]; then
args+=(--agent "$OPENCODE_AGENT")
fi
if [[ -n "${OPENCODE_EXTRA_ARGS:-}" ]]; then
# shellcheck disable=SC2206
extra_args=($OPENCODE_EXTRA_ARGS)
args+=("${extra_args[@]}")
fi
args+=("$AUTOPILOT_MESSAGE")
run_count=$((run_count + 1))
printf '[%s] autopilot iteration %d\n' "$(date -Is)" "$run_count"
"$OPENCODE_BIN" "${args[@]}"
sleep "$AUTOPILOT_INTERVAL"
done

20
scripts/compile-shaders.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$ROOT/client/odin/shaders"
OUT="$SRC/compiled"
if ! command -v glslc >/dev/null 2>&1; then
printf 'error: glslc not found. Install shaderc/glslc. On Fedora: sudo dnf install glslc\n' >&2
exit 1
fi
mkdir -p "$OUT"
glslc -fshader-stage=vert "$SRC/rect.vert.glsl" -o "$OUT/rect.vert.spv"
glslc -fshader-stage=frag "$SRC/rect.frag.glsl" -o "$OUT/rect.frag.spv"
glslc -fshader-stage=vert "$SRC/text.vert.glsl" -o "$OUT/text.vert.spv"
glslc -fshader-stage=frag "$SRC/text.frag.glsl" -o "$OUT/text.frag.spv"
printf 'compiled SDL3 GPU shaders to %s\n' "$OUT"