commit 4a15350860726f5866ffb0451286ff0a740a20b9 Author: pavel Date: Fri Jul 3 08:37:41 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afcc94f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0fe970 --- /dev/null +++ b/README.md @@ -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 /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= 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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..60c25bc --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/client/odin/assets/fonts/NotoSansMono-Regular.ttf b/client/odin/assets/fonts/NotoSansMono-Regular.ttf new file mode 100644 index 0000000..159ca4b Binary files /dev/null and b/client/odin/assets/fonts/NotoSansMono-Regular.ttf differ diff --git a/client/odin/buffer.odin b/client/odin/buffer.odin new file mode 100644 index 0000000..1fb597b --- /dev/null +++ b/client/odin/buffer.odin @@ -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 +} diff --git a/client/odin/daemon_client.odin b/client/odin/daemon_client.odin new file mode 100644 index 0000000..ae5d029 --- /dev/null +++ b/client/odin/daemon_client.odin @@ -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 +} diff --git a/client/odin/editor.odin b/client/odin/editor.odin new file mode 100644 index 0000000..3ad1dea --- /dev/null +++ b/client/odin/editor.odin @@ -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 +} diff --git a/client/odin/gpu_renderer.odin b/client/odin/gpu_renderer.odin new file mode 100644 index 0000000..6a4c613 --- /dev/null +++ b/client/odin/gpu_renderer.odin @@ -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) +} diff --git a/client/odin/main.odin b/client/odin/main.odin new file mode 100644 index 0000000..13c420a --- /dev/null +++ b/client/odin/main.odin @@ -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 -- [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 +} diff --git a/client/odin/sdl_app.odin b/client/odin/sdl_app.odin new file mode 100644 index 0000000..3c61529 --- /dev/null +++ b/client/odin/sdl_app.odin @@ -0,0 +1,3847 @@ +package main + +import "core:c" +import "core:fmt" +import "core:os" +import "base:runtime" +import "core:strings" +import "core:time" +import json "core:encoding/json" +import SDL "vendor:sdl3" + +SDL_View :: struct { + first_line: int, + tree_first: int, + mouse_selecting: bool, + tab_dragging: bool, + tab_drag_index: int, + resizing_panel: Resize_Panel, + show_metrics: bool, + window_width: int, + window_height: int, + sidebar_width: int, + right_sidebar_width: int, + explorer_visible: bool, + gradle_sidebar_visible: bool, + cached_workspace: string, + saved_active_file: int, + saved_open_files: [dynamic]Saved_Open_File, +} + +Saved_Open_File :: struct { + path: string, + cursor: int, +} + +Resize_Panel :: enum { + None, + Left_Sidebar, + Right_Sidebar, +} + +Project_File :: struct { + path: string, + label: string, + depth: int, + is_dir: bool, +} + +Project_Tree :: struct { + files: [dynamic]Project_File, +} + +Completion_Popup :: struct { + open: bool, + selected: int, + line: int, + column: int, + pending_id: int, + items: [dynamic]Completion_Item, +} + +Completion_Item :: struct { + label: string, + kind: string, +} + +Hover_Tooltip :: struct { + open: bool, + pending_id: int, + line: int, + column: int, + contents: string, +} + +Definition_Jump :: struct { + pending_id: int, + origin_path: string, + origin_offset: int, +} + +Nav_Location :: struct { + path: string, + offset: int, +} + +Navigation_History :: struct { + back: [dynamic]Nav_Location, + forward: [dynamic]Nav_Location, +} + +Reference_Item :: struct { + path: string, + line: int, + column: int, + label: string, +} + +References_Panel :: struct { + open: bool, + pending_id: int, + selected: int, + items: [dynamic]Reference_Item, +} + +Rename_Panel :: struct { + open: bool, + pending_id: int, + input: [dynamic]u8, + summary: string, + edits: [dynamic]string, +} + +Search_Panel :: struct { + open: bool, + input: [dynamic]u8, + message: string, +} + +Diagnostics_Panel :: struct { + open: bool, + selected: int, +} + +Command_Palette :: struct { + open: bool, + selected: int, + input: [dynamic]u8, +} + +Command_Id :: enum { + Reload_Workspace, + Save, + Save_All, + Find, + Diagnostics, + Gradle_Tasks, + Explorer, + Metrics, + Completion, + Hover, + Definition, + References, + Rename, +} + +Command_Item :: struct { + id: Command_Id, + label: string, +} + +COMMAND_ITEMS := [?]Command_Item{ + {.Reload_Workspace, "Reload Workspace"}, + {.Save, "Save File"}, + {.Save_All, "Save All"}, + {.Find, "Find in File"}, + {.Diagnostics, "Toggle Diagnostics"}, + {.Gradle_Tasks, "Toggle Gradle Tasks"}, + {.Explorer, "Toggle Explorer"}, + {.Metrics, "Toggle Metrics Overlay"}, + {.Completion, "Show Completions"}, + {.Hover, "Show Hover"}, + {.Definition, "Go to Definition"}, + {.References, "Find References"}, + {.Rename, "Rename Preview"}, +} + +Gradle_Task_Item :: struct { + path: string, + name: string, + description: string, +} + +Gradle_Tasks_Panel :: struct { + open: bool, + pending_id: int, + pending_run: bool, + selected: int, + items: [dynamic]Gradle_Task_Item, + message: string, + output: [dynamic]string, +} + +Syntax_Kind :: enum { + Plain, + Keyword, + String, + Comment, + Number, + Type, +} + +Syntax_Span :: struct { + start: int, + end: int, + kind: Syntax_Kind, +} + +Syntax_State :: struct { + in_block_comment: bool, + in_triple_string: bool, +} + +Daemon_Sync_State :: struct { + pending: bool, + last_edit: time.Time, +} + +SDL_WINDOW_WIDTH :: 1100 +SDL_WINDOW_HEIGHT :: 760 +SDL_TOP_BAR_HEIGHT :: 32 +SDL_TAB_BAR_HEIGHT :: 30 +SDL_STATUS_BAR_HEIGHT :: 24 +SDL_LINE_HEIGHT :: 17 +SDL_TREE_ROW_HEIGHT :: 17 +SDL_SIDEBAR_DEFAULT_WIDTH :: 260 +SDL_SIDEBAR_MIN_WIDTH :: 180 +SDL_SIDEBAR_MAX_WIDTH :: 520 +SDL_RIGHT_SIDEBAR_DEFAULT_WIDTH :: 340 +SDL_RIGHT_SIDEBAR_MIN_WIDTH :: 220 +SDL_RIGHT_SIDEBAR_MAX_WIDTH :: 620 +SDL_RESIZE_HANDLE_WIDTH :: 6 +SDL_DAEMON_SYNC_DEBOUNCE_MS :: 250 +SDL_GUTTER_WIDTH :: 72 +SDL_EDITOR_TOP :: SDL_TOP_BAR_HEIGHT + SDL_TAB_BAR_HEIGHT +SDL_EDITOR_TEXT_Y :: SDL_EDITOR_TOP + 14 +SDL_TREE_HEADER_Y :: SDL_TOP_BAR_HEIGHT + 12 +SDL_TREE_FIRST_Y :: SDL_TOP_BAR_HEIGHT + 42 + +ui_state_load :: proc() -> SDL_View { + view := SDL_View{ + window_width = SDL_WINDOW_WIDTH, + window_height = SDL_WINDOW_HEIGHT, + sidebar_width = SDL_SIDEBAR_DEFAULT_WIDTH, + right_sidebar_width = SDL_RIGHT_SIDEBAR_DEFAULT_WIDTH, + explorer_visible = true, + } + + path, ok := ui_state_path(context.temp_allocator) + if !ok do return view + + data, err := os.read_entire_file(path, context.allocator) + if err != nil do return view + defer delete(data) + + value, parse_err := json.parse_string(string(data), .JSON, true) + if parse_err != nil do return view + defer json.destroy_value(value) + + if width, has_width := json_get_int(value, "sidebarWidth"); has_width { + view.sidebar_width = clamp_int(width, SDL_SIDEBAR_MIN_WIDTH, SDL_SIDEBAR_MAX_WIDTH) + } + if width, has_width := json_get_int(value, "rightSidebarWidth"); has_width { + view.right_sidebar_width = clamp_int(width, SDL_RIGHT_SIDEBAR_MIN_WIDTH, SDL_RIGHT_SIDEBAR_MAX_WIDTH) + } + if width, has_width := json_get_int(value, "windowWidth"); has_width { + view.window_width = clamp_int(width, 640, 4096) + } + if height, has_height := json_get_int(value, "windowHeight"); has_height { + view.window_height = clamp_int(height, 420, 4096) + } + if visible, has_visible := json_get_bool(value, "explorerVisible"); has_visible { + view.explorer_visible = visible + } + if visible, has_visible := json_get_bool(value, "gradleSidebarVisible"); has_visible { + view.gradle_sidebar_visible = visible + } + if workspace, has_workspace := json_get_string(value, "workspace"); has_workspace { + view.cached_workspace = strings.clone(workspace) + } + if active_file, has_active_file := json_get_int(value, "activeFile"); has_active_file { + view.saved_active_file = active_file + } + if open_files_value, has_open_files := json_object_get(value, "openFiles"); has_open_files { + #partial switch open_files in open_files_value { + case json.Array: + for item in open_files { + path, has_path := json_get_string(item, "path") + if !has_path || len(path) == 0 do continue + cursor, _ := json_get_int(item, "cursor") + append(&view.saved_open_files, Saved_Open_File{path = strings.clone(path), cursor = max_int(cursor, 0)}) + } + } + } + return view +} + +ui_state_save :: proc(view: ^SDL_View) { + path, ok := ui_state_path(context.temp_allocator) + if !ok do return + + dir, dir_ok := ui_state_dir(context.temp_allocator) + if dir_ok { + _ = os.make_directory_all(dir) + } + + open_files_json: [dynamic]u8 + defer delete(open_files_json) + append(&open_files_json, '[') + for file, index in view.saved_open_files { + if index > 0 do append(&open_files_json, ',') + item := fmt.tprintf("{\"path\":%q,\"cursor\":%d}", file.path, file.cursor) + for b in transmute([]u8)item do append(&open_files_json, b) + } + append(&open_files_json, ']') + + text := fmt.tprintf("{\n \"windowWidth\": %d,\n \"windowHeight\": %d,\n \"sidebarWidth\": %d,\n \"rightSidebarWidth\": %d,\n \"explorerVisible\": %v,\n \"gradleSidebarVisible\": %v,\n \"workspace\": %q,\n \"activeFile\": %d,\n \"openFiles\": %s\n}\n", view.window_width, view.window_height, view.sidebar_width, view.right_sidebar_width, view.explorer_visible, view.gradle_sidebar_visible, view.cached_workspace, view.saved_active_file, string(open_files_json[:])) + _ = os.write_entire_file(path, transmute([]byte)text) +} + +ui_state_destroy :: proc(view: ^SDL_View) { + delete(view.cached_workspace) + ui_state_clear_open_files(view) + delete(view.saved_open_files) +} + +ui_state_clear_open_files :: proc(view: ^SDL_View) { + for file in view.saved_open_files { + delete(file.path) + } + clear(&view.saved_open_files) +} + +ui_state_capture_editor :: proc(view: ^SDL_View, editor: ^Editor, workspace: string) { + delete(view.cached_workspace) + view.cached_workspace = strings.clone(workspace) + view.saved_active_file = editor.active + ui_state_clear_open_files(view) + for &buffer in editor.buffers { + append(&view.saved_open_files, Saved_Open_File{path = strings.clone(buffer.path), cursor = buffer.cursor.offset}) + } +} + +ui_state_path :: proc(allocator: runtime.Allocator) -> (string, bool) { + dir, ok := ui_state_dir(allocator) + if !ok do return "", false + return fmt.aprintf("%s/ui.json", dir), true +} + +ui_state_dir :: proc(allocator: runtime.Allocator) -> (string, bool) { + cache_dir, err := os.user_cache_dir(allocator) + if err != nil do return "", false + return fmt.aprintf("%s/native-kotlin-editor", cache_dir), true +} + +left_sidebar_width :: proc(view: ^SDL_View) -> int { + if !view.explorer_visible do return 0 + return clamp_int(view.sidebar_width, SDL_SIDEBAR_MIN_WIDTH, SDL_SIDEBAR_MAX_WIDTH) +} + +right_sidebar_width_view :: proc(view: ^SDL_View, window_width: int) -> int { + max_available := max_int(window_width - left_sidebar_width(view) - 220, SDL_RIGHT_SIDEBAR_MIN_WIDTH) + return min_int(clamp_int(view.right_sidebar_width, SDL_RIGHT_SIDEBAR_MIN_WIDTH, SDL_RIGHT_SIDEBAR_MAX_WIDTH), max_available) +} + +editor_text_x :: proc(view: ^SDL_View) -> int { + return left_sidebar_width(view) + SDL_GUTTER_WIDTH + 20 +} + +daemon_sync_schedule :: proc(sync: ^Daemon_Sync_State) { + sync.pending = true + sync.last_edit = time.now() +} + +daemon_sync_now :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor, open: bool) { + daemon_sync_active_buffer(daemon, editor, open) + sync.pending = false +} + +daemon_sync_flush_if_due :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor) { + if !sync.pending || !daemon.connected do return + if time.diff(sync.last_edit, time.now()) < SDL_DAEMON_SYNC_DEBOUNCE_MS * time.Millisecond do return + daemon_sync_now(sync, daemon, editor, false) +} + +daemon_begin_owned_start :: proc(workspace: string, owned_daemon: ^Daemon_Process, port_line: ^[dynamic]u8, editor: ^Editor, start_pending: ^bool) -> bool { + daemon_stop_process(owned_daemon) + clear(port_line) + started_daemon, ok := daemon_start_process_begin(workspace) + if ok { + owned_daemon^ = started_daemon + start_pending^ = true + editor_set_status(editor, "Starting Kotlin daemon...") + return true + } + editor_set_status(editor, "Kotlin daemon failed to start") + return false +} + +editor_restore_initial_files :: proc(editor: ^Editor, view: ^SDL_View, workspace, file_path: string) -> bool { + if len(file_path) > 0 { + return editor_open_file(editor, file_path) + } + + restored := false + if view.cached_workspace == workspace && len(view.saved_open_files) > 0 { + for file in view.saved_open_files { + if !os.is_file(file.path) do continue + if editor_open_file(editor, file.path) { + active := editor_active_buffer(editor) + if active != nil { + active.cursor.offset = clamp_int(file.cursor, 0, buffer_len(&active.buffer)) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + } + restored = true + } + } + if restored { + editor.active = clamp_int(view.saved_active_file, 0, len(editor.buffers) - 1) + return true + } + } + + path := fmt.tprintf("%s/src/main/kotlin/dev/nativeeditor/daemon/Main.kt", workspace) + return editor_open_file(editor, path) +} + +run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { + view := ui_state_load() + defer ui_state_destroy(&view) + + editor := Editor{} + defer editor_destroy(&editor) + + if !editor_restore_initial_files(&editor, &view, workspace, file_path) { + return + } + + tree := project_tree_load(workspace) + defer project_tree_destroy(&tree) + + if !SDL.Init(SDL.INIT_VIDEO) { + fmt.println("SDL init failed:", SDL.GetError()) + return + } + defer SDL.Quit() + + window := SDL.CreateWindow("Native Kotlin Editor", i32(view.window_width), i32(view.window_height), {.RESIZABLE}) + if window == nil { + fmt.println("SDL window failed:", SDL.GetError()) + return + } + defer SDL.DestroyWindow(window) + + gpu := gpu_renderer_make(window) + defer gpu_renderer_destroy(&gpu) + + renderer: ^SDL.Renderer + if !gpu.available { + renderer = SDL.CreateRenderer(window, nil) + if renderer == nil { + fmt.println("SDL renderer failed:", SDL.GetError()) + return + } + } + defer { + if renderer != nil { + SDL.DestroyRenderer(renderer) + } + } + + _ = SDL.StartTextInput(window) + defer { _ = SDL.StopTextInput(window) } + + default_cursor := SDL.GetDefaultCursor() + text_cursor := SDL.CreateSystemCursor(.TEXT) + resize_cursor := SDL.CreateSystemCursor(.EW_RESIZE) + defer { + if text_cursor != nil do SDL.DestroyCursor(text_cursor) + if resize_cursor != nil do SDL.DestroyCursor(resize_cursor) + } + + refresh_view_size(window, &view) + defer { + ui_state_capture_editor(&view, &editor, workspace) + ui_state_save(&view) + } + completion := completion_popup_make() + defer completion_popup_destroy(&completion) + hover := Hover_Tooltip{} + defer hover_tooltip_destroy(&hover) + definition := Definition_Jump{} + defer definition_jump_destroy(&definition) + navigation := Navigation_History{} + defer navigation_history_destroy(&navigation) + references := References_Panel{} + defer references_panel_destroy(&references) + rename := Rename_Panel{} + defer rename_panel_destroy(&rename) + search := Search_Panel{} + defer search_panel_destroy(&search) + diagnostics_panel := Diagnostics_Panel{} + command_palette := Command_Palette{} + defer command_palette_destroy(&command_palette) + tasks_panel := Gradle_Tasks_Panel{} + tasks_panel.open = view.gradle_sidebar_visible + if tasks_panel.open { + tasks_panel.message = strings.clone("Waiting for Kotlin daemon...") + } + defer gradle_tasks_panel_destroy(&tasks_panel) + + owned_daemon := Daemon_Process{} + defer daemon_stop_process(&owned_daemon) + daemon := Daemon_Client{} + defer daemon_close(&daemon) + daemon_sync_state := Daemon_Sync_State{} + daemon_port_line: [dynamic]u8 + defer delete(daemon_port_line) + daemon_start_pending := false + daemon_connect_pending := false + daemon_initialized := false + owned_daemon_enabled := daemon_port <= 0 + daemon_restart_cooldown_frames := 0 + port := daemon_port + if port > 0 { + daemon_connect_pending = true + } else { + _ = daemon_begin_owned_start(workspace, &owned_daemon, &daemon_port_line, &editor, &daemon_start_pending) + } + + running := true + for running { + event: SDL.Event + for SDL.PollEvent(&event) { + #partial switch event.type { + case .QUIT, .WINDOW_CLOSE_REQUESTED: + running = false + case .KEY_DOWN: + if handle_sdl_key(&editor, &view, &tree, workspace, &completion, &hover, &definition, &navigation, &references, &rename, &search, &diagnostics_panel, &command_palette, &tasks_panel, &daemon, &daemon_sync_state, event.key.key, event.key.mod) { + daemon_sync_schedule(&daemon_sync_state) + } + case .TEXT_INPUT: + text := strings.truncate_to_byte(string(event.text.text), 0) + if len(text) > 0 { + if rename.open { + rename_panel_insert(&rename, text) + continue + } + if search.open { + search_panel_insert(&search, text) + continue + } + if command_palette.open { + command_palette_insert(&command_palette, text) + continue + } + active := editor_active_buffer(&editor) + if active != nil { + editor_insert_text(active, text) + close_stale_edit_overlays(&completion, &hover, &references, &rename) + ensure_cursor_visible(&editor, &view) + daemon_sync_schedule(&daemon_sync_state) + } + } + case .MOUSE_WHEEL: + if handle_overlay_wheel(&editor, &view, &gpu, &command_palette, &completion, &references, &diagnostics_panel, event.wheel.mouse_x, event.wheel.mouse_y, int(event.wheel.integer_y)) { + // Routed to the topmost hovered overlay. + } else if handle_gradle_tasks_wheel(&view, &tasks_panel, event.wheel.mouse_x, event.wheel.mouse_y, int(event.wheel.integer_y)) { + // Routed to the right sidebar. + } else if event.wheel.mouse_x < f32(left_sidebar_width(&view)) { + scroll_project_tree(&tree, &view, -int(event.wheel.integer_y) * 3) + } else { + scroll_sdl_view(&editor, &view, -int(event.wheel.integer_y) * 3) + } + case .MOUSE_BUTTON_DOWN: + if event.button.button != SDL.BUTTON_LEFT do continue + if handle_panel_resize_down(&view, &tasks_panel, event.button.x, event.button.y) { + view.mouse_selecting = false + set_editor_cursor(&view, &tasks_panel, event.button.x, event.button.y, default_cursor, text_cursor, resize_cursor) + } else if handle_command_palette_click(&command_palette, &editor, &view, &tree, workspace, &completion, &hover, &definition, &references, &rename, &search, &diagnostics_panel, &tasks_panel, &daemon, &daemon_sync_state, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_completion_click(&editor, &view, &gpu, &completion, &hover, &references, &rename, &search, &daemon_sync_state, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_references_click(&references, &navigation, &editor, &view, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_diagnostics_click(&diagnostics_panel, &editor, &view, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_gradle_tasks_click(&view, &tasks_panel, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_overlay_outside_click(&command_palette, &completion, &references, &diagnostics_panel) { + view.mouse_selecting = false + } else if handle_editor_tab_click(&editor, &view, event.button.x, event.button.y) { + daemon_sync_now(&daemon_sync_state, &daemon, &editor, true) + } else if handle_project_tree_click(&editor, &tree, &view, event.button.x, event.button.y) { + daemon_sync_now(&daemon_sync_state, &daemon, &editor, true) + } else { + view.mouse_selecting = handle_editor_text_mouse(&editor, &view, &gpu, event.button.x, event.button.y, false) + } + case .MOUSE_BUTTON_UP: + if event.button.button == SDL.BUTTON_LEFT { + if view.resizing_panel != .None { + view.resizing_panel = .None + ui_state_save(&view) + } + view.tab_dragging = false + view.tab_drag_index = 0 + view.mouse_selecting = false + } + case .MOUSE_MOTION: + if view.resizing_panel != .None && (event.motion.state & SDL.BUTTON_LMASK) != {} { + handle_panel_resize_motion(&view, event.motion.x) + } else if view.tab_dragging && (event.motion.state & SDL.BUTTON_LMASK) != {} { + handle_editor_tab_drag(&editor, &view, event.motion.x, event.motion.y) + } else if view.mouse_selecting && (event.motion.state & SDL.BUTTON_LMASK) != {} { + handle_editor_text_mouse(&editor, &view, &gpu, event.motion.x, event.motion.y, true) + } + set_editor_cursor(&view, &tasks_panel, event.motion.x, event.motion.y, default_cursor, text_cursor, resize_cursor) + } + } + + refresh_view_size(window, &view) + + if owned_daemon_enabled && !daemon.connected && !daemon_start_pending && !daemon_connect_pending { + if daemon_initialized { + daemon_initialized = false + daemon_close(&daemon) + editor_set_status(&editor, "Kotlin daemon disconnected; restarting...") + daemon_restart_cooldown_frames = 30 + } + if daemon_restart_cooldown_frames > 0 { + daemon_restart_cooldown_frames -= 1 + } else { + _ = daemon_begin_owned_start(workspace, &owned_daemon, &daemon_port_line, &editor, &daemon_start_pending) + daemon_restart_cooldown_frames = 30 + } + } + + if !daemon.connected { + if daemon_start_pending { + started_port, ready, failed := daemon_poll_port(&owned_daemon, &daemon_port_line) + if ready { + port = started_port + daemon_start_pending = false + daemon_connect_pending = true + } else if failed { + daemon_start_pending = false + daemon_stop_process(&owned_daemon) + daemon_restart_cooldown_frames = 60 + editor_set_status(&editor, "Kotlin daemon failed to announce a port") + } + } + if daemon_connect_pending { + daemon = daemon_connect(port) + daemon_connect_pending = false + if daemon.connected { + daemon_start_reader(&daemon) + daemon_send_workspace_open(&daemon, workspace) + daemon_sync_now(&daemon_sync_state, &daemon, &editor, true) + if tasks_panel.open && tasks_panel.pending_id == 0 && len(tasks_panel.items) == 0 { + gradle_tasks_panel_request(&tasks_panel, &daemon) + } + daemon_initialized = true + editor_set_status(&editor, "Kotlin daemon ready") + } else { + if owned_daemon_enabled { + daemon_stop_process(&owned_daemon) + daemon_restart_cooldown_frames = 60 + } + editor_set_status(&editor, "Kotlin daemon connection failed") + } + } + } else if !daemon_initialized { + daemon_start_reader(&daemon) + daemon_send_workspace_open(&daemon, workspace) + daemon_sync_now(&daemon_sync_state, &daemon, &editor, true) + if tasks_panel.open && tasks_panel.pending_id == 0 && len(tasks_panel.items) == 0 { + gradle_tasks_panel_request(&tasks_panel, &daemon) + } + daemon_initialized = true + } + + daemon_sync_flush_if_due(&daemon_sync_state, &daemon, &editor) + daemon_apply_latest_diagnostics(&daemon, &editor) + completion_popup_apply_response(&completion, &daemon) + hover_tooltip_apply_response(&hover, &daemon) + definition_jump_apply_response(&definition, &navigation, &editor, &view, &daemon) + references_panel_apply_response(&references, &daemon) + rename_panel_apply_response(&rename, &daemon) + gradle_tasks_panel_apply_event(&tasks_panel, &daemon) + gradle_tasks_panel_apply_response(&tasks_panel, &daemon) + if gpu.available { + render_sdl_editor_gpu(&gpu, &editor, &view, &tree, &completion, &hover, &references, &rename, &search, &diagnostics_panel, &command_palette, &tasks_panel) + } else { + render_sdl_editor(renderer, &editor, &view, &tree, &completion, &hover, &references, &rename) + } + SDL.Delay(16) + } +} + +completion_popup_make :: proc() -> Completion_Popup { + popup := Completion_Popup{} + completion_popup_set_message(&popup, "No completions yet") + return popup +} + +completion_popup_destroy :: proc(popup: ^Completion_Popup) { + for item in popup.items { + delete(item.label) + delete(item.kind) + } + delete(popup.items) +} + +close_stale_edit_overlays :: proc(completion: ^Completion_Popup, hover: ^Hover_Tooltip, references: ^References_Panel, rename: ^Rename_Panel) { + completion.open = false + hover.open = false + references.open = false + rename.open = false +} + +close_completion_accept_overlays :: proc(completion: ^Completion_Popup, hover: ^Hover_Tooltip, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel) { + close_stale_edit_overlays(completion, hover, references, rename) + search.open = false +} + +completion_popup_open :: proc(popup: ^Completion_Popup, editor: ^Editor, daemon: ^Daemon_Client) { + active := editor_active_buffer(editor) + if active == nil do return + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + popup.open = true + popup.selected = 0 + popup.line = line + popup.column = column + popup.pending_id = daemon_send_completion(daemon, active.path, line, column) + completion_popup_set_message(popup, "Loading completions...") +} + +hover_tooltip_destroy :: proc(hover: ^Hover_Tooltip) { + delete(hover.contents) +} + +hover_tooltip_open :: proc(hover: ^Hover_Tooltip, editor: ^Editor, daemon: ^Daemon_Client) { + active := editor_active_buffer(editor) + if active == nil do return + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + hover.open = true + hover.line = line + hover.column = column + hover.pending_id = daemon_send_hover(daemon, active.path, line, column) + hover_tooltip_set_contents(hover, "Loading hover...") +} + +definition_jump_request :: proc(definition: ^Definition_Jump, editor: ^Editor, daemon: ^Daemon_Client) { + active := editor_active_buffer(editor) + if active == nil do return + + delete(definition.origin_path) + definition.origin_path = strings.clone(active.path) + definition.origin_offset = active.cursor.offset + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + definition.pending_id = daemon_send_definition(daemon, active.path, line, column) +} + +definition_jump_destroy :: proc(definition: ^Definition_Jump) { + delete(definition.origin_path) +} + +definition_jump_apply_response :: proc(definition: ^Definition_Jump, navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View, daemon: ^Daemon_Client) { + if definition.pending_id == 0 do return + + response := daemon_take_response(daemon, definition.pending_id) + defer delete(response) + if len(response) == 0 do return + + path, line, column, ok := parse_definition_response(string(response[:]), definition.pending_id) + defer delete(path) + if !ok do return + + definition.pending_id = 0 + if len(path) == 0 do return + if len(definition.origin_path) > 0 { + navigation_push(&navigation.back, definition.origin_path, definition.origin_offset) + navigation_clear_stack(&navigation.forward) + } + if !editor_jump_current_tab_to_location(editor, view, path, line, column) do return + daemon_sync_active_buffer(daemon, editor, true) +} + +editor_jump_to_location :: proc(editor: ^Editor, view: ^SDL_View, path: string, line, column: int) -> bool { + if !editor_open_or_focus_file(editor, path) do return false + + active := editor_active_buffer(editor) + if active == nil do return false + cursor_move_to_line_col(&active.buffer, &active.cursor, max_int(line - 1, 0), max_int(column - 1, 0)) + ensure_cursor_visible(editor, view) + return true +} + +editor_jump_current_tab_to_location :: proc(editor: ^Editor, view: ^SDL_View, path: string, line, column: int) -> bool { + if !editor_replace_active_file(editor, path) do return false + + active := editor_active_buffer(editor) + if active == nil do return false + cursor_move_to_line_col(&active.buffer, &active.cursor, max_int(line - 1, 0), max_int(column - 1, 0)) + ensure_cursor_visible(editor, view) + return true +} + +parse_definition_response :: proc(response: string, expected_id: int) -> (string, int, int, bool) { + message, value, ok := parse_protocol_message(response) + if !ok do return "", 0, 0, false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return "", 0, 0, false + + result, has_result := json_object_get(value, "result") + if !has_result do return "", 0, 0, false + locations_value, has_locations := json_object_get(result, "locations") + if !has_locations do return "", 0, 0, false + + #partial switch locations in locations_value { + case json.Array: + if len(locations) == 0 do return "", 0, 0, true + location := locations[0] + path, has_path := json_get_string(location, "path") + if !has_path do return "", 0, 0, false + line, _ := json_get_int(location, "line") + column, _ := json_get_int(location, "column") + return strings.clone(path), line, column, true + } + + return "", 0, 0, false +} + +navigation_history_destroy :: proc(navigation: ^Navigation_History) { + navigation_clear_stack(&navigation.back) + navigation_clear_stack(&navigation.forward) + delete(navigation.back) + delete(navigation.forward) +} + +navigation_clear_stack :: proc(stack: ^[dynamic]Nav_Location) { + for location in stack^ { + delete(location.path) + } + clear(stack) +} + +navigation_push :: proc(stack: ^[dynamic]Nav_Location, path: string, offset: int) { + append(stack, Nav_Location{path = strings.clone(path), offset = offset}) +} + +navigation_pop :: proc(stack: ^[dynamic]Nav_Location) -> (Nav_Location, bool) { + if len(stack^) == 0 do return {}, false + index := len(stack^) - 1 + location := stack^[index] + resize(stack, index) + return location, true +} + +navigation_current_push :: proc(stack: ^[dynamic]Nav_Location, editor: ^Editor) { + active := editor_active_buffer(editor) + if active == nil do return + navigation_push(stack, active.path, active.cursor.offset) +} + +navigation_go_back :: proc(navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View) { + location, ok := navigation_pop(&navigation.back) + if !ok do return + defer delete(location.path) + + navigation_current_push(&navigation.forward, editor) + if !editor_open_or_focus_file(editor, location.path) do return + active := editor_active_buffer(editor) + if active == nil do return + active.cursor.offset = clamp_int(location.offset, 0, buffer_len(&active.buffer)) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + ensure_cursor_visible(editor, view) +} + +navigation_go_forward :: proc(navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View) { + location, ok := navigation_pop(&navigation.forward) + if !ok do return + defer delete(location.path) + + navigation_current_push(&navigation.back, editor) + if !editor_open_or_focus_file(editor, location.path) do return + active := editor_active_buffer(editor) + if active == nil do return + active.cursor.offset = clamp_int(location.offset, 0, buffer_len(&active.buffer)) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + ensure_cursor_visible(editor, view) +} + +references_panel_destroy :: proc(panel: ^References_Panel) { + references_panel_clear(panel) +} + +references_panel_clear :: proc(panel: ^References_Panel) { + for item in panel.items { + delete(item.path) + delete(item.label) + } + clear(&panel.items) +} + +references_panel_request :: proc(panel: ^References_Panel, editor: ^Editor, daemon: ^Daemon_Client) { + active := editor_active_buffer(editor) + if active == nil do return + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + panel.open = true + panel.selected = 0 + panel.pending_id = daemon_send_references(daemon, active.path, line, column) + references_panel_clear(panel) + append(&panel.items, Reference_Item{label = strings.clone("Loading references...")}) +} + +references_panel_apply_response :: proc(panel: ^References_Panel, daemon: ^Daemon_Client) { + if !panel.open || panel.pending_id == 0 do return + + response := daemon_take_response(daemon, panel.pending_id) + defer delete(response) + if len(response) == 0 do return + + items, ok := parse_references_response(string(response[:]), panel.pending_id) + defer { + for item in items { + delete(item.path) + delete(item.label) + } + delete(items) + } + if !ok do return + + panel.pending_id = 0 + references_panel_clear(panel) + if len(items) == 0 { + append(&panel.items, Reference_Item{label = strings.clone("No references")}) + } else { + for item in items { + append(&panel.items, Reference_Item{ + path = strings.clone(item.path), + line = item.line, + column = item.column, + label = strings.clone(item.label), + }) + } + } + panel.selected = 0 +} + +parse_references_response :: proc(response: string, expected_id: int) -> ([dynamic]Reference_Item, bool) { + items: [dynamic]Reference_Item + message, value, ok := parse_protocol_message(response) + if !ok do return items, false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return items, false + + result, has_result := json_object_get(value, "result") + if !has_result do return items, false + locations_value, has_locations := json_object_get(result, "locations") + if !has_locations do return items, false + + #partial switch locations in locations_value { + case json.Array: + for location in locations { + path, has_path := json_get_string(location, "path") + if !has_path do continue + line, _ := json_get_int(location, "line") + column, _ := json_get_int(location, "column") + _, file := os.split_path(path) + label := fmt.tprintf("%s:%d:%d", file, line, column) + append(&items, Reference_Item{ + path = strings.clone(path), + line = line, + column = column, + label = strings.clone(label), + }) + } + } + + return items, true +} + +references_panel_accept :: proc(panel: ^References_Panel, navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View) { + if !panel.open || len(panel.items) == 0 do return + index := clamp_int(panel.selected, 0, len(panel.items) - 1) + item := panel.items[index] + if len(item.path) == 0 do return + + navigation_current_push(&navigation.back, editor) + navigation_clear_stack(&navigation.forward) + _ = editor_jump_to_location(editor, view, item.path, item.line, item.column) + panel.open = false +} + +rename_panel_destroy :: proc(panel: ^Rename_Panel) { + delete(panel.input) + delete(panel.summary) + for edit in panel.edits { + delete(edit) + } + delete(panel.edits) +} + +search_panel_destroy :: proc(panel: ^Search_Panel) { + delete(panel.input) + delete(panel.message) +} + +command_palette_destroy :: proc(palette: ^Command_Palette) { + delete(palette.input) +} + +command_palette_open :: proc(palette: ^Command_Palette) { + palette.open = true + palette.selected = 0 + clear(&palette.input) +} + +command_palette_insert :: proc(palette: ^Command_Palette, text: string) { + for b in transmute([]u8)text { + if b >= 32 && b < 127 { + append(&palette.input, b) + } + } + palette.selected = min_int(palette.selected, max_int(command_palette_match_count(palette) - 1, 0)) +} + +command_palette_backspace :: proc(palette: ^Command_Palette) { + if len(palette.input) > 0 { + resize(&palette.input, len(palette.input) - 1) + palette.selected = min_int(palette.selected, max_int(command_palette_match_count(palette) - 1, 0)) + } +} + +command_palette_match_count :: proc(palette: ^Command_Palette) -> int { + count := 0 + for item in COMMAND_ITEMS { + if command_palette_matches(palette, item.label) do count += 1 + } + return count +} + +command_palette_selected_item :: proc(palette: ^Command_Palette) -> (Command_Item, bool) { + seen := 0 + for item in COMMAND_ITEMS { + if !command_palette_matches(palette, item.label) do continue + if seen == palette.selected do return item, true + seen += 1 + } + return {}, false +} + +command_palette_matches :: proc(palette: ^Command_Palette, label: string) -> bool { + if len(palette.input) == 0 do return true + return ascii_contains_fold(label, string(palette.input[:])) +} + +ascii_contains_fold :: proc(haystack, needle: string) -> bool { + if len(needle) == 0 do return true + if len(needle) > len(haystack) do return false + for start := 0; start <= len(haystack) - len(needle); start += 1 { + match := true + for i := 0; i < len(needle); i += 1 { + if ascii_lower(haystack[start + i]) != ascii_lower(needle[i]) { + match = false + break + } + } + if match do return true + } + return false +} + +ascii_lower :: proc(b: u8) -> u8 { + if b >= 'A' && b <= 'Z' do return b + ('a' - 'A') + return b +} + +command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, completion: ^Completion_Popup, hover: ^Hover_Tooltip, definition: ^Definition_Jump, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel, diagnostics_panel: ^Diagnostics_Panel, tasks_panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) { + item, ok := command_palette_selected_item(palette) + if !ok do return + palette.open = false + + switch item.id { + case .Reload_Workspace: + workspace_reload(editor, view, tree, workspace, daemon, sync) + case .Save: + _ = editor_save_active(editor) + daemon_sync_now(sync, daemon, editor, false) + case .Save_All: + _ = editor_save_all(editor) + daemon_sync_now(sync, daemon, editor, false) + case .Find: + search_panel_open(search) + case .Diagnostics: + diagnostics_panel.open = !diagnostics_panel.open + diagnostics_panel.selected = 0 + case .Gradle_Tasks: + if tasks_panel.open { + tasks_panel.open = false + view.gradle_sidebar_visible = false + } else { + view.gradle_sidebar_visible = true + daemon_sync_now(sync, daemon, editor, false) + gradle_tasks_panel_request(tasks_panel, daemon) + } + ui_state_save(view) + case .Explorer: + view.explorer_visible = !view.explorer_visible + view.tree_first = 0 + ui_state_save(view) + case .Metrics: + view.show_metrics = !view.show_metrics + case .Completion: + daemon_sync_now(sync, daemon, editor, false) + completion_popup_open(completion, editor, daemon) + case .Hover: + daemon_sync_now(sync, daemon, editor, false) + hover_tooltip_open(hover, editor, daemon) + case .Definition: + daemon_sync_now(sync, daemon, editor, false) + definition_jump_request(definition, editor, daemon) + case .References: + daemon_sync_now(sync, daemon, editor, false) + references_panel_request(references, editor, daemon) + case .Rename: + rename_panel_open(rename) + } +} + +gradle_tasks_panel_destroy :: proc(panel: ^Gradle_Tasks_Panel) { + gradle_tasks_panel_clear(panel) + delete(panel.items) + delete(panel.message) + gradle_tasks_panel_clear_output(panel) + delete(panel.output) +} + +gradle_tasks_panel_clear :: proc(panel: ^Gradle_Tasks_Panel) { + for item in panel.items { + delete(item.path) + delete(item.name) + delete(item.description) + } + clear(&panel.items) +} + +gradle_tasks_panel_clear_output :: proc(panel: ^Gradle_Tasks_Panel) { + for line in panel.output { + delete(line) + } + clear(&panel.output) +} + +gradle_tasks_panel_add_output :: proc(panel: ^Gradle_Tasks_Panel, stream, text: string) { + label := fmt.tprintf("%s: %s", stream, text) + append(&panel.output, strings.clone(label)) + for len(panel.output) > 80 { + delete(panel.output[0]) + ordered_remove(&panel.output, 0) + } +} + +gradle_tasks_panel_request :: proc(panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client) { + panel.open = true + panel.selected = 0 + panel.pending_id = daemon_send_gradle_tasks(daemon) + panel.pending_run = false + gradle_tasks_panel_clear(panel) + delete(panel.message) + panel.message = strings.clone("Loading Gradle tasks...") +} + +gradle_tasks_panel_apply_response :: proc(panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client) { + if !panel.open || panel.pending_id == 0 do return + response := daemon_take_response(daemon, panel.pending_id) + defer delete(response) + if len(response) == 0 do return + + if panel.pending_run { + message, ok := parse_gradle_run_response(string(response[:]), panel.pending_id) + defer delete(message) + if !ok do return + panel.pending_id = 0 + panel.pending_run = false + delete(panel.message) + panel.message = strings.clone(message) + return + } + + items, ok := parse_gradle_tasks_response(string(response[:]), panel.pending_id) + defer { + for item in items { + delete(item.path) + delete(item.name) + delete(item.description) + } + delete(items) + } + if !ok do return + + panel.pending_id = 0 + gradle_tasks_panel_clear(panel) + for item in items { + append(&panel.items, Gradle_Task_Item{path = strings.clone(item.path), name = strings.clone(item.name), description = strings.clone(item.description)}) + } + delete(panel.message) + if len(panel.items) == 0 { + panel.message = strings.clone("No Gradle tasks") + } else { + panel.message = strings.clone("Enter will run tasks in a later slice") + } +} + +gradle_tasks_panel_apply_event :: proc(panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client) { + if !panel.open do return + for { + event := daemon_take_gradle_event(daemon) + if len(event) == 0 { + delete(event) + return + } + + task, state, stream, text, ok := parse_gradle_run_event(string(event[:])) + delete(event) + if ok { + if state == "output" { + gradle_tasks_panel_add_output(panel, stream, text) + } else { + delete(panel.message) + panel.message = strings.clone(fmt.tprintf("Gradle %s: %s", state, task)) + } + } + delete(task) + delete(state) + delete(stream) + delete(text) + } +} + +parse_gradle_run_event :: proc(event: string) -> (string, string, string, string, bool) { + message, value, ok := parse_protocol_message(event) + if !ok do return "", "", "", "", false + defer json.destroy_value(value) + if message.kind != .Event || message.event != "gradle/run" do return "", "", "", "", false + params, has_params := json_object_get(value, "params") + if !has_params do return "", "", "", "", false + task, _ := json_get_string(params, "task") + state, _ := json_get_string(params, "state") + stream, _ := json_get_string(params, "stream") + text, _ := json_get_string(params, "text") + return strings.clone(task), strings.clone(state), strings.clone(stream), strings.clone(text), true +} + +gradle_tasks_panel_run_selected :: proc(panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client) { + if len(panel.items) == 0 || panel.pending_id != 0 do return + index := clamp_int(panel.selected, 0, len(panel.items) - 1) + task := panel.items[index].path + if len(task) == 0 do task = panel.items[index].name + if len(task) == 0 do return + + panel.pending_id = daemon_send_gradle_run(daemon, task) + panel.pending_run = true + gradle_tasks_panel_clear_output(panel) + delete(panel.message) + panel.message = strings.clone(fmt.tprintf("Running %s...", task)) +} + +parse_gradle_run_response :: proc(response: string, expected_id: int) -> (string, bool) { + message, value, ok := parse_protocol_message(response) + if !ok do return "", false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id { + return "", false + } + if !message.ok { + code, _ := json_get_string(value, "code") + error_message, _ := json_get_string(value, "message") + if len(error_message) > 0 { + return strings.clone(fmt.tprintf("Gradle failed: %s", error_message)), true + } + return strings.clone(fmt.tprintf("Gradle failed: %s", code)), true + } + result, has_result := json_object_get(value, "result") + task := "task" + if has_result { + task, _ = json_get_string(result, "task") + } + return strings.clone(fmt.tprintf("Gradle finished: %s", task)), true +} + +parse_gradle_tasks_response :: proc(response: string, expected_id: int) -> ([dynamic]Gradle_Task_Item, bool) { + items: [dynamic]Gradle_Task_Item + message, value, ok := parse_protocol_message(response) + if !ok do return items, false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return items, false + + result, has_result := json_object_get(value, "result") + if !has_result do return items, false + tasks_value, has_tasks := json_object_get(result, "tasks") + if !has_tasks do return items, false + + #partial switch tasks in tasks_value { + case json.Array: + for task in tasks { + path, _ := json_get_string(task, "path") + name, _ := json_get_string(task, "name") + description, _ := json_get_string(task, "description") + append(&items, Gradle_Task_Item{path = strings.clone(path), name = strings.clone(name), description = strings.clone(description)}) + } + } + return items, true +} + +search_panel_open :: proc(panel: ^Search_Panel) { + panel.open = true + delete(panel.message) + panel.message = strings.clone("Enter search text, then Enter") +} + +search_panel_insert :: proc(panel: ^Search_Panel, text: string) { + for b in transmute([]u8)text { + if b >= 32 && b < 127 { + append(&panel.input, b) + } + } +} + +search_panel_backspace :: proc(panel: ^Search_Panel) { + if len(panel.input) > 0 { + resize(&panel.input, len(panel.input) - 1) + } +} + +search_panel_find_next :: proc(panel: ^Search_Panel, editor: ^Editor, view: ^SDL_View) { + search_panel_find(panel, editor, view, 1) +} + +search_panel_find_previous :: proc(panel: ^Search_Panel, editor: ^Editor, view: ^SDL_View) { + search_panel_find(panel, editor, view, -1) +} + +search_panel_find :: proc(panel: ^Search_Panel, editor: ^Editor, view: ^SDL_View, direction: int) { + active := editor_active_buffer(editor) + if active == nil do return + if len(panel.input) == 0 { + search_panel_set_message(panel, "Search text is empty") + return + } + + text := buffer_bytes(&active.buffer) + defer delete(text) + query := panel.input[:] + found: int + ok: bool + if direction >= 0 { + start := min_int(active.cursor.offset + 1, len(text)) + found, ok = find_bytes_from(text[:], query, start) + if !ok && start > 0 { + found, ok = find_bytes_from(text[:], query, 0) + } + } else { + start := max_int(active.cursor.offset - len(query) - 1, 0) + found, ok = find_bytes_reverse_from(text[:], query, start) + if !ok && start < len(text) { + found, ok = find_bytes_reverse_from(text[:], query, len(text) - len(query)) + } + } + if !ok { + search_panel_set_message(panel, "No matches") + return + } + + active.selection_active = true + active.selection_anchor = found + active.cursor.offset = found + len(query) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + ensure_cursor_visible(editor, view) + line, column := buffer_offset_to_line_col(&active.buffer, found) + search_panel_set_message(panel, fmt.tprintf("Found at %d:%d", line + 1, column + 1)) +} + +search_panel_set_message :: proc(panel: ^Search_Panel, message: string) { + delete(panel.message) + panel.message = strings.clone(message) +} + +find_bytes_from :: proc(text: []u8, query: []u8, start: int) -> (int, bool) { + if len(query) == 0 || len(query) > len(text) do return 0, false + i := clamp_int(start, 0, len(text) - len(query)) + for i <= len(text) - len(query) { + match := true + for j := 0; j < len(query); j += 1 { + if text[i + j] != query[j] { + match = false + break + } + } + if match do return i, true + i += 1 + } + return 0, false +} + +find_bytes_reverse_from :: proc(text: []u8, query: []u8, start: int) -> (int, bool) { + if len(query) == 0 || len(query) > len(text) do return 0, false + i := clamp_int(start, 0, len(text) - len(query)) + for i >= 0 { + match := true + for j := 0; j < len(query); j += 1 { + if text[i + j] != query[j] { + match = false + break + } + } + if match do return i, true + i -= 1 + } + return 0, false +} + +rename_panel_open :: proc(panel: ^Rename_Panel) { + panel.open = true + panel.pending_id = 0 + clear(&panel.input) + delete(panel.summary) + panel.summary = strings.clone("Enter new name, then Enter for preview") + for edit in panel.edits { + delete(edit) + } + clear(&panel.edits) +} + +rename_panel_insert :: proc(panel: ^Rename_Panel, text: string) { + for b in transmute([]u8)text { + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' { + append(&panel.input, b) + } + } +} + +rename_panel_backspace :: proc(panel: ^Rename_Panel) { + if len(panel.input) > 0 { + resize(&panel.input, len(panel.input) - 1) + } +} + +rename_panel_request :: proc(panel: ^Rename_Panel, editor: ^Editor, daemon: ^Daemon_Client) { + active := editor_active_buffer(editor) + if active == nil do return + if len(panel.input) == 0 { + delete(panel.summary) + panel.summary = strings.clone("New name is empty") + return + } + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + panel.pending_id = daemon_send_rename(daemon, active.path, line, column, string(panel.input[:])) + delete(panel.summary) + panel.summary = strings.clone("Loading rename preview...") +} + +rename_panel_apply_response :: proc(panel: ^Rename_Panel, daemon: ^Daemon_Client) { + if !panel.open || panel.pending_id == 0 do return + + response := daemon_take_response(daemon, panel.pending_id) + defer delete(response) + if len(response) == 0 do return + + summary, edits, ok := parse_rename_response(string(response[:]), panel.pending_id) + defer { + delete(summary) + for edit in edits { + delete(edit) + } + delete(edits) + } + if !ok do return + + panel.pending_id = 0 + delete(panel.summary) + panel.summary = strings.clone(summary) + for edit in panel.edits { + delete(edit) + } + clear(&panel.edits) + for edit in edits { + append(&panel.edits, strings.clone(edit)) + } +} + +parse_rename_response :: proc(response: string, expected_id: int) -> (string, [dynamic]string, bool) { + edits: [dynamic]string + message, value, ok := parse_protocol_message(response) + if !ok do return "", edits, false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return "", edits, false + + result, has_result := json_object_get(value, "result") + if !has_result do return "", edits, false + old_name, _ := json_get_string(result, "oldName") + new_name, _ := json_get_string(result, "newName") + edits_value, has_edits := json_object_get(result, "edits") + if !has_edits do return "", edits, false + + #partial switch items in edits_value { + case json.Array: + for item in items { + path, has_path := json_get_string(item, "path") + if !has_path do continue + line, _ := json_get_int(item, "line") + column, _ := json_get_int(item, "column") + _, file := os.split_path(path) + label := fmt.tprintf("%s:%d:%d", file, line, column) + append(&edits, strings.clone(label)) + } + } + + summary := fmt.tprintf("Preview: %s -> %s (%d edits)", old_name, new_name, len(edits)) + return strings.clone(summary), edits, true +} + +hover_tooltip_apply_response :: proc(hover: ^Hover_Tooltip, daemon: ^Daemon_Client) { + if !hover.open || hover.pending_id == 0 do return + + response := daemon_take_response(daemon, hover.pending_id) + defer delete(response) + if len(response) == 0 do return + + contents, ok := parse_hover_response(string(response[:]), hover.pending_id) + defer delete(contents) + if !ok do return + + hover.pending_id = 0 + if len(contents) == 0 { + hover_tooltip_set_contents(hover, "No hover information") + } else { + hover_tooltip_set_contents(hover, contents) + } +} + +hover_tooltip_set_contents :: proc(hover: ^Hover_Tooltip, contents: string) { + delete(hover.contents) + hover.contents = strings.clone(contents) +} + +parse_hover_response :: proc(response: string, expected_id: int) -> (string, bool) { + message, value, ok := parse_protocol_message(response) + if !ok do return "", false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return "", false + + result, has_result := json_object_get(value, "result") + if !has_result do return "", false + contents, has_contents := json_get_string(result, "contents") + if !has_contents do return "", true + return strings.clone(contents), true +} + +completion_popup_apply_response :: proc(popup: ^Completion_Popup, daemon: ^Daemon_Client) { + if !popup.open || popup.pending_id == 0 do return + + response := daemon_take_response(daemon, popup.pending_id) + defer delete(response) + if len(response) == 0 do return + + items, ok := parse_completion_response(string(response[:]), popup.pending_id) + defer { + for item in items { + delete(item.label) + delete(item.kind) + } + delete(items) + } + if !ok do return + + popup.pending_id = 0 + if len(items) == 0 { + completion_popup_set_message(popup, "No completions") + } else { + completion_popup_set_items(popup, items[:]) + } +} + +completion_popup_set_message :: proc(popup: ^Completion_Popup, message: string) { + completion_popup_set_items(popup, []Completion_Item{{label = message, kind = "status"}}) +} + +completion_popup_set_items :: proc(popup: ^Completion_Popup, items: []Completion_Item) { + for item in popup.items { + delete(item.label) + delete(item.kind) + } + clear(&popup.items) + for item in items { + append(&popup.items, Completion_Item{label = strings.clone(item.label), kind = strings.clone(item.kind)}) + } + popup.selected = 0 +} + +completion_popup_accept :: proc(popup: ^Completion_Popup, editor: ^Editor) -> bool { + if !popup.open || len(popup.items) == 0 do return false + active := editor_active_buffer(editor) + if active == nil do return false + + index := clamp_int(popup.selected, 0, len(popup.items) - 1) + item := popup.items[index] + if item.kind == "status" || len(item.label) == 0 do return false + + selection_start, selection_end, has_selection := editor_selection_range(active) + if has_selection { + buffer_replace_range(&active.buffer, selection_start, selection_end - selection_start, item.label) + active.cursor.offset = selection_start + len(item.label) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + editor_update_dirty(active) + editor_clear_selection(active) + popup.open = false + return true + } + + start := active.cursor.offset + for start > 0 { + bytes := buffer_range_bytes(&active.buffer, start - 1, 1) + if len(bytes) == 0 { + delete(bytes) + break + } + ch := bytes[0] + delete(bytes) + if !is_identifier_part_byte(ch) do break + start -= 1 + } + + if start < active.cursor.offset { + buffer_replace_range(&active.buffer, start, active.cursor.offset - start, item.label) + active.cursor.offset = start + active.cursor.offset += len(item.label) + } else { + buffer_replace_range(&active.buffer, active.cursor.offset, 0, item.label) + active.cursor.offset += len(item.label) + } + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + editor_update_dirty(active) + editor_clear_selection(active) + popup.open = false + return true +} + +parse_completion_response :: proc(response: string, expected_id: int) -> ([dynamic]Completion_Item, bool) { + labels: [dynamic]Completion_Item + + message, value, ok := parse_protocol_message(response) + if !ok do return labels, false + defer json.destroy_value(value) + if message.kind != .Response || message.id != expected_id || !message.ok do return labels, false + + result, has_result := json_object_get(value, "result") + if !has_result do return labels, false + items_value, has_items := json_object_get(result, "items") + if !has_items do return labels, false + + #partial switch items in items_value { + case json.Array: + for item in items { + label := "" + kind := "" + #partial switch value in item { + case json.String: + label = string(value) + case json.Object: + label, _ = json_get_string(item, "label") + kind, _ = json_get_string(item, "kind") + } + if len(label) > 0 { + append(&labels, Completion_Item{label = strings.clone(label), kind = strings.clone(kind)}) + } + } + } + + return labels, true +} + +project_tree_load :: proc(workspace: string) -> Project_Tree { + tree := Project_Tree{} + project_tree_append_dir(&tree, workspace, "", 0) + return tree +} + +project_tree_destroy :: proc(tree: ^Project_Tree) { + for file in tree.files { + delete(file.path) + delete(file.label) + } + delete(tree.files) + tree.files = nil +} + +workspace_reload :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) { + project_tree_destroy(tree) + tree^ = project_tree_load(workspace) + view.tree_first = 0 + editor_clear_all_diagnostics(editor) + daemon_send_workspace_open(daemon, workspace) + daemon_sync_now(sync, daemon, editor, true) + editor_set_status(editor, "Workspace reloaded") +} + +project_tree_append_dir :: proc(tree: ^Project_Tree, dir: string, prefix: string, depth: int) { + if depth > 5 || len(tree.files) >= 300 do return + + entries, err := os.read_all_directory_by_path(dir, context.allocator) + if err != nil do return + defer os.file_info_slice_delete(entries, context.allocator) + + for entry in entries { + if len(tree.files) >= 300 do return + if project_tree_skip(entry.name) do continue + + path := fmt.tprintf("%s/%s", dir, entry.name) + label := fmt.tprintf("%s%s", prefix, entry.name) + append(&tree.files, Project_File{ + path = strings.clone(path), + label = strings.clone(label), + depth = depth, + is_dir = entry.type == .Directory, + }) + + if entry.type == .Directory { + child_prefix := fmt.tprintf("%s ", prefix) + project_tree_append_dir(tree, path, child_prefix, depth + 1) + } + } +} + +project_tree_skip :: proc(name: string) -> bool { + return name == ".git" || name == ".gradle" || name == "build" || name == ".idea" +} + +set_editor_cursor :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32, default_cursor, text_cursor, resize_cursor: ^SDL.Cursor) { + if view.resizing_panel != .None || panel_resize_hit(view, tasks_panel, x, y) { + if resize_cursor != nil do _ = SDL.SetCursor(resize_cursor) + return + } + if editor_text_hit(view, tasks_panel, x, y) { + if text_cursor != nil do _ = SDL.SetCursor(text_cursor) + return + } + if default_cursor != nil do _ = SDL.SetCursor(default_cursor) +} + +panel_resize_hit :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if view.explorer_visible { + left_edge := f32(left_sidebar_width(view)) + if abs_f32(x - left_edge) <= SDL_RESIZE_HANDLE_WIDTH && y >= SDL_TOP_BAR_HEIGHT { + return true + } + } + + if tasks_panel != nil && tasks_panel.open { + right_edge := f32(view.window_width - right_sidebar_width_view(view, view.window_width)) + if abs_f32(x - right_edge) <= SDL_RESIZE_HANDLE_WIDTH && y >= SDL_TOP_BAR_HEIGHT { + return true + } + } + return false +} + +handle_panel_resize_down :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if view.explorer_visible { + left_edge := f32(left_sidebar_width(view)) + if abs_f32(x - left_edge) <= SDL_RESIZE_HANDLE_WIDTH && y >= SDL_TOP_BAR_HEIGHT { + view.resizing_panel = .Left_Sidebar + return true + } + } + + if tasks_panel != nil && tasks_panel.open { + right_edge := f32(view.window_width - right_sidebar_width_view(view, view.window_width)) + if abs_f32(x - right_edge) <= SDL_RESIZE_HANDLE_WIDTH && y >= SDL_TOP_BAR_HEIGHT { + view.resizing_panel = .Right_Sidebar + return true + } + } + return false +} + +editor_text_hit :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if y < SDL_EDITOR_TEXT_Y || y >= f32(view.window_height - SDL_STATUS_BAR_HEIGHT) do return false + if x < f32(editor_text_x(view)) do return false + + right_limit := f32(view.window_width - 8) + if tasks_panel != nil && tasks_panel.open { + right_limit = f32(view.window_width - right_sidebar_width_view(view, view.window_width) - 8) + } + return x < right_limit +} + +handle_panel_resize_motion :: proc(view: ^SDL_View, x: f32) { + #partial switch view.resizing_panel { + case .Left_Sidebar: + max_width := min_int(SDL_SIDEBAR_MAX_WIDTH, max_int(view.window_width - view.right_sidebar_width - 260, SDL_SIDEBAR_MIN_WIDTH)) + view.sidebar_width = clamp_int(int(x), SDL_SIDEBAR_MIN_WIDTH, max_width) + case .Right_Sidebar: + available := view.window_width - left_sidebar_width(view) - 220 + max_width := min_int(SDL_RIGHT_SIDEBAR_MAX_WIDTH, max_int(available, SDL_RIGHT_SIDEBAR_MIN_WIDTH)) + view.right_sidebar_width = clamp_int(view.window_width - int(x), SDL_RIGHT_SIDEBAR_MIN_WIDTH, max_width) + } +} + +handle_project_tree_click :: proc(editor: ^Editor, tree: ^Project_Tree, view: ^SDL_View, x, y: f32) -> bool { + if x >= f32(left_sidebar_width(view)) do return false + if y < SDL_TREE_FIRST_Y do return false + index := view.tree_first + int((y - SDL_TREE_FIRST_Y) / SDL_TREE_ROW_HEIGHT) + if index < 0 || index >= len(tree.files) do return false + + item := tree.files[index] + if item.is_dir do return false + if !editor_open_or_focus_file(editor, item.path) do return false + view.first_line = 0 + return true +} + +handle_editor_tab_click :: proc(editor: ^Editor, view: ^SDL_View, x, y: f32) -> bool { + index, tab_x, width, ok := editor_tab_hit(editor, view, x, y) + if !ok do return false + + if x >= tab_x + width - 28 { + _ = editor_close_buffer(editor, index) + view.tab_dragging = false + return true + } + editor.active = index + view.tab_dragging = true + view.tab_drag_index = index + return true +} + +handle_editor_tab_drag :: proc(editor: ^Editor, view: ^SDL_View, x, y: f32) { + if !view.tab_dragging || len(editor.buffers) <= 1 do return + target, _, _, ok := editor_tab_hit(editor, view, x, y) + if !ok || target == view.tab_drag_index do return + + editor_move_buffer(editor, view.tab_drag_index, target) + view.tab_drag_index = target +} + +handle_command_palette_click :: proc(palette: ^Command_Palette, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, completion: ^Completion_Popup, hover: ^Hover_Tooltip, definition: ^Definition_Jump, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel, diagnostics_panel: ^Diagnostics_Panel, tasks_panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State, x, y: f32) -> bool { + if !palette.open do return false + + width := f32(min_int(max_int(view.window_width - 280, 420), 720)) + palette_x := f32(view.window_width) * 0.5 - width * 0.5 + palette_y: f32 = SDL_TOP_BAR_HEIGHT + 48 + match_count := command_palette_match_count(palette) + visible := min_int(max_int(match_count, 1), 8) + height := f32(58 + visible * 24) + if x < palette_x || x >= palette_x + width || y < palette_y || y >= palette_y + height do return false + if y < palette_y + 68 do return true + if match_count == 0 do return true + + first_seen := 0 + if palette.selected >= 8 { + first_seen = palette.selected - 7 + } + row := int((y - (palette_y + 68)) / 24) + target_seen := first_seen + row + if target_seen >= 0 && target_seen < match_count { + palette.selected = target_seen + command_palette_accept(palette, editor, view, tree, workspace, completion, hover, definition, references, rename, search, diagnostics_panel, tasks_panel, daemon, sync) + } + return true +} + +handle_completion_click :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, popup: ^Completion_Popup, hover: ^Hover_Tooltip, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel, sync: ^Daemon_Sync_State, x, y: f32) -> bool { + if !popup.open do return false + active := editor_active_buffer(editor) + if active == nil do return false + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + popup_x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, cursor_line, cursor_col) + popup_y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 1) * SDL_LINE_HEIGHT) + if popup_y > f32(view.window_height - 110) do popup_y = f32(view.window_height - 110) + + visible_items := min_int(len(popup.items), 8) + if visible_items <= 0 do return x >= popup_x && x < popup_x + 260 && y >= popup_y && y < popup_y + 26 + height := f32(26 + visible_items * 16) + if x < popup_x || x >= popup_x + 260 || y < popup_y || y >= popup_y + height do return false + if y < popup_y + 22 do return true + + first_index := 0 + if popup.selected >= 8 { + first_index = popup.selected - 7 + } + row := int((y - (popup_y + 22)) / 16) + index := first_index + row + if index >= 0 && index < len(popup.items) { + popup.selected = index + if completion_popup_accept(popup, editor) { + close_completion_accept_overlays(popup, hover, references, rename, search) + ensure_cursor_visible(editor, view) + daemon_sync_schedule(sync) + } + } + return true +} + +handle_references_click :: proc(panel: ^References_Panel, navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View, x, y: f32) -> bool { + if !panel.open do return false + + popup_x: f32 = 700 + popup_y: f32 = 72 + visible := min_int(max_int(len(panel.items), 1), 14) + height := f32(30 + visible * 16) + if x < popup_x || x >= popup_x + 380 || y < popup_y || y >= popup_y + height do return false + if y < popup_y + 28 do return true + + first_index := 0 + if panel.selected >= 14 { + first_index = panel.selected - 13 + } + row := int((y - (popup_y + 28)) / 16) + index := first_index + row + if index >= 0 && index < len(panel.items) { + panel.selected = index + references_panel_accept(panel, navigation, editor, view) + } + return true +} + +handle_diagnostics_click :: proc(panel: ^Diagnostics_Panel, editor: ^Editor, view: ^SDL_View, x, y: f32) -> bool { + if !panel.open do return false + active := editor_active_buffer(editor) + if active == nil do return false + + height: f32 = 168 + sidebar_width := left_sidebar_width(view) + panel_x := f32(sidebar_width) + panel_y := f32(view.window_height - SDL_STATUS_BAR_HEIGHT) - height + width := f32(view.window_width - sidebar_width) + if x < panel_x || x >= panel_x + width || y < panel_y || y >= panel_y + height do return false + if y < panel_y + 38 do return true + + if len(active.diagnostics) == 0 do return true + panel.selected = clamp_int(panel.selected, 0, len(active.diagnostics) - 1) + visible_items := 7 + first_index := 0 + if panel.selected >= visible_items { + first_index = panel.selected - visible_items + 1 + } + row := int((y - (panel_y + 38)) / 18) + index := first_index + row + if index >= 0 && index < len(active.diagnostics) && index < first_index + visible_items { + panel.selected = index + diagnostics_panel_accept(panel, editor, view) + } + return true +} + +handle_gradle_tasks_click :: proc(view: ^SDL_View, panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if !panel.open do return false + + width := f32(right_sidebar_width_view(view, view.window_width)) + height := f32(view.window_height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + panel_x := f32(view.window_width) - width + panel_y: f32 = SDL_TOP_BAR_HEIGHT + if x < panel_x || x >= panel_x + width || y < panel_y || y >= panel_y + height do return false + + output_height: f32 = 0 + if len(panel.output) > 0 { + output_height = min_f32(160, max_f32(72, height * 0.32)) + } + visible_items := max_int(int((height - output_height - 104) / 18), 1) + if len(panel.items) == 0 || y < panel_y + 92 do return true + + panel.selected = clamp_int(panel.selected, 0, len(panel.items) - 1) + first_index := 0 + if panel.selected >= visible_items { + first_index = panel.selected - visible_items + 1 + } + row := int((y - (panel_y + 92)) / 18) + index := first_index + row + if index >= 0 && index < len(panel.items) && index < first_index + visible_items { + panel.selected = index + } + return true +} + +handle_gradle_tasks_wheel :: proc(view: ^SDL_View, panel: ^Gradle_Tasks_Panel, x, y: f32, wheel_y: int) -> bool { + if !panel.open do return false + width := f32(right_sidebar_width_view(view, view.window_width)) + panel_x := f32(view.window_width) - width + panel_y: f32 = SDL_TOP_BAR_HEIGHT + panel_height := f32(view.window_height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + if x < panel_x || x >= panel_x + width || y < panel_y || y >= panel_y + panel_height do return false + if len(panel.items) == 0 do return true + + panel.selected = clamp_int(panel.selected - wheel_y * 3, 0, len(panel.items) - 1) + return true +} + +handle_overlay_outside_click :: proc(command_palette: ^Command_Palette, completion: ^Completion_Popup, references: ^References_Panel, diagnostics_panel: ^Diagnostics_Panel) -> bool { + if command_palette.open { + command_palette.open = false + return true + } + if completion.open { + completion.open = false + return true + } + if references.open { + references.open = false + return true + } + if diagnostics_panel.open { + diagnostics_panel.open = false + return true + } + return false +} + +handle_overlay_wheel :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, command_palette: ^Command_Palette, completion: ^Completion_Popup, references: ^References_Panel, diagnostics_panel: ^Diagnostics_Panel, x, y: f32, wheel_y: int) -> bool { + if command_palette.open { + width := f32(min_int(max_int(view.window_width - 280, 420), 720)) + palette_x := f32(view.window_width) * 0.5 - width * 0.5 + palette_y: f32 = SDL_TOP_BAR_HEIGHT + 48 + visible := min_int(max_int(command_palette_match_count(command_palette), 1), 8) + height := f32(58 + visible * 24) + if x >= palette_x && x < palette_x + width && y >= palette_y && y < palette_y + height { + max_selected := max_int(command_palette_match_count(command_palette) - 1, 0) + command_palette.selected = clamp_int(command_palette.selected - wheel_y * 3, 0, max_selected) + return true + } + } + + if completion.open { + active := editor_active_buffer(editor) + if active != nil { + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + popup_x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, cursor_line, cursor_col) + popup_y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 1) * SDL_LINE_HEIGHT) + if popup_y > f32(view.window_height - 110) do popup_y = f32(view.window_height - 110) + visible_items := min_int(len(completion.items), 8) + height := f32(26 + visible_items * 16) + if x >= popup_x && x < popup_x + 260 && y >= popup_y && y < popup_y + height { + completion.selected = clamp_int(completion.selected - wheel_y * 3, 0, max_int(len(completion.items) - 1, 0)) + return true + } + } + } + + if references.open { + popup_x: f32 = 700 + popup_y: f32 = 72 + visible := min_int(max_int(len(references.items), 1), 14) + height := f32(30 + visible * 16) + if x >= popup_x && x < popup_x + 380 && y >= popup_y && y < popup_y + height { + references.selected = clamp_int(references.selected - wheel_y * 3, 0, max_int(len(references.items) - 1, 0)) + return true + } + } + + if diagnostics_panel.open { + active := editor_active_buffer(editor) + if active != nil { + height: f32 = 168 + sidebar_width := left_sidebar_width(view) + panel_x := f32(sidebar_width) + panel_y := f32(view.window_height - SDL_STATUS_BAR_HEIGHT) - height + width := f32(view.window_width - sidebar_width) + if x >= panel_x && x < panel_x + width && y >= panel_y && y < panel_y + height { + diagnostics_panel.selected = clamp_int(diagnostics_panel.selected - wheel_y * 3, 0, max_int(len(active.diagnostics) - 1, 0)) + return true + } + } + } + + return false +} + +editor_tab_hit :: proc(editor: ^Editor, view: ^SDL_View, x, y: f32) -> (int, f32, f32, bool) { + sidebar_width := left_sidebar_width(view) + if x < f32(sidebar_width) || y < SDL_TOP_BAR_HEIGHT || y >= SDL_EDITOR_TOP do return 0, 0, 0, false + + tab_x := f32(sidebar_width + 12) + max_x := f32(view.window_width - 12) + for &buffer, index in editor.buffers { + if tab_x >= max_x do break + _, file_name := os.split_path(buffer.path) + width := f32(tab_width_for_label(file_name)) + if tab_x + width > max_x { + width = max_x - tab_x + } + if x >= tab_x && x < tab_x + width { + return index, tab_x, width, true + } + tab_x += width + 4 + } + return 0, 0, 0, false +} + +editor_move_buffer :: proc(editor: ^Editor, from, to: int) -> bool { + if from < 0 || from >= len(editor.buffers) || to < 0 || to >= len(editor.buffers) || from == to do return false + buffer := editor.buffers[from] + ordered_remove(&editor.buffers, from) + inject_at(&editor.buffers, to, buffer) + editor.active = to + return true +} + +tab_width_for_label :: proc(label: string) -> int { + return max_int(150, min_int(280, gpu_text_width(label) + 54)) +} + +handle_editor_text_mouse :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, x, y: f32, extend: bool) -> bool { + active := editor_active_buffer(editor) + if active == nil do return false + + text_x := f32(editor_text_x(view)) + text_y: f32 = SDL_EDITOR_TEXT_Y + if x < text_x || y < text_y do return false + + line := view.first_line + int((y - text_y) / SDL_LINE_HEIGHT) + if line < 0 || line >= buffer_line_count(&active.buffer) do return false + + column := text_column_from_pixel_x_gpu(gpu, &active.buffer, line, x - text_x) + if extend { + editor_start_selection(active) + } else { + editor_clear_selection(active) + } + cursor_move_to_line_col(&active.buffer, &active.cursor, line, max_int(column, 0)) + if !extend { + active.selection_anchor = active.cursor.offset + } + ensure_cursor_visible(editor, view) + return true +} + +scroll_project_tree :: proc(tree: ^Project_Tree, view: ^SDL_View, delta: int) { + max_first := max_int(len(tree.files) - visible_tree_rows(view), 0) + view.tree_first = clamp_int(view.tree_first + delta, 0, max_first) +} + +handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, completion: ^Completion_Popup, hover: ^Hover_Tooltip, definition: ^Definition_Jump, navigation: ^Navigation_History, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel, diagnostics_panel: ^Diagnostics_Panel, command_palette: ^Command_Palette, tasks_panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State, key: SDL.Keycode, mod: SDL.Keymod) -> bool { + active := editor_active_buffer(editor) + if active == nil do return false + + if command_palette.open { + switch key { + case SDL.K_ESCAPE: + command_palette.open = false + return false + case SDL.K_BACKSPACE: + command_palette_backspace(command_palette) + return false + case SDL.K_UP: + command_palette.selected = max_int(command_palette.selected - 1, 0) + return false + case SDL.K_DOWN: + command_palette.selected = min_int(command_palette.selected + 1, max_int(command_palette_match_count(command_palette) - 1, 0)) + return false + case SDL.K_PAGEUP: + command_palette.selected = max_int(command_palette.selected - 8, 0) + return false + case SDL.K_PAGEDOWN: + command_palette.selected = min_int(command_palette.selected + 8, max_int(command_palette_match_count(command_palette) - 1, 0)) + return false + case SDL.K_HOME: + command_palette.selected = 0 + return false + case SDL.K_END: + command_palette.selected = max_int(command_palette_match_count(command_palette) - 1, 0) + return false + case SDL.K_RETURN: + command_palette_accept(command_palette, editor, view, tree, workspace, completion, hover, definition, references, rename, search, diagnostics_panel, tasks_panel, daemon, sync) + return false + } + } + + if rename.open { + switch key { + case SDL.K_ESCAPE: + rename.open = false + return false + case SDL.K_BACKSPACE: + rename_panel_backspace(rename) + return false + case SDL.K_RETURN: + daemon_sync_now(sync, daemon, editor, false) + rename_panel_request(rename, editor, daemon) + return false + } + } + if search.open { + switch key { + case SDL.K_ESCAPE: + search.open = false + return false + case SDL.K_BACKSPACE: + search_panel_backspace(search) + return false + case SDL.K_RETURN: + if (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + search_panel_find_previous(search, editor, view) + } else { + search_panel_find_next(search, editor, view) + } + return false + } + } + if diagnostics_panel.open { + active_for_diagnostics := editor_active_buffer(editor) + diagnostic_count := 0 if active_for_diagnostics == nil else len(active_for_diagnostics.diagnostics) + switch key { + case SDL.K_ESCAPE: + diagnostics_panel.open = false + return false + case SDL.K_UP: + diagnostics_panel.selected = max_int(diagnostics_panel.selected - 1, 0) + return false + case SDL.K_DOWN: + diagnostics_panel.selected = min_int(diagnostics_panel.selected + 1, max_int(diagnostic_count - 1, 0)) + return false + case SDL.K_PAGEUP: + diagnostics_panel.selected = max_int(diagnostics_panel.selected - 7, 0) + return false + case SDL.K_PAGEDOWN: + diagnostics_panel.selected = min_int(diagnostics_panel.selected + 7, max_int(diagnostic_count - 1, 0)) + return false + case SDL.K_HOME: + diagnostics_panel.selected = 0 + return false + case SDL.K_END: + diagnostics_panel.selected = max_int(diagnostic_count - 1, 0) + return false + case SDL.K_RETURN: + diagnostics_panel_accept(diagnostics_panel, editor, view) + return false + } + } + if tasks_panel.open { + switch key { + case SDL.K_ESCAPE: + tasks_panel.open = false + view.gradle_sidebar_visible = false + ui_state_save(view) + return false + case SDL.K_UP: + tasks_panel.selected = max_int(tasks_panel.selected - 1, 0) + return false + case SDL.K_DOWN: + tasks_panel.selected = min_int(tasks_panel.selected + 1, max_int(len(tasks_panel.items) - 1, 0)) + return false + case SDL.K_PAGEUP: + tasks_panel.selected = max_int(tasks_panel.selected - 10, 0) + return false + case SDL.K_PAGEDOWN: + tasks_panel.selected = min_int(tasks_panel.selected + 10, max_int(len(tasks_panel.items) - 1, 0)) + return false + case SDL.K_HOME: + tasks_panel.selected = 0 + return false + case SDL.K_END: + tasks_panel.selected = max_int(len(tasks_panel.items) - 1, 0) + return false + case SDL.K_RETURN: + gradle_tasks_panel_run_selected(tasks_panel, daemon) + return false + } + } + if key == SDL.K_LEFT && (mod & SDL.KMOD_ALT) != SDL.KMOD_NONE { + navigation_go_back(navigation, editor, view) + return false + } + if key == SDL.K_RIGHT && (mod & SDL.KMOD_ALT) != SDL.KMOD_NONE { + navigation_go_forward(navigation, editor, view) + return false + } + if key == SDL.K_SPACE && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + daemon_sync_now(sync, daemon, editor, false) + completion_popup_open(completion, editor, daemon) + return false + } + if key == SDL.K_H && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + daemon_sync_now(sync, daemon, editor, false) + hover_tooltip_open(hover, editor, daemon) + return false + } + if key == SDL.K_B && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + daemon_sync_now(sync, daemon, editor, false) + definition_jump_request(definition, editor, daemon) + return false + } + if key == SDL.K_R && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + rename_panel_open(rename) + return false + } + if key == SDL.K_R && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + daemon_sync_now(sync, daemon, editor, false) + references_panel_request(references, editor, daemon) + return false + } + if key == SDL.K_F && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + search_panel_open(search) + return false + } + if key == SDL.K_P && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + command_palette_open(command_palette) + return false + } + if key == SDL.K_E && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + view.explorer_visible = !view.explorer_visible + view.tree_first = 0 + ui_state_save(view) + return false + } + if key == SDL.K_T && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + if tasks_panel.open { + tasks_panel.open = false + view.gradle_sidebar_visible = false + } else { + view.gradle_sidebar_visible = true + daemon_sync_now(sync, daemon, editor, false) + gradle_tasks_panel_request(tasks_panel, daemon) + } + ui_state_save(view) + return false + } + if key == SDL.K_M && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + diagnostics_panel.open = !diagnostics_panel.open + diagnostics_panel.selected = 0 + return false + } + if key == SDL.K_M && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + view.show_metrics = !view.show_metrics + return false + } + if key == SDL.K_F8 && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + editor_jump_diagnostic(editor, view, -1) + return false + } + if key == SDL.K_F8 { + editor_jump_diagnostic(editor, view, 1) + return false + } + if references.open { + switch key { + case SDL.K_ESCAPE: + references.open = false + return false + case SDL.K_UP: + references.selected = max_int(references.selected - 1, 0) + return false + case SDL.K_DOWN: + references.selected = min_int(references.selected + 1, max_int(len(references.items) - 1, 0)) + return false + case SDL.K_PAGEUP: + references.selected = max_int(references.selected - 14, 0) + return false + case SDL.K_PAGEDOWN: + references.selected = min_int(references.selected + 14, max_int(len(references.items) - 1, 0)) + return false + case SDL.K_HOME: + references.selected = 0 + return false + case SDL.K_END: + references.selected = max_int(len(references.items) - 1, 0) + return false + case SDL.K_RETURN: + references_panel_accept(references, navigation, editor, view) + return false + } + } + if completion.open { + switch key { + case SDL.K_ESCAPE: + completion.open = false + return false + case SDL.K_UP: + completion.selected = max_int(completion.selected - 1, 0) + return false + case SDL.K_DOWN: + completion.selected = min_int(completion.selected + 1, max_int(len(completion.items) - 1, 0)) + return false + case SDL.K_PAGEUP: + completion.selected = max_int(completion.selected - 8, 0) + return false + case SDL.K_PAGEDOWN: + completion.selected = min_int(completion.selected + 8, max_int(len(completion.items) - 1, 0)) + return false + case SDL.K_HOME: + completion.selected = 0 + return false + case SDL.K_END: + completion.selected = max_int(len(completion.items) - 1, 0) + return false + case SDL.K_RETURN: + if completion_popup_accept(completion, editor) { + close_completion_accept_overlays(completion, hover, references, rename, search) + ensure_cursor_visible(editor, view) + daemon_sync_schedule(sync) + return true + } + completion.open = false + return false + } + } + + if key == SDL.K_S && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + _ = editor_save_all(editor) + daemon_sync_now(sync, daemon, editor, false) + return false + } + if key == SDL.K_S && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + _ = editor_save_active(editor) + daemon_sync_now(sync, daemon, editor, false) + return false + } + if key == SDL.K_W && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + if editor_close_active(editor) { + scroll_sdl_view(editor, view, 0) + } + return false + } + if key == SDL.K_Z && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + if buffer_undo(&active.buffer, &active.cursor) { + editor_update_dirty(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + return false + } + if key == SDL.K_Y && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + if buffer_redo(&active.buffer, &active.cursor) { + editor_update_dirty(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + return false + } + if key == SDL.K_C && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + editor_copy_selection_to_clipboard(active) + return false + } + if key == SDL.K_X && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + if editor_copy_selection_to_clipboard(active) && editor_delete_selection(active) { + editor_update_dirty(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + return false + } + if key == SDL.K_V && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + if editor_paste_clipboard(active) { + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + return false + } + if key == SDL.K_LEFT && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + prepare_selection_for_keyboard_move(active, mod) + cursor_move_word_left(&active.buffer, &active.cursor) + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + return false + } + if key == SDL.K_RIGHT && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + prepare_selection_for_keyboard_move(active, mod) + cursor_move_word_right(&active.buffer, &active.cursor) + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + return false + } + + switch key { + case SDL.K_ESCAPE: + // Window close is handled by the window manager; escape currently only clears intent. + case SDL.K_RETURN: + editor_insert_newline_auto_indent(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + case SDL.K_TAB: + if (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + if editor_outdent(active) { + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + return false + } + editor_insert_text(active, " ") + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + case SDL.K_BACKSPACE: + if !editor_delete_selection(active) { + cursor_backspace(&active.buffer, &active.cursor) + } + editor_clear_selection(active) + editor_update_dirty(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + case SDL.K_DELETE: + if editor_delete_selection(active) || cursor_delete_forward(&active.buffer, &active.cursor) { + editor_clear_selection(active) + editor_update_dirty(active) + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + return true + } + case SDL.K_UP: + prepare_selection_for_keyboard_move(active, mod) + cursor_move_vertical(&active.buffer, &active.cursor, -1) + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + case SDL.K_DOWN: + prepare_selection_for_keyboard_move(active, mod) + cursor_move_vertical(&active.buffer, &active.cursor, 1) + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + case SDL.K_LEFT: + prepare_selection_for_keyboard_move(active, mod) + if active.cursor.offset > 0 { + active.cursor.offset -= 1 + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + } + finish_selection_for_keyboard_move(active, mod) + case SDL.K_RIGHT: + prepare_selection_for_keyboard_move(active, mod) + if active.cursor.offset < buffer_len(&active.buffer) { + active.cursor.offset += 1 + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + } + finish_selection_for_keyboard_move(active, mod) + case SDL.K_HOME: + prepare_selection_for_keyboard_move(active, mod) + line, _ := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + active.cursor.offset = buffer_line_start(&active.buffer, line) + active.cursor.wanted_column = 0 + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + case SDL.K_END: + prepare_selection_for_keyboard_move(active, mod) + line, _ := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + active.cursor.offset = buffer_line_end(&active.buffer, line) + _, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + finish_selection_for_keyboard_move(active, mod) + ensure_cursor_visible(editor, view) + case SDL.K_PAGEUP: + prepare_selection_for_keyboard_move(active, mod) + page_delta := max_int(visible_editor_lines(view) - 2, 1) + cursor_move_vertical(&active.buffer, &active.cursor, -page_delta) + finish_selection_for_keyboard_move(active, mod) + scroll_sdl_view(editor, view, -page_delta) + ensure_cursor_visible(editor, view) + case SDL.K_PAGEDOWN: + prepare_selection_for_keyboard_move(active, mod) + page_delta := max_int(visible_editor_lines(view) - 2, 1) + cursor_move_vertical(&active.buffer, &active.cursor, page_delta) + finish_selection_for_keyboard_move(active, mod) + scroll_sdl_view(editor, view, page_delta) + ensure_cursor_visible(editor, view) + } + + return false +} + +editor_copy_selection_to_clipboard :: proc(active: ^Editor_Buffer) -> bool { + start, end, ok := editor_selection_range(active) + if !ok do return false + + bytes := buffer_range_bytes(&active.buffer, start, end - start) + defer delete(bytes) + text := string(bytes[:]) + c_text, err := strings.clone_to_cstring(text, context.temp_allocator) + if err != nil do return false + return SDL.SetClipboardText(c_text) +} + +editor_paste_clipboard :: proc(active: ^Editor_Buffer) -> bool { + if !SDL.HasClipboardText() do return false + raw := SDL.GetClipboardText() + if raw == nil do return false + defer SDL.free(raw) + + text := string(cstring(raw)) + if len(text) == 0 do return false + editor_insert_text(active, text) + return true +} + +editor_jump_diagnostic :: proc(editor: ^Editor, view: ^SDL_View, direction: int) { + active := editor_active_buffer(editor) + if active == nil || len(active.diagnostics) == 0 do return + + cursor_line, cursor_column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + best_index := -1 + best_distance := 1 << 30 + for diagnostic, index in active.diagnostics { + distance: int + if direction >= 0 { + if diagnostic.line > cursor_line || (diagnostic.line == cursor_line && diagnostic.column > cursor_column) { + distance = (diagnostic.line - cursor_line) * 100000 + diagnostic.column - cursor_column + } else { + distance = (buffer_line_count(&active.buffer) + diagnostic.line - cursor_line) * 100000 + diagnostic.column + } + } else { + if diagnostic.line < cursor_line || (diagnostic.line == cursor_line && diagnostic.column < cursor_column) { + distance = (cursor_line - diagnostic.line) * 100000 + cursor_column - diagnostic.column + } else { + distance = (buffer_line_count(&active.buffer) + cursor_line - diagnostic.line) * 100000 + cursor_column + } + } + if distance < best_distance { + best_distance = distance + best_index = index + } + } + if best_index < 0 do return + + target := active.diagnostics[best_index] + cursor_move_to_line_col(&active.buffer, &active.cursor, target.line, target.column) + editor_clear_selection(active) + ensure_cursor_visible(editor, view) +} + +diagnostics_panel_accept :: proc(panel: ^Diagnostics_Panel, editor: ^Editor, view: ^SDL_View) { + active := editor_active_buffer(editor) + if active == nil || len(active.diagnostics) == 0 do return + index := clamp_int(panel.selected, 0, len(active.diagnostics) - 1) + diagnostic := active.diagnostics[index] + cursor_move_to_line_col(&active.buffer, &active.cursor, diagnostic.line, diagnostic.column) + editor_clear_selection(active) + ensure_cursor_visible(editor, view) +} + +prepare_selection_for_keyboard_move :: proc(active: ^Editor_Buffer, mod: SDL.Keymod) { + if (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + editor_start_selection(active) + } +} + +finish_selection_for_keyboard_move :: proc(active: ^Editor_Buffer, mod: SDL.Keymod) { + if (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE { + start, end, ok := editor_selection_range(active) + if !ok || start == end { + editor_clear_selection(active) + } + } else { + editor_clear_selection(active) + } +} + +scroll_sdl_view :: proc(editor: ^Editor, view: ^SDL_View, delta: int) { + active := editor_active_buffer(editor) + if active == nil do return + + max_first := max_int(buffer_line_count(&active.buffer) - visible_editor_lines(view), 0) + view.first_line = clamp_int(view.first_line + delta, 0, max_first) +} + +ensure_cursor_visible :: proc(editor: ^Editor, view: ^SDL_View) { + active := editor_active_buffer(editor) + if active == nil do return + + line, _ := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + visible_lines := visible_editor_lines(view) + if line < view.first_line { + view.first_line = line + } else if line >= view.first_line + visible_lines { + view.first_line = line - visible_lines + 1 + } + scroll_sdl_view(editor, view, 0) +} + +refresh_view_size :: proc(window: ^SDL.Window, view: ^SDL_View) { + w, h: i32 + if SDL.GetWindowSize(window, &w, &h) { + view.window_width = max_int(int(w), 1) + view.window_height = max_int(int(h), 1) + } +} + +visible_editor_lines :: proc(view: ^SDL_View) -> int { + available := view.window_height - SDL_EDITOR_TEXT_Y - SDL_STATUS_BAR_HEIGHT - 8 + return max_int(available / SDL_LINE_HEIGHT, 1) +} + +visible_tree_rows :: proc(view: ^SDL_View) -> int { + available := view.window_height - SDL_TREE_FIRST_Y - SDL_STATUS_BAR_HEIGHT - 8 + return max_int(available / SDL_TREE_ROW_HEIGHT, 1) +} + +render_sdl_editor :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, completion: ^Completion_Popup, hover: ^Hover_Tooltip, references: ^References_Panel, rename: ^Rename_Panel) { + active := editor_active_buffer(editor) + if active == nil do return + + _ = SDL.SetRenderDrawColor(renderer, 15, 17, 22, 255) + _ = SDL.RenderClear(renderer) + + render_project_tree(renderer, tree, view, active.path) + + _ = SDL.SetRenderDrawColor(renderer, 37, 41, 54, 255) + sidebar_width := left_sidebar_width(view) + text_x := editor_text_x(view) + gutter := SDL.FRect{f32(sidebar_width), 0, 72, f32(view.window_height)} + _ = SDL.RenderFillRect(renderer, &gutter) + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + line_height: f32 = 16 + y: f32 = 18 + + dirty_marker := "" if !active.dirty else "*" + _ = SDL.SetRenderDrawColor(renderer, 180, 190, 210, 255) + title := fmt.tprintf("%s%s v%d", active.path, dirty_marker, active.buffer.version) + render_debug_text_limited(renderer, f32(sidebar_width + 84), 4, title, 92) + + for screen_line := 0; screen_line < visible_editor_lines(view); screen_line += 1 { + line_index := view.first_line + screen_line + if line_index >= buffer_line_count(&active.buffer) do break + + bytes := buffer_line_bytes(&active.buffer, line_index) + line_text := string(bytes[:]) + + gutter_text := fmt.ctprintf("%4d", line_index + 1) + diagnostic, has_diagnostic := editor_diagnostic_on_line(editor, line_index) + if line_index == cursor_line { + marker := SDL.FRect{f32(sidebar_width), y - 2, f32(view.window_width - sidebar_width), line_height} + _ = SDL.SetRenderDrawColor(renderer, 29, 34, 48, 255) + _ = SDL.RenderFillRect(renderer, &marker) + _ = SDL.SetRenderDrawColor(renderer, 138, 180, 248, 255) + } else if has_diagnostic { + _ = SDL.SetRenderDrawColor(renderer, 235, 95, 95, 255) + } else { + _ = SDL.SetRenderDrawColor(renderer, 103, 111, 135, 255) + } + _ = SDL.RenderDebugText(renderer, f32(sidebar_width + 20), y, gutter_text) + if has_diagnostic { + _ = SDL.RenderDebugText(renderer, f32(sidebar_width + 62), y, "!") + } + + render_selection_for_line(renderer, view, active, line_index, y) + + _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) + render_debug_text_limited(renderer, f32(text_x), y, line_text, visible_text_columns(view)) + + if line_index == cursor_line { + cursor_x := f32(text_x + cursor_col * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE) + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + _ = SDL.RenderLine(renderer, cursor_x, y - 2, cursor_x, y + line_height - 2) + } + + delete(bytes) + y += line_height + } + + if len(editor.status) > 0 { + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + status := fmt.ctprintf("%s", editor.status) + _ = SDL.RenderDebugText(renderer, f32(sidebar_width + 84), 734, status) + } else if len(active.diagnostics) > 0 { + first := active.diagnostics[0] + errors, warnings, infos := editor_diagnostic_counts(active) + status := fmt.tprintf("%d errors %d warnings %d info | %s %d:%d %s", errors, warnings, infos, first.severity, first.line + 1, first.column + 1, first.message) + _ = SDL.SetRenderDrawColor(renderer, 235, 95, 95, 255) + render_debug_text_limited(renderer, f32(sidebar_width + 84), 734, status, 92) + } else if active.dirty { + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + _ = SDL.RenderDebugText(renderer, f32(sidebar_width + 84), 734, "modified - Ctrl+S to save") + } else { + _ = SDL.SetRenderDrawColor(renderer, 120, 210, 150, 255) + _ = SDL.RenderDebugText(renderer, f32(sidebar_width + 84), 734, "saved") + } + + render_completion_popup(renderer, editor, view, completion) + render_hover_tooltip(renderer, editor, view, hover) + render_references_panel(renderer, references) + render_rename_panel(renderer, rename) + + _ = SDL.RenderPresent(renderer) +} + +render_sdl_editor_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, completion: ^Completion_Popup, hover: ^Hover_Tooltip, references: ^References_Panel, rename: ^Rename_Panel, search: ^Search_Panel, diagnostics_panel: ^Diagnostics_Panel, command_palette: ^Command_Palette, tasks_panel: ^Gradle_Tasks_Panel) { + active := editor_active_buffer(editor) + if active == nil do return + + gpu_begin(gpu) + gpu_rect(gpu, 0, 0, f32(gpu.width), f32(gpu.height), 30, 31, 34, 255) + render_top_bar_gpu(gpu, active) + sidebar_width := left_sidebar_width(view) + text_x := editor_text_x(view) + render_project_tree_gpu(gpu, tree, view, active.path) + + editor_height := f32(gpu.height - SDL_EDITOR_TOP - SDL_STATUS_BAR_HEIGHT) + gpu_rect(gpu, f32(sidebar_width), SDL_TOP_BAR_HEIGHT, f32(gpu.width - sidebar_width), SDL_TAB_BAR_HEIGHT, 37, 38, 43, 255) + gpu_rect(gpu, f32(sidebar_width), SDL_EDITOR_TOP, f32(SDL_GUTTER_WIDTH), editor_height, 32, 33, 36, 255) + gpu_rect(gpu, f32(sidebar_width + SDL_GUTTER_WIDTH), SDL_EDITOR_TOP, f32(gpu.width - sidebar_width - SDL_GUTTER_WIDTH), editor_height, 27, 28, 32, 255) + gpu_rect(gpu, f32(sidebar_width - 1), SDL_TOP_BAR_HEIGHT, 1, f32(gpu.height - SDL_TOP_BAR_HEIGHT), 48, 50, 56, 255) + gpu_rect(gpu, f32(sidebar_width + SDL_GUTTER_WIDTH - 1), SDL_EDITOR_TOP, 1, editor_height, 43, 45, 51, 255) + + render_editor_tabs_gpu(gpu, editor, view) + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + line_height: f32 = SDL_LINE_HEIGHT + y: f32 = SDL_EDITOR_TEXT_Y + + syntax_state := kotlin_syntax_state_before_line(&active.buffer, view.first_line) + for screen_line := 0; screen_line < visible_editor_lines(view); screen_line += 1 { + line_index := view.first_line + screen_line + if line_index >= buffer_line_count(&active.buffer) do break + + bytes := buffer_line_bytes(&active.buffer, line_index) + line_text := string(bytes[:]) + + gutter_text := fmt.tprintf("%4d", line_index + 1) + diagnostic, has_diagnostic := editor_diagnostic_on_line(editor, line_index) + gutter_color := [4]u8{116, 120, 128, 255} + if line_index == cursor_line { + gpu_rect(gpu, f32(sidebar_width), y - 3, f32(gpu.width - sidebar_width), line_height + 1, 38, 40, 46, 255) + gpu_rect(gpu, f32(sidebar_width + SDL_GUTTER_WIDTH), y - 3, 2, line_height + 1, 77, 155, 230, 255) + gutter_color = {169, 174, 184, 255} + } else if has_diagnostic { + gutter_color = {244, 113, 116, 255} + } + + gpu_text(gpu, f32(sidebar_width + 18), y, gutter_text, gutter_color[0], gutter_color[1], gutter_color[2], gutter_color[3]) + if has_diagnostic { + gpu_text(gpu, f32(sidebar_width + 58), y, "!", 244, 113, 116, 255) + } + + render_selection_for_line_gpu(gpu, view, active, line_index, y) + render_search_matches_for_line_gpu(gpu, view, active, search, line_index, line_text, y) + syntax_state = render_syntax_line_gpu(gpu, f32(text_x), y, line_text, visible_gpu_text_columns(gpu, view, tasks_panel), syntax_state) + if has_diagnostic { + render_diagnostic_underline_gpu(gpu, view, &active.buffer, line_index, diagnostic.column, y) + } + + if line_index == cursor_line { + cursor_x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, cursor_line, cursor_col) + gpu_line(gpu, cursor_x, y - 3, cursor_x, y + line_height - 1, 230, 230, 230, 255) + } + + delete(bytes) + y += line_height + } + + if len(editor.status) > 0 { + render_status_bar_gpu(gpu, editor.status, false) + } else if len(active.diagnostics) > 0 { + first := active.diagnostics[0] + errors, warnings, infos := editor_diagnostic_counts(active) + status := fmt.tprintf("%d errors %d warnings %d info | %s %d:%d %s", errors, warnings, infos, first.severity, first.line + 1, first.column + 1, first.message) + render_status_bar_gpu(gpu, status, true) + } else if active.dirty { + render_status_bar_gpu(gpu, "modified - Ctrl+S to save", false) + } else { + render_status_bar_gpu(gpu, "saved", false) + } + + render_completion_popup_gpu(gpu, editor, view, completion) + render_hover_tooltip_gpu(gpu, editor, view, hover) + render_references_panel_gpu(gpu, references) + render_rename_panel_gpu(gpu, rename) + render_search_panel_gpu(gpu, search) + render_diagnostics_panel_gpu(gpu, editor, view, diagnostics_panel) + render_gradle_tasks_panel_gpu(gpu, view, tasks_panel) + render_command_palette_gpu(gpu, command_palette) + render_metrics_overlay_gpu(gpu, editor, view) + gpu_present(gpu) +} + +visible_text_columns :: proc(view: ^SDL_View) -> int { + return max_int((view.window_width - editor_text_x(view) - 8) / SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE, 1) +} + +visible_gpu_text_columns :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel) -> int { + right_limit := gpu.width - 16 + if tasks_panel != nil && tasks_panel.open { + right_limit = gpu.width - right_sidebar_width_view(view, gpu.width) - 16 + } + return max_int(int(f32(right_limit - editor_text_x(view)) / max_f32(gpu.font_advance, 1)), 1) +} + +editor_column_pixel_x :: proc(view: ^SDL_View, buffer: ^Buffer, line, column: int) -> f32 { + return f32(editor_text_x(view) + line_prefix_pixel_width(buffer, line, column)) +} + +editor_column_pixel_x_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, buffer: ^Buffer, line, column: int) -> f32 { + return round_f32(f32(editor_text_x(view)) + line_prefix_pixel_advance_gpu(gpu, buffer, line, column)) +} + +line_prefix_pixel_width :: proc(buffer: ^Buffer, line, column: int) -> int { + bytes := buffer_line_bytes(buffer, line) + defer delete(bytes) + prefix_len := clamp_int(column, 0, len(bytes)) + return gpu_text_width(string(bytes[:prefix_len])) +} + +line_prefix_pixel_width_gpu :: proc(gpu: ^GPU_Renderer, buffer: ^Buffer, line, column: int) -> int { + return int(line_prefix_pixel_advance_gpu(gpu, buffer, line, column) + 0.5) +} + +line_prefix_pixel_advance_gpu :: proc(gpu: ^GPU_Renderer, buffer: ^Buffer, line, column: int) -> f32 { + bytes := buffer_line_bytes(buffer, line) + defer delete(bytes) + prefix_len := clamp_int(column, 0, len(bytes)) + return gpu_font_text_advance(gpu, string(bytes[:prefix_len])) +} + +text_column_from_pixel_x :: proc(buffer: ^Buffer, line: int, x: f32) -> int { + bytes := buffer_line_bytes(buffer, line) + defer delete(bytes) + if len(bytes) == 0 do return 0 + + target := max_int(int(x), 0) + best := 0 + best_distance := 1 << 30 + for column := 0; column <= len(bytes); column += 1 { + width := gpu_text_width(string(bytes[:column])) + distance := abs_int(width - target) + if distance < best_distance { + best = column + best_distance = distance + } + if width > target && column > 0 do break + } + return best +} + +text_column_from_pixel_x_gpu :: proc(gpu: ^GPU_Renderer, buffer: ^Buffer, line: int, x: f32) -> int { + if gpu == nil || !gpu.available || gpu.font_advance <= 0 { + return text_column_from_pixel_x(buffer, line, x) + } + + bytes := buffer_line_bytes(buffer, line) + defer delete(bytes) + if len(bytes) == 0 do return 0 + + column := int((x + gpu.font_advance * 0.5) / gpu.font_advance) + return clamp_int(column, 0, len(bytes)) +} + +abs_int :: proc(v: int) -> int { + if v < 0 do return -v + return v +} + +render_syntax_line_gpu :: proc(gpu: ^GPU_Renderer, x, y: f32, line: string, max_chars: int, state: Syntax_State) -> Syntax_State { + if len(line) == 0 do return state + limit := min_int(len(line), max_chars) + spans, next_state := tokenize_kotlin_line(line[:limit], state) + defer delete(spans) + + for span in spans { + if span.start >= span.end do continue + text := line[span.start:span.end] + offset := gpu_font_text_advance(gpu, line[:span.start]) + r, g, b := syntax_color(span.kind) + gpu_text(gpu, round_f32(x + offset), y, text, r, g, b, 255) + } + return next_state +} + +tokenize_kotlin_line :: proc(line: string, initial_state: Syntax_State) -> ([dynamic]Syntax_Span, Syntax_State) { + spans: [dynamic]Syntax_Span + state := initial_state + i := 0 + for i < len(line) { + start := i + ch := line[i] + + if state.in_block_comment { + for i < len(line) { + if line[i] == '*' && i + 1 < len(line) && line[i + 1] == '/' { + i += 2 + state.in_block_comment = false + break + } + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .Comment}) + continue + } + + if state.in_triple_string { + for i < len(line) { + if starts_with_at(line, i, "\"\"\"") { + i += 3 + state.in_triple_string = false + break + } + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .String}) + continue + } + + if ch == '/' && i + 1 < len(line) && line[i + 1] == '/' { + append(&spans, Syntax_Span{start = i, end = len(line), kind = .Comment}) + break + } + + if ch == '/' && i + 1 < len(line) && line[i + 1] == '*' { + i += 2 + state.in_block_comment = true + for i < len(line) { + if line[i] == '*' && i + 1 < len(line) && line[i + 1] == '/' { + i += 2 + state.in_block_comment = false + break + } + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .Comment}) + continue + } + + if starts_with_at(line, i, "\"\"\"") { + i += 3 + state.in_triple_string = true + for i < len(line) { + if starts_with_at(line, i, "\"\"\"") { + i += 3 + state.in_triple_string = false + break + } + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .String}) + continue + } + + if ch == '"' { + i += 1 + escaped := false + for i < len(line) { + if line[i] == '"' && !escaped { + i += 1 + break + } + escaped = line[i] == '\\' && !escaped + if line[i] != '\\' do escaped = false + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .String}) + continue + } + + if ch == '\'' { + i += 1 + escaped := false + for i < len(line) { + if line[i] == '\'' && !escaped { + i += 1 + break + } + escaped = line[i] == '\\' && !escaped + if line[i] != '\\' do escaped = false + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .String}) + continue + } + + if is_digit_byte(ch) { + i += 1 + for i < len(line) && (is_digit_byte(line[i]) || line[i] == '.') { + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .Number}) + continue + } + + if is_identifier_start_byte(ch) { + i += 1 + for i < len(line) && is_identifier_part_byte(line[i]) { + i += 1 + } + word := line[start:i] + kind := Syntax_Kind.Keyword if is_kotlin_or_java_keyword(word) else Syntax_Kind.Plain + if kind == .Plain && is_type_like_identifier(word) { + kind = .Type + } + append(&spans, Syntax_Span{start = start, end = i, kind = kind}) + continue + } + + i += 1 + for i < len(line) && !is_identifier_start_byte(line[i]) && !is_digit_byte(line[i]) && line[i] != '"' && line[i] != '\'' { + if line[i] == '/' && i + 1 < len(line) && line[i + 1] == '/' do break + i += 1 + } + append(&spans, Syntax_Span{start = start, end = i, kind = .Plain}) + } + return spans, state +} + +kotlin_syntax_state_before_line :: proc(buffer: ^Buffer, line: int) -> Syntax_State { + state := Syntax_State{} + for index := 0; index < line; index += 1 { + bytes := buffer_line_bytes(buffer, index) + spans: [dynamic]Syntax_Span + spans, state = tokenize_kotlin_line(string(bytes[:]), state) + delete(spans) + delete(bytes) + } + return state +} + +starts_with_at :: proc(text: string, at: int, prefix: string) -> bool { + if at < 0 || at + len(prefix) > len(text) do return false + return text[at:at + len(prefix)] == prefix +} + +syntax_color :: proc(kind: Syntax_Kind) -> (u8, u8, u8) { + switch kind { + case .Keyword: + return 197, 134, 192 + case .String: + return 206, 145, 120 + case .Comment: + return 106, 153, 85 + case .Number: + return 181, 206, 168 + case .Type: + return 78, 201, 176 + case .Plain: + return 214, 217, 223 + } + return 214, 217, 223 +} + +is_kotlin_or_java_keyword :: proc(word: string) -> bool { + switch word { + case "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", "is", "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias", "typeof", "val", "var", "when", "while", "by", "catch", "constructor", "delegate", "dynamic", "field", "file", "finally", "get", "import", "init", "param", "property", "receiver", "set", "setparam", "where", "actual", "abstract", "annotation", "companion", "const", "crossinline", "data", "enum", "expect", "external", "final", "infix", "inline", "inner", "internal", "lateinit", "noinline", "open", "operator", "out", "override", "private", "protected", "public", "reified", "sealed", "suspend", "tailrec", "vararg": + return true + case "assert", "boolean", "byte", "case", "char", "default", "double", "extends", "float", "goto", "implements", "instanceof", "int", "long", "native", "new", "record", "short", "static", "strictfp", "switch", "synchronized", "throws", "transient", "void", "volatile": + return true + } + return false +} + +is_type_like_identifier :: proc(word: string) -> bool { + if len(word) == 0 do return false + return word[0] >= 'A' && word[0] <= 'Z' +} + +is_identifier_start_byte :: proc(ch: u8) -> bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_' +} + +is_identifier_part_byte :: proc(ch: u8) -> bool { + return is_identifier_start_byte(ch) || is_digit_byte(ch) +} + +is_digit_byte :: proc(ch: u8) -> bool { + return ch >= '0' && ch <= '9' +} + +render_debug_text_limited :: proc(renderer: ^SDL.Renderer, x, y: f32, text: string, max_chars: int) { + if max_chars <= 0 do return + + if len(text) <= max_chars { + text_c := fmt.ctprintf("%s", text) + _ = SDL.RenderDebugText(renderer, x, y, text_c) + return + } + + if max_chars <= 3 { + text_c := fmt.ctprintf("%s", text[:max_chars]) + _ = SDL.RenderDebugText(renderer, x, y, text_c) + return + } + + text_c := fmt.ctprintf("%s...", text[:max_chars - 3]) + _ = SDL.RenderDebugText(renderer, x, y, text_c) +} + +render_rename_panel :: proc(renderer: ^SDL.Renderer, panel: ^Rename_Panel) { + if !panel.open do return + + x: f32 = 620 + y: f32 = 420 + width: f32 = 460 + visible := min_int(max_int(len(panel.edits), 1), 8) + height := f32(76 + visible * 16) + + _ = SDL.SetRenderDrawColor(renderer, 24, 28, 38, 245) + rect := SDL.FRect{x, y, width, height} + _ = SDL.RenderFillRect(renderer, &rect) + _ = SDL.SetRenderDrawColor(renderer, 110, 135, 180, 255) + _ = SDL.RenderRect(renderer, &rect) + + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + _ = SDL.RenderDebugText(renderer, x + 10, y + 8, "Rename Preview") + + input := fmt.ctprintf("New name: %s", string(panel.input[:])) + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + _ = SDL.RenderDebugText(renderer, x + 10, y + 28, input) + + summary := fmt.ctprintf("%s", panel.summary) + _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) + _ = SDL.RenderDebugText(renderer, x + 10, y + 48, summary) + + item_y := y + 68 + for edit, index in panel.edits { + if index >= 8 do break + label := fmt.ctprintf("%s", edit) + _ = SDL.RenderDebugText(renderer, x + 10, item_y, label) + item_y += 16 + } +} + +render_rename_panel_gpu :: proc(gpu: ^GPU_Renderer, panel: ^Rename_Panel) { + if !panel.open do return + + x: f32 = 620 + y: f32 = 420 + width: f32 = 460 + visible := min_int(max_int(len(panel.edits), 1), 8) + height := f32(76 + visible * 16) + + gpu_rect(gpu, x, y, width, height, 24, 28, 38, 245) + gpu_rect_outline(gpu, x, y, width, height, 110, 135, 180, 255) + gpu_text(gpu, x + 10, y + 8, "Rename Preview", 145, 165, 205, 255) + + input := fmt.tprintf("New name: %s", string(panel.input[:])) + gpu_text_limited(gpu, x + 10, y + 28, input, 56, 255, 210, 120, 255) + gpu_text_limited(gpu, x + 10, y + 48, panel.summary, 56, 220, 225, 235, 255) + + item_y := y + 68 + for edit, index in panel.edits { + if index >= 8 do break + gpu_text_limited(gpu, x + 10, item_y, edit, 56, 220, 225, 235, 255) + item_y += 16 + } +} + +render_search_panel_gpu :: proc(gpu: ^GPU_Renderer, panel: ^Search_Panel) { + if !panel.open do return + + width: f32 = 440 + height: f32 = 72 + x := f32(gpu.width) - width - 24 + y: f32 = SDL_TOP_BAR_HEIGHT + SDL_TAB_BAR_HEIGHT + 12 + + gpu_rect(gpu, x, y, width, height, 31, 34, 41, 245) + gpu_rect_outline(gpu, x, y, width, height, 77, 155, 230, 255) + gpu_text(gpu, x + 12, y + 10, "Find", 145, 190, 255, 255) + + query := fmt.tprintf("%s", string(panel.input[:])) + gpu_rect(gpu, x + 54, y + 8, width - 66, 24, 24, 26, 31, 255) + gpu_rect_outline(gpu, x + 54, y + 8, width - 66, 24, 64, 68, 78, 255) + gpu_text_limited(gpu, x + 62, y + 15, query, 48, 230, 233, 238, 255) + if len(panel.message) > 0 { + gpu_text_limited(gpu, x + 12, y + 46, panel.message, 58, 170, 176, 186, 255) + } +} + +render_command_palette_gpu :: proc(gpu: ^GPU_Renderer, palette: ^Command_Palette) { + if !palette.open do return + + width := f32(min_int(max_int(gpu.width - 280, 420), 720)) + x := f32(gpu.width) * 0.5 - width * 0.5 + y: f32 = SDL_TOP_BAR_HEIGHT + 48 + visible := min_int(max_int(command_palette_match_count(palette), 1), 8) + height := f32(58 + visible * 24) + + gpu_rect(gpu, x, y, width, height, 25, 28, 36, 248) + gpu_rect_outline(gpu, x, y, width, height, 77, 155, 230, 255) + gpu_text(gpu, x + 16, y + 13, "Command Palette", 145, 190, 255, 255) + + query := fmt.tprintf(">%s", string(palette.input[:])) + gpu_rect(gpu, x + 14, y + 34, width - 28, 24, 18, 20, 25, 255) + gpu_rect_outline(gpu, x + 14, y + 34, width - 28, 24, 58, 62, 72, 255) + gpu_text_limited(gpu, x + 24, y + 41, query, max_int(int((width - 48) / max_f32(gpu.font_advance, 1)), 1), 230, 233, 238, 255) + + item_y := y + 68 + seen := 0 + rendered := 0 + first_seen := 0 + if palette.selected >= 8 { + first_seen = palette.selected - 7 + } + for item in COMMAND_ITEMS { + if !command_palette_matches(palette, item.label) do continue + if seen < first_seen { + seen += 1 + continue + } + if rendered >= 8 do break + if seen == palette.selected { + gpu_rect(gpu, x + 8, item_y - 5, width - 16, 22, 49, 56, 70, 255) + gpu_rect(gpu, x + 8, item_y - 5, 2, 22, 77, 155, 230, 255) + } + color := [3]u8{218, 222, 230} + if seen != palette.selected { + color = {166, 172, 184} + } + gpu_text_limited(gpu, x + 20, item_y, item.label, max_int(int((width - 40) / max_f32(gpu.font_advance, 1)), 1), color[0], color[1], color[2], 255) + item_y += 24 + seen += 1 + rendered += 1 + } + if rendered == 0 { + gpu_text(gpu, x + 20, item_y, "No matching commands", 150, 154, 162, 255) + } +} + +render_diagnostics_panel_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View, panel: ^Diagnostics_Panel) { + if !panel.open do return + active := editor_active_buffer(editor) + if active == nil do return + + height: f32 = 168 + sidebar_width := left_sidebar_width(view) + x := f32(sidebar_width) + y := f32(gpu.height - SDL_STATUS_BAR_HEIGHT) - height + width := f32(gpu.width - sidebar_width) + gpu_rect(gpu, x, y, width, height, 30, 32, 38, 245) + gpu_rect(gpu, x, y, width, 1, 64, 68, 78, 255) + gpu_text(gpu, x + 14, y + 10, "Diagnostics", 220, 223, 228, 255) + + errors, warnings, infos := editor_diagnostic_counts(active) + count_label := fmt.tprintf("%dE %dW %dI", errors, warnings, infos) + gpu_text(gpu, x + width - 120, y + 10, count_label, 150, 154, 162, 255) + + if len(active.diagnostics) == 0 { + gpu_text(gpu, x + 14, y + 42, "No diagnostics", 150, 154, 162, 255) + return + } + + panel.selected = clamp_int(panel.selected, 0, len(active.diagnostics) - 1) + visible_items := 7 + first_index := 0 + if panel.selected >= visible_items { + first_index = panel.selected - visible_items + 1 + } + item_y := y + 38 + for index := first_index; index < len(active.diagnostics) && index < first_index + visible_items; index += 1 { + diagnostic := active.diagnostics[index] + if index == panel.selected { + gpu_rect(gpu, x + 8, item_y - 4, width - 16, 20, 49, 56, 70, 255) + gpu_rect(gpu, x + 8, item_y - 4, 2, 20, 77, 155, 230, 255) + } + label := fmt.tprintf("%s %d:%d %s", diagnostic.severity, diagnostic.line + 1, diagnostic.column + 1, diagnostic.message) + color := [3]u8{218, 222, 230} + if diagnostic.severity == "error" { + color = {244, 113, 116} + } + gpu_text_limited(gpu, x + 18, item_y, label, max_int((gpu.width - sidebar_width - 40) / int(max_f32(gpu.font_advance, 1)), 1), color[0], color[1], color[2], 255) + item_y += 18 + } +} + +render_gradle_tasks_panel_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, panel: ^Gradle_Tasks_Panel) { + if !panel.open do return + + width := f32(right_sidebar_width_view(view, gpu.width)) + height := f32(gpu.height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + x := f32(gpu.width) - width + y: f32 = SDL_TOP_BAR_HEIGHT + gpu_rect(gpu, x, y, width, height, 35, 36, 40, 255) + gpu_rect(gpu, x, y, 1, height, 56, 58, 64, 255) + gpu_rect(gpu, x, y + SDL_TAB_BAR_HEIGHT, width, 1, 52, 54, 60, 255) + gpu_text(gpu, x + 14, y + 12, "GRADLE", 150, 154, 162, 255) + gpu_text(gpu, x + 14, y + 34, "Tasks", 220, 223, 228, 255) + task_count := fmt.tprintf("%d", len(panel.items)) + gpu_text_limited(gpu, x + width - 54, y + 34, task_count, 5, 150, 154, 162, 255) + + if len(panel.message) > 0 { + gpu_text_limited(gpu, x + 14, y + 58, panel.message, 38, 150, 154, 162, 255) + } + if len(panel.items) == 0 { + gpu_text_limited(gpu, x + 14, y + 92, "No Gradle tasks loaded", 34, 150, 154, 162, 255) + } + + item_y := y + 92 + output_height: f32 = 0 + if len(panel.output) > 0 { + output_height = min_f32(160, max_f32(72, height * 0.32)) + } + visible_items := max_int(int((height - output_height - 104) / 18), 1) + if len(panel.items) > 0 { + panel.selected = clamp_int(panel.selected, 0, len(panel.items) - 1) + } + first_index := 0 + if panel.selected >= visible_items { + first_index = panel.selected - visible_items + 1 + } + for index := first_index; index < len(panel.items) && index < first_index + visible_items; index += 1 { + item := panel.items[index] + if index == panel.selected { + gpu_rect(gpu, x + 8, item_y - 4, width - 16, 20, 49, 56, 70, 255) + gpu_rect(gpu, x + 8, item_y - 4, 2, 20, 77, 155, 230, 255) + } + label := item.path + if len(label) == 0 do label = item.name + gpu_text_limited(gpu, x + 18, item_y, label, 34, 218, 222, 230, 255) + if len(item.description) > 0 { + gpu_text_limited(gpu, x + 36, item_y + 13, item.description, 32, 150, 154, 162, 255) + } + item_y += 30 if len(item.description) > 0 else 18 + } + + if len(panel.output) > 0 { + output_y := y + height - output_height + gpu_rect(gpu, x, output_y, width, output_height, 28, 29, 34, 255) + gpu_rect(gpu, x, output_y, width, 1, 58, 61, 68, 255) + gpu_text(gpu, x + 14, output_y + 10, "Output", 150, 154, 162, 255) + + line_y := output_y + 34 + max_lines := max_int(int((output_height - 42) / 16), 1) + first := max_int(len(panel.output) - max_lines, 0) + max_columns := max_int(int((width - 28) / max_f32(gpu.font_advance, 1)), 1) + for index := first; index < len(panel.output); index += 1 { + line := panel.output[index] + color := [3]u8{176, 182, 192} + if strings.has_prefix(line, "stderr:") { + color = {244, 113, 116} + } + gpu_text_limited(gpu, x + 14, line_y, line, max_columns, color[0], color[1], color[2], 255) + line_y += 16 + } + } +} + +render_references_panel :: proc(renderer: ^SDL.Renderer, panel: ^References_Panel) { + if !panel.open do return + + x: f32 = 700 + y: f32 = 72 + width: f32 = 380 + visible := min_int(max_int(len(panel.items), 1), 14) + height := f32(30 + visible * 16) + + _ = SDL.SetRenderDrawColor(renderer, 24, 28, 38, 245) + rect := SDL.FRect{x, y, width, height} + _ = SDL.RenderFillRect(renderer, &rect) + _ = SDL.SetRenderDrawColor(renderer, 95, 120, 165, 255) + _ = SDL.RenderRect(renderer, &rect) + + title := fmt.ctprintf("References (%d)", len(panel.items) if panel.pending_id == 0 else 0) + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + _ = SDL.RenderDebugText(renderer, x + 10, y + 8, title) + + item_y := y + 28 + first_index := 0 + if panel.selected >= 14 { + first_index = panel.selected - 13 + } + for index := first_index; index < len(panel.items) && index < first_index + 14; index += 1 { + item := panel.items[index] + if index == panel.selected { + _ = SDL.SetRenderDrawColor(renderer, 52, 62, 86, 255) + row := SDL.FRect{x + 4, item_y - 2, width - 8, 16} + _ = SDL.RenderFillRect(renderer, &row) + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + } else { + _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) + } + label := fmt.ctprintf("%s", item.label) + _ = SDL.RenderDebugText(renderer, x + 10, item_y, label) + item_y += 16 + } +} + +render_references_panel_gpu :: proc(gpu: ^GPU_Renderer, panel: ^References_Panel) { + if !panel.open do return + + x: f32 = 700 + y: f32 = 72 + width: f32 = 380 + visible := min_int(max_int(len(panel.items), 1), 14) + height := f32(30 + visible * 16) + + gpu_rect(gpu, x, y, width, height, 24, 28, 38, 245) + gpu_rect_outline(gpu, x, y, width, height, 95, 120, 165, 255) + title := fmt.tprintf("References (%d)", len(panel.items) if panel.pending_id == 0 else 0) + gpu_text(gpu, x + 10, y + 8, title, 145, 165, 205, 255) + + item_y := y + 28 + first_index := 0 + if panel.selected >= 14 { + first_index = panel.selected - 13 + } + for index := first_index; index < len(panel.items) && index < first_index + 14; index += 1 { + item := panel.items[index] + if index == panel.selected { + gpu_rect(gpu, x + 4, item_y - 2, width - 8, 16, 52, 62, 86, 255) + gpu_text_limited(gpu, x + 10, item_y, item.label, 46, 255, 210, 120, 255) + } else { + gpu_text_limited(gpu, x + 10, item_y, item.label, 46, 220, 225, 235, 255) + } + item_y += 16 + } +} + +render_hover_tooltip :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_View, hover: ^Hover_Tooltip) { + if !hover.open do return + if len(hover.contents) == 0 do return + + active := editor_active_buffer(editor) + if active == nil do return + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + x := f32(editor_text_x(view) + cursor_col * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE) + y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 2) * SDL_LINE_HEIGHT) + if y > 690 do y = 690 + + width := f32(max_int(180, min_int(520, len(hover.contents) * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE + 20))) + height: f32 = 34 + _ = SDL.SetRenderDrawColor(renderer, 30, 34, 45, 245) + rect := SDL.FRect{x, y, width, height} + _ = SDL.RenderFillRect(renderer, &rect) + _ = SDL.SetRenderDrawColor(renderer, 100, 125, 165, 255) + _ = SDL.RenderRect(renderer, &rect) + _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) + text := fmt.ctprintf("%s", hover.contents) + _ = SDL.RenderDebugText(renderer, x + 10, y + 10, text) +} + +render_hover_tooltip_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View, hover: ^Hover_Tooltip) { + if !hover.open || len(hover.contents) == 0 do return + active := editor_active_buffer(editor) + if active == nil do return + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, cursor_line, cursor_col) + y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 2) * SDL_LINE_HEIGHT) + if y > f32(gpu.height - 70) do y = f32(gpu.height - 70) + + width := f32(max_int(180, min_int(520, gpu_font_text_width(gpu, hover.contents) + 20))) + gpu_rect(gpu, x, y, width, 34, 30, 34, 45, 245) + gpu_rect_outline(gpu, x, y, width, 34, 100, 125, 165, 255) + gpu_text_limited(gpu, x + 10, y + 10, hover.contents, 80, 220, 225, 235, 255) +} + +render_completion_popup :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_View, popup: ^Completion_Popup) { + if !popup.open do return + + active := editor_active_buffer(editor) + if active == nil do return + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + x := f32(editor_text_x(view) + cursor_col * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE) + y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 1) * SDL_LINE_HEIGHT) + if y > 650 do y = 650 + + width: f32 = 260 + height := f32(26 + min_int(len(popup.items), 8) * 16) + _ = SDL.SetRenderDrawColor(renderer, 28, 32, 43, 245) + rect := SDL.FRect{x, y, width, height} + _ = SDL.RenderFillRect(renderer, &rect) + _ = SDL.SetRenderDrawColor(renderer, 90, 110, 150, 255) + _ = SDL.RenderRect(renderer, &rect) + + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + _ = SDL.RenderDebugText(renderer, x + 8, y + 6, "Completions") + + item_y := y + 22 + first_index := 0 + if popup.selected >= 8 { + first_index = popup.selected - 7 + } + for index := first_index; index < len(popup.items) && index < first_index + 8; index += 1 { + item := popup.items[index] + if index == popup.selected { + _ = SDL.SetRenderDrawColor(renderer, 52, 62, 86, 255) + row := SDL.FRect{x + 4, item_y - 2, width - 8, 16} + _ = SDL.RenderFillRect(renderer, &row) + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + } else { + _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) + } + item_c := fmt.ctprintf("%s", item.label) + _ = SDL.RenderDebugText(renderer, x + 10, item_y, item_c) + if len(item.kind) > 0 && item.kind != "status" { + kind_c := fmt.ctprintf("%s", item.kind) + _ = SDL.RenderDebugText(renderer, x + width - 78, item_y, kind_c) + } + item_y += 16 + } +} + +render_completion_popup_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View, popup: ^Completion_Popup) { + if !popup.open do return + active := editor_active_buffer(editor) + if active == nil do return + + cursor_line, cursor_col := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, cursor_line, cursor_col) + y := f32(SDL_EDITOR_TEXT_Y + (cursor_line - view.first_line + 1) * SDL_LINE_HEIGHT) + if y > f32(gpu.height - 110) do y = f32(gpu.height - 110) + + width: f32 = 260 + height := f32(26 + min_int(len(popup.items), 8) * 16) + gpu_rect(gpu, x, y, width, height, 28, 32, 43, 245) + gpu_rect_outline(gpu, x, y, width, height, 90, 110, 150, 255) + gpu_text(gpu, x + 8, y + 6, "Completions", 145, 165, 205, 255) + + item_y := y + 22 + first_index := 0 + if popup.selected >= 8 { + first_index = popup.selected - 7 + } + for index := first_index; index < len(popup.items) && index < first_index + 8; index += 1 { + item := popup.items[index] + if index == popup.selected { + gpu_rect(gpu, x + 4, item_y - 2, width - 8, 16, 52, 62, 86, 255) + gpu_text_limited(gpu, x + 10, item_y, item.label, 24, 255, 210, 120, 255) + } else { + gpu_text_limited(gpu, x + 10, item_y, item.label, 24, 220, 225, 235, 255) + } + if len(item.kind) > 0 && item.kind != "status" { + gpu_text_limited(gpu, x + width - 78, item_y, item.kind, 10, 140, 146, 158, 255) + } + item_y += 16 + } +} + +render_selection_for_line :: proc(renderer: ^SDL.Renderer, view: ^SDL_View, active: ^Editor_Buffer, line_index: int, y: f32) { + selection_start, selection_end, ok := editor_selection_range(active) + if !ok do return + + line_start := buffer_line_start(&active.buffer, line_index) + line_end := buffer_line_end(&active.buffer, line_index) + if selection_end < line_start || selection_start > line_end do return + + start := max_int(selection_start, line_start) + end := min_int(selection_end, line_end) + if start == end { + if selection_end != line_start do return + end = min_int(line_end, start + 1) + } + + start_col := start - line_start + end_col := max_int(end - line_start, start_col + 1) + x := f32(editor_text_x(view) + start_col * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE) + width := f32((end_col - start_col) * SDL.DEBUG_TEXT_FONT_CHARACTER_SIZE) + rect := SDL.FRect{x, y - 2, width, 16} + _ = SDL.SetRenderDrawColor(renderer, 62, 83, 125, 180) + _ = SDL.RenderFillRect(renderer, &rect) +} + +render_selection_for_line_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, active: ^Editor_Buffer, line_index: int, y: f32) { + selection_start, selection_end, ok := editor_selection_range(active) + if !ok do return + + line_start := buffer_line_start(&active.buffer, line_index) + line_end := buffer_line_end(&active.buffer, line_index) + if selection_end < line_start || selection_start > line_end do return + + start := max_int(selection_start, line_start) + end := min_int(selection_end, line_end) + if start == end { + if selection_end != line_start do return + end = min_int(line_end, start + 1) + } + + start_col := start - line_start + end_col := max_int(end - line_start, start_col + 1) + x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, line_index, start_col) + width := round_f32(line_prefix_pixel_advance_gpu(gpu, &active.buffer, line_index, end_col) - line_prefix_pixel_advance_gpu(gpu, &active.buffer, line_index, start_col)) + gpu_rect(gpu, x, y - 3, width, SDL_LINE_HEIGHT + 1, 58, 91, 140, 170) +} + +render_search_matches_for_line_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, active: ^Editor_Buffer, panel: ^Search_Panel, line_index: int, line: string, y: f32) { + if !panel.open || len(panel.input) == 0 || len(line) == 0 do return + query := panel.input[:] + if len(query) > len(line) do return + + at := 0 + for at <= len(line) - len(query) { + match := true + for i := 0; i < len(query); i += 1 { + if line[at + i] != query[i] { + match = false + break + } + } + if match { + x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, line_index, at) + width := round_f32(line_prefix_pixel_advance_gpu(gpu, &active.buffer, line_index, at + len(query)) - line_prefix_pixel_advance_gpu(gpu, &active.buffer, line_index, at)) + gpu_rect(gpu, x, y - 3, width, SDL_LINE_HEIGHT + 1, 94, 75, 31, 190) + gpu_rect_outline(gpu, x, y - 3, width, SDL_LINE_HEIGHT + 1, 181, 137, 45, 220) + at += max_int(len(query), 1) + } else { + at += 1 + } + } +} + +render_diagnostic_underline_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, buffer: ^Buffer, line_index, column: int, y: f32) { + start_col := max_int(column, 0) + end_col := start_col + 6 + line_end_col := buffer_line_end(buffer, line_index) - buffer_line_start(buffer, line_index) + end_col = clamp_int(end_col, start_col + 1, max_int(line_end_col, start_col + 1)) + x0 := editor_column_pixel_x_gpu(gpu, view, buffer, line_index, start_col) + x1 := editor_column_pixel_x_gpu(gpu, view, buffer, line_index, end_col) + underline_y := y + SDL_LINE_HEIGHT - 2 + segment: f32 = 3 + x := x0 + for x < x1 { + gpu_rect(gpu, x, underline_y, min_f32(segment, x1 - x), 1, 244, 113, 116, 255) + x += segment * 2 + } +} + +render_project_tree :: proc(renderer: ^SDL.Renderer, tree: ^Project_Tree, view: ^SDL_View, active_path: string) { + if !view.explorer_visible do return + sidebar_width := left_sidebar_width(view) + _ = SDL.SetRenderDrawColor(renderer, 20, 23, 31, 255) + sidebar := SDL.FRect{0, 0, f32(sidebar_width), f32(view.window_height)} + _ = SDL.RenderFillRect(renderer, &sidebar) + + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + _ = SDL.RenderDebugText(renderer, 12, 8, "Project") + + y: f32 = 28 + end := min_int(view.tree_first + visible_tree_rows(view), len(tree.files)) + for index := view.tree_first; index < end; index += 1 { + item := tree.files[index] + if item.path == active_path { + _ = SDL.SetRenderDrawColor(renderer, 37, 45, 63, 255) + row := SDL.FRect{0, y - 2, f32(sidebar_width), 16} + _ = SDL.RenderFillRect(renderer, &row) + } + + label := fmt.ctprintf("%s%s", "/ " if item.is_dir else " ", item.label) + if item.is_dir { + _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) + } else if item.path == active_path { + _ = SDL.SetRenderDrawColor(renderer, 255, 210, 120, 255) + } else { + _ = SDL.SetRenderDrawColor(renderer, 190, 198, 215, 255) + } + _ = SDL.RenderDebugText(renderer, 12, y, label) + y += 16 + } +} + +render_project_tree_gpu :: proc(gpu: ^GPU_Renderer, tree: ^Project_Tree, view: ^SDL_View, active_path: string) { + if !view.explorer_visible do return + sidebar_width := left_sidebar_width(view) + gpu_rect(gpu, 0, SDL_TOP_BAR_HEIGHT, f32(sidebar_width), f32(gpu.height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT), 35, 36, 40, 255) + gpu_rect(gpu, 0, SDL_TOP_BAR_HEIGHT, f32(sidebar_width), 1, 55, 57, 64, 255) + gpu_text(gpu, 14, SDL_TREE_HEADER_Y, "EXPLORER", 150, 154, 162, 255) + gpu_rect(gpu, 14, SDL_TREE_HEADER_Y + 18, f32(sidebar_width - 28), 1, 52, 54, 60, 255) + gpu_rect(gpu, f32(sidebar_width - 2), SDL_TOP_BAR_HEIGHT, 4, f32(gpu.height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT), 57, 60, 68, 255) + + y: f32 = SDL_TREE_FIRST_Y + end := min_int(view.tree_first + visible_tree_rows(view), len(tree.files)) + for index := view.tree_first; index < end; index += 1 { + item := tree.files[index] + if item.path == active_path { + gpu_rect(gpu, 8, y - 3, f32(sidebar_width - 16), SDL_TREE_ROW_HEIGHT + 1, 49, 51, 58, 255) + gpu_rect(gpu, 8, y - 3, 2, SDL_TREE_ROW_HEIGHT + 1, 77, 155, 230, 255) + } + + label := fmt.tprintf("%s%s", "/ " if item.is_dir else " ", item.label) + max_columns := max_int(int(f32(sidebar_width - 32) / max_f32(gpu.font_advance, 1)), 1) + if item.is_dir { + gpu_text_limited(gpu, 16, y, label, max_columns, 180, 184, 194, 255) + } else if item.path == active_path { + gpu_text_limited(gpu, 16, y, label, max_columns, 235, 237, 241, 255) + } else { + gpu_text_limited(gpu, 16, y, label, max_columns, 190, 193, 201, 255) + } + y += SDL_TREE_ROW_HEIGHT + } +} + +render_editor_tabs_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View) { + tab_x := f32(left_sidebar_width(view) + 12) + max_x := f32(gpu.width - 12) + for &buffer, index in editor.buffers { + if tab_x >= max_x do break + _, file_name := os.split_path(buffer.path) + width := f32(tab_width_for_label(file_name)) + if tab_x + width > max_x { + width = max_x - tab_x + } + active := index == editor.active + if active { + gpu_rect(gpu, tab_x, SDL_TOP_BAR_HEIGHT + 5, width, SDL_TAB_BAR_HEIGHT - 5, 27, 28, 32, 255) + gpu_rect(gpu, tab_x, SDL_TOP_BAR_HEIGHT + SDL_TAB_BAR_HEIGHT - 2, width, 2, 77, 155, 230, 255) + } else { + gpu_rect(gpu, tab_x, SDL_TOP_BAR_HEIGHT + 6, width, SDL_TAB_BAR_HEIGHT - 6, 41, 43, 48, 255) + } + dirty_marker := " *" if buffer.dirty else "" + tab_title := fmt.tprintf("%s%s", file_name, dirty_marker) + if active { + gpu_text_limited(gpu, tab_x + 16, SDL_TOP_BAR_HEIGHT + 12, tab_title, 34, 220, 223, 228, 255) + gpu_text(gpu, tab_x + width - 22, SDL_TOP_BAR_HEIGHT + 12, "x", 168, 172, 182, 255) + } else { + gpu_text_limited(gpu, tab_x + 16, SDL_TOP_BAR_HEIGHT + 12, tab_title, 34, 168, 172, 182, 255) + gpu_text(gpu, tab_x + width - 22, SDL_TOP_BAR_HEIGHT + 12, "x", 124, 128, 138, 255) + } + gpu_rect(gpu, tab_x + width, SDL_TOP_BAR_HEIGHT + 8, 1, SDL_TAB_BAR_HEIGHT - 12, 56, 58, 64, 255) + tab_x += width + 4 + } +} + +render_metrics_overlay_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_View) { + if !view.show_metrics do return + active := editor_active_buffer(editor) + if active == nil do return + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + prefix_width := line_prefix_pixel_advance_gpu(gpu, &active.buffer, line, column) + text := fmt.tprintf("metrics: advance=%.2f line=%d col=%d prefix=%.2f x=%.2f", gpu.font_advance, line + 1, column + 1, prefix_width, editor_column_pixel_x_gpu(gpu, view, &active.buffer, line, column)) + width := f32(max_int(gpu_font_text_width(gpu, text) + 20, 420)) + x := f32(left_sidebar_width(view) + 16) + y := f32(SDL_EDITOR_TOP + 10) + gpu_rect(gpu, x, y, width, 34, 22, 24, 29, 235) + gpu_rect_outline(gpu, x, y, width, 34, 77, 155, 230, 255) + gpu_text(gpu, x + 10, y + 10, text, 220, 230, 245, 255) +} + +render_top_bar_gpu :: proc(gpu: ^GPU_Renderer, active: ^Editor_Buffer) { + gpu_rect(gpu, 0, 0, f32(gpu.width), SDL_TOP_BAR_HEIGHT, 43, 45, 50, 255) + gpu_rect(gpu, 0, SDL_TOP_BAR_HEIGHT - 1, f32(gpu.width), 1, 56, 58, 64, 255) + gpu_text(gpu, 14, 10, "Native Kotlin Editor", 220, 223, 228, 255) + + line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) + right := fmt.tprintf("Ln %d, Col %d", line + 1, column + 1) + gpu_text_limited(gpu, f32(gpu.width - 150), 10, right, 20, 172, 176, 186, 255) +} + +render_status_bar_gpu :: proc(gpu: ^GPU_Renderer, message: string, diagnostic: bool) { + y := f32(gpu.height - SDL_STATUS_BAR_HEIGHT) + if diagnostic { + gpu_rect(gpu, 0, y, f32(gpu.width), SDL_STATUS_BAR_HEIGHT, 122, 50, 54, 255) + gpu_text_limited(gpu, 14, y + 7, message, 120, 255, 235, 235, 255) + } else { + gpu_rect(gpu, 0, y, f32(gpu.width), SDL_STATUS_BAR_HEIGHT, 34, 92, 143, 255) + gpu_text_limited(gpu, 14, y + 7, message, 120, 232, 242, 255, 255) + } + gpu_text(gpu, f32(gpu.width - 156), y + 7, "Odin + SDL3 GPU", 218, 232, 246, 255) +} + +gpu_rect_outline :: proc(gpu: ^GPU_Renderer, x, y, w, h: f32, r, g, b, a: u8) { + gpu_rect(gpu, x, y, w, 1, r, g, b, a) + gpu_rect(gpu, x, y + h - 1, w, 1, r, g, b, a) + gpu_rect(gpu, x, y, 1, h, r, g, b, a) + gpu_rect(gpu, x + w - 1, y, 1, h, r, g, b, a) +} diff --git a/client/odin/shaders/rect.frag.glsl b/client/odin/shaders/rect.frag.glsl new file mode 100644 index 0000000..04a080a --- /dev/null +++ b/client/odin/shaders/rect.frag.glsl @@ -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; +} diff --git a/client/odin/shaders/rect.vert.glsl b/client/odin/shaders/rect.vert.glsl new file mode 100644 index 0000000..a9702bf --- /dev/null +++ b/client/odin/shaders/rect.vert.glsl @@ -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; +} diff --git a/client/odin/shaders/text.frag.glsl b/client/odin/shaders/text.frag.glsl new file mode 100644 index 0000000..06c14e1 --- /dev/null +++ b/client/odin/shaders/text.frag.glsl @@ -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); +} diff --git a/client/odin/shaders/text.vert.glsl b/client/odin/shaders/text.vert.glsl new file mode 100644 index 0000000..25cc296 --- /dev/null +++ b/client/odin/shaders/text.vert.glsl @@ -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; +} diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts new file mode 100644 index 0000000..d585771 --- /dev/null +++ b/daemon/build.gradle.kts @@ -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().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/daemon/settings.gradle.kts b/daemon/settings.gradle.kts new file mode 100644 index 0000000..8c10b87 --- /dev/null +++ b/daemon/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "native-editor-daemon" diff --git a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt new file mode 100644 index 0000000..3abad30 --- /dev/null +++ b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt @@ -0,0 +1,1188 @@ +package dev.nativeeditor.daemon + +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.io.PrintStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.file.Files +import java.util.concurrent.ConcurrentHashMap +import javax.tools.DiagnosticCollector +import javax.tools.JavaFileObject +import javax.tools.StandardLocation +import javax.tools.ToolProvider +import kotlin.concurrent.thread +import org.gradle.tooling.GradleConnector +import org.gradle.tooling.model.GradleProject +import org.gradle.tooling.model.idea.IdeaProject +import org.gradle.tooling.model.idea.IdeaSingleEntryLibraryDependency +import org.jetbrains.kotlin.cli.common.ExitCode +import org.jetbrains.kotlin.cli.common.messages.MessageRenderer +import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler + +fun main(args: Array) { + val requestedPort = args.firstOrNull()?.toIntOrNull() ?: 0 + val server = ServerSocket(requestedPort, 50, InetAddress.getLoopbackAddress()) + + println("PORT ${server.localPort}") + System.out.flush() + + val state = DaemonState() + while (true) { + val socket = server.accept() + thread(name = "editor-client-${socket.port}", isDaemon = true) { + ClientSession(socket, state).run() + } + } +} + +private class DaemonState { + @Volatile var workspaceRoot: File? = null + @Volatile var gradleWorkspace: GradleWorkspace? = null + val openTexts = ConcurrentHashMap() + val textVersions = ConcurrentHashMap() + val diagnosticsVersions = ConcurrentHashMap() +} + +private data class GradleWorkspace( + val root: File, + val modules: List, + val tasks: List, +) + +private data class GradleModule( + val name: String, + val gradlePath: String, + val directory: File, + val sourceRoots: List, + val testSourceRoots: List, + val resourceRoots: List, + val testResourceRoots: List, + val classpath: List, +) + +private data class KotlinDiagnostic( + val severity: String, + val message: String, + val path: String?, + val line: Int?, + val column: Int?, +) + +private data class SourceLocation( + val path: String, + val line: Int, + val column: Int, +) + +private data class CompletionCandidate( + val label: String, + val kind: String, +) + +private data class GradleTaskInfo( + val path: String, + val name: String, + val description: String?, +) + +private class ClientSession( + private val socket: Socket, + private val state: DaemonState, +) { + private val writerLock = Any() + + fun run() { + socket.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + + val id = extractJsonInt(line, "id") + val method = extractJsonString(line, "method") + if (id == null || method == null) { + writeLine(writer, errorJson(id ?: 0, "BAD_REQUEST", "Request needs numeric id and string method")) + continue + } + + when (method) { + "ping" -> writeLine(writer, okJson(id, "{\"pong\":true}")) + "workspace/open" -> openWorkspace(writer, id, line) + "workspace/close" -> closeWorkspace(writer, id) + "text/open", "text/change" -> updateText(writer, id, line) + "text/close" -> closeText(writer, id, line) + "kotlin/diagnostics" -> kotlinDiagnostics(writer, id, line) + "kotlin/completion" -> kotlinCompletion(writer, id, line) + "kotlin/definition" -> kotlinDefinition(writer, id, line) + "kotlin/references" -> kotlinReferences(writer, id, line) + "kotlin/rename" -> kotlinRename(writer, id, line) + "kotlin/hover" -> kotlinHover(writer, id, line) + "gradle/tasks" -> writeLine(writer, okJson(id, gradleTasksJson())) + "gradle/run" -> runGradle(writer, id, line) + else -> writeLine(writer, errorJson(id, "UNKNOWN_METHOD", "No handler for $method")) + } + } + } + } + + private fun openWorkspace(writer: BufferedWriter, id: Int, line: String) { + val rootText = extractJsonString(line, "root") + if (rootText == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "workspace/open needs params.root")) + return + } + + val root = File(rootText).absoluteFile.normalize() + if (!root.isDirectory) { + writeLine(writer, errorJson(id, "NO_SUCH_WORKSPACE", "Workspace root does not exist: ${escapeJson(root.path)}")) + return + } + + state.workspaceRoot = root + writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"started\"}")) + + val gradleWorkspace = try { + importGradleWorkspace(root) + } catch (t: Throwable) { + writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"failed\"}")) + writeLine(writer, errorJson(id, "GRADLE_IMPORT_FAILED", t.message ?: t.javaClass.name)) + return + } + + state.gradleWorkspace = gradleWorkspace + writeLine(writer, okJson(id, workspaceJson(gradleWorkspace))) + writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"idle\"}")) + } + + private fun closeWorkspace(writer: BufferedWriter, id: Int) { + state.workspaceRoot = null + state.gradleWorkspace = null + state.openTexts.clear() + state.textVersions.clear() + state.diagnosticsVersions.clear() + writeLine(writer, okJson(id, "{\"closed\":true}")) + } + + private fun updateText(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "text update needs params.path")) + return + } + val normalizedPath = normalizedPath(path) + + val version = extractJsonInt(line, "version") ?: 0 + state.openTexts[normalizedPath] = extractJsonString(line, "text") ?: "" + state.textVersions[normalizedPath] = version + writeLine(writer, okJson(id, "{\"path\":\"${escapeJson(normalizedPath)}\",\"version\":$version}")) + scheduleDiagnostics(writer, normalizedPath, version) + } + + private fun closeText(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path")?.let(::normalizedPath) + if (path != null) state.openTexts.remove(path) + if (path != null) state.textVersions.remove(path) + if (path != null) state.diagnosticsVersions.remove(path) + writeLine(writer, okJson(id, "{\"closed\":true}")) + } + + private fun gradleTasksJson(): String { + return "{\"tasks\":${tasksJson(state.gradleWorkspace?.tasks.orEmpty())}}" + } + + private fun runGradle(writer: BufferedWriter, id: Int, line: String) { + val task = extractJsonString(line, "task") ?: extractJsonString(line, "path") + if (task == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "gradle/run needs params.task")) + return + } + + val root = state.workspaceRoot + if (root == null) { + writeLine(writer, errorJson(id, "NO_WORKSPACE", "Open a workspace before running Gradle")) + return + } + + writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"started\"}")) + try { + GradleConnector.newConnector().forProjectDirectory(root).connect().use { connection -> + connection.newBuild() + .forTasks(task) + .setStandardOutput(gradleOutputStream(writer, task, "stdout")) + .setStandardError(gradleOutputStream(writer, task, "stderr")) + .run() + } + } catch (t: Throwable) { + writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"failed\"}")) + writeLine(writer, errorJson(id, "GRADLE_RUN_FAILED", t.message ?: t.javaClass.name)) + return + } + + writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"finished\"}")) + writeLine(writer, okJson(id, "{\"task\":\"${escapeJson(task)}\",\"success\":true}")) + } + + private fun gradleOutputStream(writer: BufferedWriter, task: String, stream: String): OutputStream { + return object : OutputStream() { + private val buffer = StringBuilder() + + override fun write(b: Int) { + val ch = b.toChar() + if (ch == '\n') { + flushLine() + } else if (ch != '\r') { + buffer.append(ch) + } + } + + override fun flush() { + flushLine() + } + + override fun close() { + flushLine() + } + + private fun flushLine() { + if (buffer.isEmpty()) return + val text = buffer.toString() + buffer.clear() + writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"output\",\"stream\":\"$stream\",\"text\":\"${escapeJson(text)}\"}")) + } + } + } + + private fun kotlinDiagnostics(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/diagnostics needs params.path")) + return + } + + val workspace = state.gradleWorkspace + if (workspace == null) { + writeLine(writer, errorJson(id, "NO_WORKSPACE", "Open a Gradle workspace before requesting diagnostics")) + return + } + + val normalizedPath = normalizedPath(path) + val file = File(normalizedPath) + val module = findModule(workspace, file) + + if (module == null) { + writeLine(writer, errorJson(id, "NO_MODULE", "No Gradle module is available for diagnostics")) + return + } + + val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath]) + writeLine(writer, okJson(id, "{\"diagnostics\":${diagnosticsJson(diagnostics)}}")) + } + + private fun kotlinCompletion(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/completion needs params.path")) + return + } + + val requestLine = extractJsonInt(line, "line") ?: 1 + val requestColumn = extractJsonInt(line, "column") ?: 1 + val normalizedPath = normalizedPath(path) + val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") + val offset = offsetForLineColumn(text, requestLine, requestColumn) + val prefix = identifierPrefixAt(text, offset) + val items = completionCandidates(prefix, text, state.gradleWorkspace) + writeLine(writer, okJson(id, "{\"items\":${completionItemsJson(items)}}")) + } + + private fun kotlinHover(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/hover needs params.path")) + return + } + + val requestLine = extractJsonInt(line, "line") ?: 1 + val requestColumn = extractJsonInt(line, "column") ?: 1 + val normalizedPath = normalizedPath(path) + val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") + val offset = offsetForLineColumn(text, requestLine, requestColumn) + val identifier = identifierAt(text, offset) + val contents = hoverContents(identifier, normalizedPath, text, state.gradleWorkspace) + if (contents == null) { + writeLine(writer, okJson(id, "{\"contents\":null}")) + } else { + writeLine(writer, okJson(id, "{\"contents\":\"${escapeJson(contents)}\"}")) + } + } + + private fun kotlinDefinition(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/definition needs params.path")) + return + } + + val workspace = state.gradleWorkspace + if (workspace == null) { + writeLine(writer, errorJson(id, "NO_WORKSPACE", "Open a Gradle workspace before requesting definitions")) + return + } + + val requestLine = extractJsonInt(line, "line") ?: 1 + val requestColumn = extractJsonInt(line, "column") ?: 1 + val normalizedPath = normalizedPath(path) + val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") + val offset = offsetForLineColumn(text, requestLine, requestColumn) + val identifier = identifierAt(text, offset) + val location = findSimpleDeclarationInText(normalizedPath, text, identifier) + ?: findSimpleDeclaration(workspace, identifier) + writeLine(writer, okJson(id, "{\"locations\":${definitionLocationsJson(location)}}")) + } + + private fun kotlinReferences(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/references needs params.path")) + return + } + + val workspace = state.gradleWorkspace + if (workspace == null) { + writeLine(writer, errorJson(id, "NO_WORKSPACE", "Open a Gradle workspace before requesting references")) + return + } + + val requestLine = extractJsonInt(line, "line") ?: 1 + val requestColumn = extractJsonInt(line, "column") ?: 1 + val normalizedPath = normalizedPath(path) + val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") + val offset = offsetForLineColumn(text, requestLine, requestColumn) + val identifier = identifierAt(text, offset) + val locations = findSimpleReferences(workspace, normalizedPath, text, identifier) + writeLine(writer, okJson(id, "{\"locations\":${locationsJson(locations)}}")) + } + + private fun kotlinRename(writer: BufferedWriter, id: Int, line: String) { + val path = extractJsonString(line, "path") + if (path == null) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs params.path")) + return + } + + val newName = extractJsonString(line, "newName") + if (newName == null || !isValidIdentifier(newName)) { + writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs valid params.newName")) + return + } + + val workspace = state.gradleWorkspace + if (workspace == null) { + writeLine(writer, errorJson(id, "NO_WORKSPACE", "Open a Gradle workspace before requesting rename")) + return + } + + val requestLine = extractJsonInt(line, "line") ?: 1 + val requestColumn = extractJsonInt(line, "column") ?: 1 + val normalizedPath = normalizedPath(path) + val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") + val offset = offsetForLineColumn(text, requestLine, requestColumn) + val identifier = identifierAt(text, offset) + if (identifier.isBlank() || identifier in allKeywordCompletionItems) { + writeLine(writer, okJson(id, "{\"applied\":false,\"edits\":[]}")) + return + } + + val locations = findSimpleReferences(workspace, normalizedPath, text, identifier) + writeLine(writer, okJson(id, "{\"applied\":false,\"oldName\":\"${escapeJson(identifier)}\",\"newName\":\"${escapeJson(newName)}\",\"edits\":${renameEditsJson(locations, identifier, newName)}}")) + } + + private fun scheduleDiagnostics(writer: BufferedWriter, path: String, version: Int) { + state.diagnosticsVersions[path] = version + + thread(name = "diagnostics-$version", isDaemon = true) { + Thread.sleep(250) + if (state.diagnosticsVersions[path] != version || state.textVersions[path] != version) return@thread + + val workspace = state.gradleWorkspace ?: return@thread + val file = File(path).absoluteFile.normalize() + val module = findModule(workspace, file) ?: return@thread + val diagnostics = try { + compileForDiagnostics(file, module, state.openTexts[path]) + } catch (t: Throwable) { + listOf(KotlinDiagnostic("error", t.message ?: t.javaClass.name, file.path, null, null)) + } + + if (state.diagnosticsVersions[path] != version || state.textVersions[path] != version) return@thread + writeLine( + writer, + eventJson( + "diagnostics/publish", + "{\"path\":\"${escapeJson(file.path)}\",\"version\":$version,\"diagnostics\":${diagnosticsJson(diagnostics)}}", + ), + ) + } + } + + private fun writeLine(writer: BufferedWriter, text: String) { + synchronized(writerLock) { + writeLineLocked(writer, text) + } + } +} + +private fun findModule(workspace: GradleWorkspace, file: File): GradleModule? { + val normalizedFile = file.absoluteFile.normalize() + return workspace.modules + .mapNotNull { module -> + val bestRootLength = (module.sourceRoots + module.testSourceRoots) + .map { it.absoluteFile.normalize() } + .filter { root -> rootContains(root, normalizedFile) } + .maxOfOrNull { it.path.length } + ?: return@mapNotNull null + module to bestRootLength + } + .maxByOrNull { it.second } + ?.first + ?: workspace.modules.firstOrNull() +} + +private fun rootContains(root: File, file: File): Boolean { + val normalizedRoot = root.absoluteFile.normalize() + val normalizedFile = file.absoluteFile.normalize() + return normalizedFile == normalizedRoot || normalizedFile.path.startsWith(normalizedRoot.path + File.separator) +} + +private fun normalizedPath(path: String): String = File(path).absoluteFile.normalize().path + +private fun sourceRootsForFile(module: GradleModule, file: File): List { + val normalizedFile = file.absoluteFile.normalize() + val isTestFile = module.testSourceRoots.any { root -> rootContains(root, normalizedFile) } + return if (isTestFile) module.sourceRoots + module.testSourceRoots else module.sourceRoots +} + +private fun classpathForFile(module: GradleModule, file: File): List { + val normalizedFile = file.absoluteFile.normalize() + val isTestFile = module.testSourceRoots.any { root -> rootContains(root, normalizedFile) } + return (module.classpath + moduleOutputRoots(module.directory, isTestFile)) + .filter { it.exists() } + .distinctBy { it.absoluteFile.normalize().path } +} + +private fun importGradleWorkspace(root: File): GradleWorkspace { + if (!File(root, "settings.gradle.kts").isFile && + !File(root, "settings.gradle").isFile && + !File(root, "build.gradle.kts").isFile && + !File(root, "build.gradle").isFile + ) { + error("No Gradle build file found in ${root.path}") + } + + val connector = GradleConnector.newConnector().forProjectDirectory(root) + connector.connect().use { connection -> + val gradleProject = connection.getModel(GradleProject::class.java) + val ideaProject = connection.getModel(IdeaProject::class.java) + + return GradleWorkspace( + root = root, + modules = ideaProject.modules.map { module -> + val sourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.sourceDirectories.map { it.directory } } + + generatedSourceRoots(module.gradleProject.projectDirectory, test = false) + val testSourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testDirectories.map { it.directory } } + + generatedSourceRoots(module.gradleProject.projectDirectory, test = true) + GradleModule( + name = module.name, + gradlePath = module.gradleProject.path, + directory = module.gradleProject.projectDirectory, + sourceRoots = sourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path }, + testSourceRoots = testSourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path }, + resourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.resourceDirectories.map { it.directory } }.distinctBy { it.path }, + testResourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testResourceDirectories.map { it.directory } }.distinctBy { it.path }, + classpath = module.dependencies + .filterIsInstance() + .map { it.file } + .filter { it.exists() } + .distinctBy { it.path }, + ) + }, + tasks = collectTasks(gradleProject), + ) + } +} + +private fun generatedSourceRoots(projectDir: File, test: Boolean): List { + val sourceSet = if (test) "test" else "main" + val kotlinSourceSets = if (test) listOf("test", "commonTest", "jvmTest") else listOf("main", "commonMain", "jvmMain") + return buildList { + add(File(projectDir, "build/generated/source/kapt/$sourceSet")) + add(File(projectDir, "build/generated/source/kaptKotlin/$sourceSet")) + add(File(projectDir, "build/generated/source/annotationProcessor/java/$sourceSet")) + add(File(projectDir, "build/generated/sources/annotationProcessor/java/$sourceSet")) + add(File(projectDir, "build/generated/sources/kapt/$sourceSet")) + for (generatedSourceSet in kotlinSourceSets) { + add(File(projectDir, "build/generated/ksp/$generatedSourceSet/kotlin")) + add(File(projectDir, "build/generated/ksp/$generatedSourceSet/java")) + add(File(projectDir, "build/generated/sources/ksp/$generatedSourceSet/kotlin")) + add(File(projectDir, "build/generated/sources/ksp/$generatedSourceSet/java")) + } + } +} + +private fun moduleOutputRoots(projectDir: File, includeTest: Boolean): List { + val mainRoots = listOf( + File(projectDir, "build/classes/kotlin/main"), + File(projectDir, "build/classes/kotlin/jvm/main"), + File(projectDir, "build/classes/kotlin/jvmMain"), + File(projectDir, "build/classes/java/main"), + File(projectDir, "build/classes/java/jvm/main"), + File(projectDir, "build/resources/main"), + File(projectDir, "build/resources/jvm/main"), + ) + if (!includeTest) return mainRoots + return mainRoots + listOf( + File(projectDir, "build/classes/kotlin/test"), + File(projectDir, "build/classes/kotlin/jvm/test"), + File(projectDir, "build/classes/kotlin/jvmTest"), + File(projectDir, "build/classes/java/test"), + File(projectDir, "build/classes/java/jvm/test"), + File(projectDir, "build/resources/test"), + File(projectDir, "build/resources/jvm/test"), + ) +} + +private fun collectTasks(project: GradleProject): List { + val tasks = mutableListOf() + + fun visit(current: GradleProject) { + current.tasks.forEach { task -> + tasks += GradleTaskInfo( + path = task.path, + name = task.name, + description = task.description, + ) + } + current.children.forEach(::visit) + } + + visit(project) + return tasks.sortedWith(compareBy { it.path }.thenBy { it.name }) +} + +private fun workspaceJson(workspace: GradleWorkspace): String { + return buildString { + append("{\"root\":\"").append(escapeJson(workspace.root.path)).append("\"") + append(",\"gradle\":true") + append(",\"modules\":[") + workspace.modules.forEachIndexed { index, module -> + if (index > 0) append(',') + append(moduleJson(module)) + } + append(']') + append(",\"sourceRoots\":").append(filesJson(workspace.modules.flatMap { it.sourceRoots + it.testSourceRoots })) + append(",\"tasks\":").append(tasksJson(workspace.tasks)) + append('}') + } +} + +private fun moduleJson(module: GradleModule): String = buildString { + append("{\"name\":\"").append(escapeJson(module.name)).append("\"") + append(",\"gradlePath\":\"").append(escapeJson(module.gradlePath)).append("\"") + append(",\"directory\":\"").append(escapeJson(module.directory.path)).append("\"") + append(",\"sourceRoots\":").append(filesJson(module.sourceRoots)) + append(",\"testSourceRoots\":").append(filesJson(module.testSourceRoots)) + append(",\"resourceRoots\":").append(filesJson(module.resourceRoots)) + append(",\"testResourceRoots\":").append(filesJson(module.testResourceRoots)) + append(",\"classpath\":").append(filesJson(module.classpath)) + append('}') +} + +private fun compileForDiagnostics(file: File, module: GradleModule, openText: String?): List { + return when (file.extension.lowercase()) { + "java" -> compileJavaForDiagnostics(file, module, openText) + else -> compileKotlinForDiagnostics(file, module, openText) + } +} + +private fun compileKotlinForDiagnostics(file: File, module: GradleModule, openText: String?): List { + val originalPath = file.absoluteFile.normalize().path + var tempDir: File? = null + val sourceFile = if (openText == null) { + file.absoluteFile.normalize() + } else { + tempDir = Files.createTempDirectory("native-editor-kotlin-").toFile() + val packageDir = sourcePackageName(openText, semicolon = false)?.replace('.', File.separatorChar)?.let { File(tempDir, it) } ?: tempDir + packageDir.mkdirs() + val tempFile = File(packageDir, file.name) + tempFile.writeText(openText) + tempFile + } + + try { + val sourceFilePath = sourceFile.absoluteFile.normalize().path + + val diagnosticSourceRoots = sourceRootsForFile(module, file) + val sourceFiles = collectKotlinAndJavaSources(diagnosticSourceRoots) + .filter { it.absoluteFile.normalize().path != originalPath } + .map { it.absoluteFile.normalize().path } + .toMutableList() + sourceFiles += sourceFilePath + + val sourceRoots = diagnosticSourceRoots.filter { it.exists() }.joinToString(File.pathSeparator) { it.path } + val classpath = classpathForFile(module, file).joinToString(File.pathSeparator) { it.path } + val args = buildList { + add("-no-stdlib") + add("-no-reflect") + if (classpath.isNotEmpty()) { + add("-classpath") + add(classpath) + } + if (sourceRoots.isNotEmpty()) { + add("-Xjava-source-roots=$sourceRoots") + } + addAll(sourceFiles) + } + + val output = ByteArrayOutputStream() + val exitCode = K2JVMCompiler().exec(PrintStream(output, true, Charsets.UTF_8), MessageRenderer.PLAIN_RELATIVE_PATHS, *args.toTypedArray()) + val diagnostics = parseCompilerOutput(output.toString(Charsets.UTF_8), sourceFile, file, openText != null) + .filter { it.path == originalPath } + .toMutableList() + if (exitCode == ExitCode.INTERNAL_ERROR && diagnostics.none { it.severity == "error" }) { + diagnostics += KotlinDiagnostic("error", "Kotlin compiler internal error", originalPath, null, null) + } + return diagnostics + } finally { + tempDir?.deleteRecursively() + } +} + +private fun compileJavaForDiagnostics(file: File, module: GradleModule, openText: String?): List { + val compiler = ToolProvider.getSystemJavaCompiler() + ?: return listOf(KotlinDiagnostic("error", "No system Java compiler available", file.path, null, null)) + val originalPath = file.absoluteFile.normalize().path + var tempDir: File? = null + val sourceFile = if (openText == null) { + file.absoluteFile.normalize() + } else { + tempDir = Files.createTempDirectory("native-editor-java-").toFile() + val packageDir = sourcePackageName(openText, semicolon = true)?.replace('.', File.separatorChar)?.let { File(tempDir, it) } ?: tempDir + packageDir.mkdirs() + val tempFile = File(packageDir, file.name) + tempFile.writeText(openText) + tempFile + } + + try { + val diagnostics = DiagnosticCollector() + compiler.getStandardFileManager(diagnostics, null, Charsets.UTF_8).use { fileManager -> + val classpath = classpathForFile(module, file) + if (classpath.isNotEmpty()) { + fileManager.setLocation(StandardLocation.CLASS_PATH, classpath) + } + val sourcePath = sourceRootsForFile(module, file).filter { it.exists() } + if (sourcePath.isNotEmpty()) { + fileManager.setLocation(StandardLocation.SOURCE_PATH, sourcePath) + } + val units = fileManager.getJavaFileObjectsFromFiles(listOf(sourceFile)) + val options = listOf("-proc:none") + compiler.getTask(null, fileManager, diagnostics, options, null, units).call() + } + + val sourcePath = sourceFile.absoluteFile.normalize().path + return diagnostics.diagnostics.mapNotNull { diagnostic -> + val diagnosticPath = diagnostic.source?.toUri()?.let { File(it).absoluteFile.normalize().path } + if (diagnosticPath != sourcePath) return@mapNotNull null + val severity = when (diagnostic.kind) { + javax.tools.Diagnostic.Kind.ERROR -> "error" + javax.tools.Diagnostic.Kind.WARNING, + javax.tools.Diagnostic.Kind.MANDATORY_WARNING -> "warning" + else -> "info" + } + KotlinDiagnostic( + severity = severity, + message = diagnostic.getMessage(null), + path = originalPath, + line = diagnostic.lineNumber.takeIf { it > 0 }?.toInt(), + column = diagnostic.columnNumber.takeIf { it > 0 }?.toInt(), + ) + } + } finally { + tempDir?.deleteRecursively() + } +} + +private fun collectKotlinSources(module: GradleModule): List { + return collectKotlinSources(module.sourceRoots + module.testSourceRoots) +} + +private fun sourcePackageName(text: String, semicolon: Boolean): String? { + val terminator = if (semicolon) "\\s*;" else "\\b" + val match = Regex("(?m)^\\s*package\\s+([A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)$terminator").find(text) + ?: return null + return match.groupValues[1] +} + +private fun collectKotlinSources(sourceRoots: List): List { + return sourceRoots + .filter { it.isDirectory } + .flatMap { root -> + root.walkTopDown() + .filter { file -> file.isFile && (file.extension == "kt" || file.extension == "kts") } + .toList() + } + .distinctBy { it.absoluteFile.normalize().path } +} + +private fun collectKotlinAndJavaSources(sourceRoots: List): List { + return sourceRoots + .filter { it.isDirectory } + .flatMap { root -> + root.walkTopDown() + .filter { file -> file.isFile && file.extension in setOf("kt", "kts", "java") } + .toList() + } + .distinctBy { it.absoluteFile.normalize().path } +} + +private fun collectWorkspaceKotlinSources(workspace: GradleWorkspace): List { + return workspace.modules + .flatMap { collectKotlinSources(it) } + .distinctBy { it.absoluteFile.normalize().path } +} + +private fun collectWorkspaceSourceFiles(workspace: GradleWorkspace): List { + return workspace.modules + .flatMap { module -> collectKotlinAndJavaSources(module.sourceRoots + module.testSourceRoots) } + .distinctBy { it.absoluteFile.normalize().path } +} + +private fun findSimpleDeclaration(workspace: GradleWorkspace, identifier: String): SourceLocation? { + if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null + + for (file in collectWorkspaceSourceFiles(workspace)) { + val text = runCatching { file.readText() }.getOrNull() ?: continue + findSimpleDeclarationInText(file.absoluteFile.normalize().path, text, identifier)?.let { return it } + } + return null +} + +private fun findSimpleDeclarationInText(path: String, text: String, identifier: String): SourceLocation? { + if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null + val escaped = Regex.escape(identifier) + val declarations = listOf( + Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+$escaped\\b"), + Regex("\\b(class|interface|enum|record)\\s+$escaped\\b"), + Regex("\\b[A-Za-z_][A-Za-z0-9_<>, ?\\[\\]]+\\s+$escaped\\s*\\("), + Regex("\\b[A-Za-z_][A-Za-z0-9_<>, ?\\[\\]]+\\s+$escaped\\s*(=|;)") + ) + for (declaration in declarations) { + val match = declaration.find(text) ?: continue + val lineColumn = lineColumnForOffset(text, match.range.first + match.value.indexOf(identifier)) + return SourceLocation(path, lineColumn.first, lineColumn.second) + } + return null +} + +private fun findSimpleReferences(workspace: GradleWorkspace, openPath: String, openText: String, identifier: String): List { + if (identifier.isBlank() || identifier in allKeywordCompletionItems) return emptyList() + val locations = mutableListOf() + locations += findSimpleReferencesInText(openPath, openText, identifier) + + for (file in collectWorkspaceSourceFiles(workspace)) { + val path = file.absoluteFile.normalize().path + if (path == openPath) continue + val text = runCatching { file.readText() }.getOrNull() ?: continue + locations += findSimpleReferencesInText(path, text, identifier) + if (locations.size >= 100) break + } + return locations.take(100) +} + +private fun findSimpleReferencesInText(path: String, text: String, identifier: String): List { + val regex = Regex("\\b${Regex.escape(identifier)}\\b") + return regex.findAll(text).map { match -> + val lineColumn = lineColumnForOffset(text, match.range.first) + SourceLocation(path, lineColumn.first, lineColumn.second) + }.toList() +} + +private fun lineColumnForOffset(text: String, offset: Int): Pair { + var line = 1 + var column = 1 + val end = offset.coerceIn(0, text.length) + for (index in 0 until end) { + if (text[index] == '\n') { + line++ + column = 1 + } else { + column++ + } + } + return line to column +} + +private fun parseCompilerOutput(output: String, sourceFile: File, originalFile: File, usedTempFile: Boolean): List { + val pattern = Regex("^(.+):(\\d+):(\\d+):\\s+(error|warning|info):\\s+(.+)$") + val sourcePath = sourceFile.absoluteFile.normalize().path + val originalPath = originalFile.absoluteFile.normalize().path + return output.lineSequence().mapNotNull { line -> + val match = pattern.matchEntire(line.trim()) ?: return@mapNotNull null + val path = File(match.groupValues[1]).absoluteFile.normalize().path + KotlinDiagnostic( + severity = match.groupValues[4], + message = match.groupValues[5], + path = if (usedTempFile && path == sourcePath) originalPath else path, + line = match.groupValues[2].toIntOrNull(), + column = match.groupValues[3].toIntOrNull(), + ) + }.toList() +} + +private val kotlinKeywordCompletionItems = listOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", + "is", "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias", + "val", "var", "when", "while", +) + +private val javaKeywordCompletionItems = listOf( + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", + "default", "do", "double", "else", "enum", "extends", "false", "final", "finally", "float", "for", "goto", + "if", "implements", "import", "instanceof", "int", "interface", "long", "native", "new", "null", "package", + "private", "protected", "public", "record", "return", "short", "static", "strictfp", "super", "switch", + "synchronized", "this", "throw", "throws", "transient", "true", "try", "void", "volatile", "while", +) + +private val allKeywordCompletionItems = (kotlinKeywordCompletionItems + javaKeywordCompletionItems).distinct() + +private fun completionCandidates(prefix: String, currentText: String, workspace: GradleWorkspace?): List { + val normalized = prefix.lowercase() + val candidates = linkedMapOf() + + fun add(label: String, kind: String) { + if (label.isBlank()) return + if (normalized.isNotEmpty() && !label.lowercase().startsWith(normalized)) return + candidates.putIfAbsent(label, CompletionCandidate(label, kind)) + } + + for (keyword in allKeywordCompletionItems) add(keyword, "keyword") + collectCompletionIdentifiers(currentText, 80).forEach { add(it.first, it.second) } + + if (workspace != null && candidates.size < 80) { + for (file in collectWorkspaceSourceFiles(workspace)) { + val text = runCatching { file.readText() }.getOrNull() ?: continue + collectCompletionIdentifiers(text, 60).forEach { add(it.first, it.second) } + if (candidates.size >= 80) break + } + } + + return candidates.values.take(80) +} + +private fun collectCompletionIdentifiers(text: String, limit: Int): List> { + val result = linkedMapOf() + val kotlinDeclaration = Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)") + for (match in kotlinDeclaration.findAll(text)) { + val kind = when (match.groupValues[1]) { + "fun" -> "function" + "class", "object", "interface", "typealias" -> "type" + else -> "variable" + } + result.putIfAbsent(match.groupValues[2], kind) + if (result.size >= limit) return result.map { it.key to it.value } + } + + val javaTypeDeclaration = Regex("\\b(class|interface|enum|record)\\s+([A-Za-z_][A-Za-z0-9_]*)") + for (match in javaTypeDeclaration.findAll(text)) { + result.putIfAbsent(match.groupValues[2], "type") + if (result.size >= limit) return result.map { it.key to it.value } + } + + val javaMemberDeclaration = Regex("\\b[A-Za-z_][A-Za-z0-9_<>, ?\\[\\]]+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\(|=|;)") + for (match in javaMemberDeclaration.findAll(text)) { + val name = match.groupValues[1] + val kind = if (match.groupValues[2] == "(") "function" else "variable" + if (name !in allKeywordCompletionItems) result.putIfAbsent(name, kind) + if (result.size >= limit) return result.map { it.key to it.value } + } + + val identifier = Regex("\\b[A-Za-z_][A-Za-z0-9_]*\\b") + for (match in identifier.findAll(text)) { + val value = match.value + if (value !in allKeywordCompletionItems) { + result.putIfAbsent(value, "identifier") + } + if (result.size >= limit) break + } + return result.map { it.key to it.value } +} + +private fun offsetForLineColumn(text: String, oneBasedLine: Int, oneBasedColumn: Int): Int { + val targetLine = oneBasedLine.coerceAtLeast(1) + val targetColumn = oneBasedColumn.coerceAtLeast(1) + var index = 0 + var line = 1 + while (index < text.length && line < targetLine) { + if (text[index] == '\n') line++ + index++ + } + + var column = 1 + while (index < text.length && column < targetColumn && text[index] != '\n') { + index++ + column++ + } + return index.coerceIn(0, text.length) +} + +private fun identifierPrefixAt(text: String, offset: Int): String { + val end = offset.coerceIn(0, text.length) + var start = end + while (start > 0 && isIdentifierPart(text[start - 1])) { + start-- + } + return text.substring(start, end) +} + +private fun identifierAt(text: String, offset: Int): String { + if (text.isEmpty()) return "" + val clamped = offset.coerceIn(0, text.length) + var start = clamped + if (start == text.length || !isIdentifierPart(text[start])) { + start-- + } + if (start < 0 || !isIdentifierPart(text[start])) return "" + var end = start + 1 + while (start > 0 && isIdentifierPart(text[start - 1])) start-- + while (end < text.length && isIdentifierPart(text[end])) end++ + return text.substring(start, end) +} + +private fun isIdentifierPart(ch: Char): Boolean = ch == '_' || ch.isLetterOrDigit() + +private fun isValidIdentifier(value: String): Boolean { + if (value.isEmpty()) return false + if (value.first() != '_' && !value.first().isLetter()) return false + return value.all(::isIdentifierPart) && value !in allKeywordCompletionItems +} + +private fun hoverContents(identifier: String, currentPath: String, currentText: String, workspace: GradleWorkspace?): String? { + if (identifier.isBlank()) return null + if (identifier in allKeywordCompletionItems) return "Keyword `$identifier`" + val currentDeclaration = findSimpleDeclarationInText(currentPath, currentText, identifier) + if (currentDeclaration != null) { + return hoverDeclarationContents(identifier, currentDeclaration, currentText) + } + if (workspace != null) { + for (file in collectWorkspaceSourceFiles(workspace)) { + val path = file.absoluteFile.normalize().path + if (path == currentPath) continue + val text = runCatching { file.readText() }.getOrNull() ?: continue + val declaration = findSimpleDeclarationInText(path, text, identifier) ?: continue + return hoverDeclarationContents(identifier, declaration, text) + } + } + return "Identifier `$identifier`" +} + +private fun hoverDeclarationContents(identifier: String, location: SourceLocation, text: String): String { + val declarationLine = sourceLine(text, location.line).trim().ifEmpty { identifier } + return "${declarationLine}\n${location.path}:${location.line}:${location.column}" +} + +private fun sourceLine(text: String, oneBasedLine: Int): String { + if (oneBasedLine <= 0) return "" + var line = 1 + var start = 0 + var index = 0 + while (index < text.length) { + if (line == oneBasedLine) { + val end = text.indexOf('\n', start).let { if (it < 0) text.length else it } + return text.substring(start, end) + } + if (text[index] == '\n') { + line++ + start = index + 1 + } + index++ + } + return if (line == oneBasedLine) text.substring(start) else "" +} + +private fun completionItemsJson(items: List): String = buildString { + append('[') + items.forEachIndexed { index, item -> + if (index > 0) append(',') + append("{\"label\":\"").append(escapeJson(item.label)).append("\",\"kind\":\"").append(escapeJson(item.kind)).append("\"}") + } + append(']') +} + +private fun definitionLocationsJson(location: SourceLocation?): String = buildString { + append('[') + if (location != null) { + append("{\"path\":\"").append(escapeJson(location.path)).append("\"") + append(",\"line\":").append(location.line) + append(",\"column\":").append(location.column) + append('}') + } + append(']') +} + +private fun locationsJson(locations: List): String = buildString { + append('[') + locations.forEachIndexed { index, location -> + if (index > 0) append(',') + append("{\"path\":\"").append(escapeJson(location.path)).append("\"") + append(",\"line\":").append(location.line) + append(",\"column\":").append(location.column) + append('}') + } + append(']') +} + +private fun renameEditsJson(locations: List, oldName: String, newName: String): String = buildString { + append('[') + locations.forEachIndexed { index, location -> + if (index > 0) append(',') + append("{\"path\":\"").append(escapeJson(location.path)).append("\"") + append(",\"line\":").append(location.line) + append(",\"column\":").append(location.column) + append(",\"oldText\":\"").append(escapeJson(oldName)).append("\"") + append(",\"newText\":\"").append(escapeJson(newName)).append("\"}") + } + append(']') +} + +private fun diagnosticsJson(diagnostics: List): String = buildString { + append('[') + diagnostics.forEachIndexed { index, diagnostic -> + if (index > 0) append(',') + append("{\"severity\":\"").append(escapeJson(diagnostic.severity)).append("\"") + append(",\"message\":\"").append(escapeJson(diagnostic.message)).append("\"") + append(",\"path\":") + if (diagnostic.path == null) append("null") else append('\"').append(escapeJson(diagnostic.path)).append('\"') + append(",\"line\":").append(diagnostic.line ?: "null") + append(",\"column\":").append(diagnostic.column ?: "null") + append('}') + } + append(']') +} + +private fun filesJson(files: List): String = buildString { + append('[') + files.distinctBy { it.path }.forEachIndexed { index, file -> + if (index > 0) append(',') + append('\"').append(escapeJson(file.path)).append('\"') + } + append(']') +} + +private fun tasksJson(tasks: List): String = buildString { + append('[') + tasks.forEachIndexed { index, task -> + if (index > 0) append(',') + append("{\"path\":\"").append(escapeJson(task.path)).append("\"") + append(",\"name\":\"").append(escapeJson(task.name)).append("\"") + append(",\"description\":") + if (task.description == null) { + append("null") + } else { + append('\"').append(escapeJson(task.description)).append('\"') + } + append('}') + } + append(']') +} + +private fun writeLineLocked(writer: BufferedWriter, text: String) { + writer.write(text) + writer.newLine() + writer.flush() +} + +private fun okJson(id: Int, result: String): String = "{\"id\":$id,\"ok\":true,\"result\":$result}" + +private fun errorJson(id: Int, code: String, message: String): String = + "{\"id\":$id,\"ok\":false,\"error\":{\"code\":\"${escapeJson(code)}\",\"message\":\"${escapeJson(message)}\"}}" + +private fun eventJson(event: String, params: String): String = + "{\"event\":\"${escapeJson(event)}\",\"params\":$params}" + +private fun extractJsonString(json: String, key: String): String? { + val marker = "\"$key\"" + val keyIndex = json.indexOf(marker) + if (keyIndex < 0) return null + val colon = json.indexOf(':', keyIndex + marker.length) + if (colon < 0) return null + var index = colon + 1 + while (index < json.length && json[index].isWhitespace()) index++ + if (index >= json.length || json[index] != '\"') return null + return readJsonString(json, index) +} + +private fun extractJsonInt(json: String, key: String): Int? { + val marker = "\"$key\"" + val keyIndex = json.indexOf(marker) + if (keyIndex < 0) return null + val colon = json.indexOf(':', keyIndex + marker.length) + if (colon < 0) return null + var index = colon + 1 + while (index < json.length && json[index].isWhitespace()) index++ + val start = index + if (index < json.length && json[index] == '-') index++ + while (index < json.length && json[index].isDigit()) index++ + if (index == start) return null + return json.substring(start, index).toIntOrNull() +} + +private fun readJsonString(json: String, quoteIndex: Int): String? { + val out = StringBuilder() + var index = quoteIndex + 1 + while (index < json.length) { + val ch = json[index] + if (ch == '\"') return out.toString() + if (ch == '\\') { + index++ + if (index >= json.length) return null + out.append( + when (val escaped = json[index]) { + '\"' -> '\"' + '\\' -> '\\' + '/' -> '/' + 'b' -> '\b' + 'f' -> '\u000C' + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + else -> escaped + } + ) + } else { + out.append(ch) + } + index++ + } + return null +} + +private fun escapeJson(value: String): String = buildString { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '\"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } +} diff --git a/docs/shader-toolchain.md b/docs/shader-toolchain.md new file mode 100644 index 0000000..885c2f2 --- /dev/null +++ b/docs/shader-toolchain.md @@ -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. diff --git a/protocol.md b/protocol.md new file mode 100644 index 0000000..9cefb5a --- /dev/null +++ b/protocol.md @@ -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"} + ] +} +``` diff --git a/scripts/autopilot-opencode.sh b/scripts/autopilot-opencode.sh new file mode 100755 index 0000000..ef2cb61 --- /dev/null +++ b/scripts/autopilot-opencode.sh @@ -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 diff --git a/scripts/compile-shaders.sh b/scripts/compile-shaders.sh new file mode 100755 index 0000000..e91f647 --- /dev/null +++ b/scripts/compile-shaders.sh @@ -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"