diff --git a/README.md b/README.md index 2aee58c..65ba477 100644 --- a/README.md +++ b/README.md @@ -36,21 +36,27 @@ 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: +Run the SDL editor window (the default mode): ```sh -odin run client/odin -- --sdl /path/to/workspace /path/to/file.kt +odin run client/odin ``` -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. +With no arguments the editor reopens the last workspace, falling back to the current directory on first launch. Workspace, daemon port, and file are all optional and can be passed in any order — a directory is the workspace, a number is a daemon port, anything else is a file to open: ```sh -odin run client/odin -- --sdl /path/to/workspace /path/to/file.kt +odin run client/odin -- /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. +The editor starts the daemon automatically when no port is passed. You can still connect to an already-running daemon by passing its port: + +```sh +odin run client/odin -- /path/to/workspace /path/to/file.kt +``` + +Auto-started daemons are restarted automatically if the process exits or the TCP connection drops. Explicit-port mode does not restart external daemons. The `--sdl` flag is still accepted for compatibility. Note that a bare numeric first argument (`odin run client/odin -- `) selects the smoke-test mode above, so pass a workspace too when connecting the editor to an external daemon. + +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. diff --git a/client/odin/daemon_client.odin b/client/odin/daemon_client.odin index b0f39ec..8e7ae65 100644 --- a/client/odin/daemon_client.odin +++ b/client/odin/daemon_client.odin @@ -111,16 +111,49 @@ daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) { return child, port, true } +// The daemon is its own Gradle project; the workspace is sent later over the +// protocol. Locate the daemon project relative to the environment or the +// editor executable so any folder can be opened as a workspace. +daemon_project_dir :: proc() -> (string, bool) { + env := os.get_env("NATIVE_EDITOR_DAEMON_DIR", context.temp_allocator) + if len(env) > 0 && os.is_dir(env) { + return env, true + } + if os.is_file("daemon/build.gradle.kts") { + return "daemon", true + } + exe, exe_err := os.get_executable_path(context.temp_allocator) + if exe_err == nil { + dir := exe + for _ in 0 ..< 5 { + parent, _ := os.split_path(dir) + if len(parent) == 0 || parent == dir do break + dir = strings.trim_suffix(parent, "/") + if len(dir) == 0 do break + if os.is_file(fmt.tprintf("%s/daemon/build.gradle.kts", dir)) { + return fmt.tprintf("%s/daemon", dir), true + } + } + } + return "", false +} + daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) { child := Daemon_Process{} + project_dir, found := daemon_project_dir() + if !found { + fmt.println("daemon project not found; set NATIVE_EDITOR_DAEMON_DIR to the daemon directory") + return child, false + } + 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"} + command := []string{"gradle", "-p", project_dir, "run", "--quiet"} process, start_err := os.process_start(os.Process_Desc{ command = command, stdout = stdout_w, @@ -387,7 +420,7 @@ daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int { daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) { active := editor_active_buffer(editor) - if active == nil do return + if active == nil || active.scratch do return text := editor_active_text(editor) defer delete(text) diff --git a/client/odin/diff_view.odin b/client/odin/diff_view.odin new file mode 100644 index 0000000..9f6bc58 --- /dev/null +++ b/client/odin/diff_view.odin @@ -0,0 +1,267 @@ +package main + +import "core:strings" + +// Side-by-side diff viewer. A unified diff is parsed into aligned rows: +// context lines appear on both sides, removed/added runs are paired up +// line-by-line (with blank filler cells for the unmatched remainder), and +// hunk headers become full-width separator rows. A buffer whose `diff` +// field is set renders this view instead of its text. + +Diff_Row :: struct { + header: bool, + header_text: string, + left_number: int, // 0 = no line on this side + right_number: int, + left_text: string, + right_text: string, + left_kind: u8, // '-' removed, ' ' context, 0 filler + right_kind: u8, // '+' added, ' ' context, 0 filler +} + +Diff_Document :: struct { + rows: [dynamic]Diff_Row, + scroll: int, +} + +diff_document_destroy :: proc(doc: ^Diff_Document) { + for row in doc.rows { + delete(row.header_text) + delete(row.left_text) + delete(row.right_text) + } + delete(doc.rows) + free(doc) +} + +diff_parse_leading_int :: proc(s: string) -> int { + value := 0 + for b in transmute([]u8)s { + if b < '0' || b > '9' do break + value = value * 10 + int(b - '0') + } + return value +} + +// "@@ -12,5 +14,6 @@ ..." -> (12, 14) +diff_parse_hunk_header :: proc(line: string) -> (int, int) { + left, right := 1, 1 + if minus := strings.index_byte(line, '-'); minus >= 0 { + left = max_int(diff_parse_leading_int(line[minus + 1:]), 1) + } + if plus := strings.index_byte(line, '+'); plus >= 0 { + right = max_int(diff_parse_leading_int(line[plus + 1:]), 1) + } + return left, right +} + +// Pairs the collected removed/added runs into aligned rows. +diff_flush_changes :: proc(doc: ^Diff_Document, removed, added: ^[dynamic]string, left_num, right_num: ^int) { + count := max_int(len(removed), len(added)) + for i in 0 ..< count { + row := Diff_Row{} + if i < len(removed) { + row.left_text = strings.clone(removed[i]) + row.left_kind = '-' + row.left_number = left_num^ + left_num^ += 1 + } + if i < len(added) { + row.right_text = strings.clone(added[i]) + row.right_kind = '+' + row.right_number = right_num^ + right_num^ += 1 + } + append(&doc.rows, row) + } + clear(removed) + clear(added) +} + +diff_document_make :: proc(text: string) -> ^Diff_Document { + doc := new(Diff_Document) + removed := make([dynamic]string, context.temp_allocator) + added := make([dynamic]string, context.temp_allocator) + left_num, right_num := 1, 1 + in_hunk := false + + lines, _ := strings.split_lines(text, context.temp_allocator) + for raw_line in lines { + line := strings.trim_right(raw_line, "\r") + + if strings.has_prefix(line, "@@") { + diff_flush_changes(doc, &removed, &added, &left_num, &right_num) + left_num, right_num = diff_parse_hunk_header(line) + in_hunk = true + append(&doc.rows, Diff_Row{header = true, header_text = strings.clone(line)}) + continue + } + if strings.has_prefix(line, "diff --git") { + diff_flush_changes(doc, &removed, &added, &left_num, &right_num) + in_hunk = false + continue + } + if !in_hunk { + // File headers (index, ---, +++, mode changes, binary notes). + if len(strings.trim_space(line)) > 0 && len(doc.rows) == 0 && !strings.has_prefix(line, "index ") && + !strings.has_prefix(line, "---") && !strings.has_prefix(line, "+++") && + !strings.has_prefix(line, "old mode") && !strings.has_prefix(line, "new mode") && + !strings.has_prefix(line, "new file") && !strings.has_prefix(line, "deleted file") && + !strings.has_prefix(line, "similarity") && !strings.has_prefix(line, "rename") && + !strings.has_prefix(line, "copy") { + // Notes like "Binary files differ" get a visible header row. + append(&doc.rows, Diff_Row{header = true, header_text = strings.clone(line)}) + } + continue + } + if len(line) == 0 do continue + + switch line[0] { + case ' ': + diff_flush_changes(doc, &removed, &added, &left_num, &right_num) + content := line[1:] + append(&doc.rows, Diff_Row{ + left_number = left_num, + right_number = right_num, + left_text = strings.clone(content), + right_text = strings.clone(content), + left_kind = ' ', + right_kind = ' ', + }) + left_num += 1 + right_num += 1 + case '-': + append(&removed, line[1:]) + case '+': + append(&added, line[1:]) + case '\\': + // "\ No newline at end of file" + } + } + diff_flush_changes(doc, &removed, &added, &left_num, &right_num) + return doc +} + +// Opens (or refreshes) a diff tab for the given unified diff text. +editor_open_diff :: proc(editor: ^Editor, name, diff_text: string) { + doc := diff_document_make(diff_text) + + for &buffer, index in editor.buffers { + if buffer.scratch && buffer.path == name { + editor_buffer_destroy(&buffer) + buffer = diff_editor_buffer(name, doc) + editor.active = index + return + } + } + append(&editor.buffers, diff_editor_buffer(name, doc)) + editor.active = len(editor.buffers) - 1 +} + +diff_editor_buffer :: proc(name: string, doc: ^Diff_Document) -> Editor_Buffer { + return Editor_Buffer{ + path = strings.clone(name), + daemon_path = strings.clone(""), + buffer = buffer_make(""), + scratch = true, + diff = doc, + } +} + +diff_view_scroll :: proc(active: ^Editor_Buffer, view: ^SDL_View, delta: int) { + doc := active.diff + visible := visible_editor_lines(view) + doc.scroll = clamp_int(doc.scroll + delta, 0, max_int(len(doc.rows) - visible, 0)) +} + +// --------------------------------------------------------------------------- +// Rendering + +DIFF_REMOVED_BG :: [3]u8{58, 33, 36} +DIFF_ADDED_BG :: [3]u8{34, 51, 38} +DIFF_FILLER_BG :: [3]u8{33, 34, 39} +DIFF_HEADER_BG :: [3]u8{39, 41, 49} + +render_diff_view_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, active: ^Editor_Buffer, tasks_panel: ^Gradle_Tasks_Panel) { + doc := active.diff + sidebar_width := left_sidebar_width(view) + right_limit := content_right_edge(gpu.width) - 8 + if tasks_panel != nil && tasks_panel.open { + right_limit = content_right_edge(gpu.width) - right_sidebar_width_view(view, gpu.width) - 8 + } + + area_x := f32(sidebar_width) + area_width := f32(right_limit) - area_x + area_bottom := f32(editor_content_bottom(view)) + half := area_width * 0.5 + advance := max_f32(gpu.font_advance, 1) + gutter_width := 5 * advance + 10 + columns := max_int(int((half - gutter_width - 16) / advance), 4) + + visible := visible_editor_lines(view) + doc.scroll = clamp_int(doc.scroll, 0, max_int(len(doc.rows) - visible, 0)) + + y := f32(SDL_EDITOR_TEXT_Y) + for offset in 0 ..< visible { + index := doc.scroll + offset + if index >= len(doc.rows) do break + row := doc.rows[index] + + if row.header { + gpu_rect(gpu, area_x, y - 3, area_width, SDL_LINE_HEIGHT + 1, DIFF_HEADER_BG[0], DIFF_HEADER_BG[1], DIFF_HEADER_BG[2], 255) + gpu_text_limited(gpu, area_x + 12, y, row.header_text, max_int(int((area_width - 24) / advance), 4), 150, 160, 180, 255) + } else { + render_diff_side_gpu(gpu, area_x, y, half, gutter_width, columns, row.left_number, row.left_text, row.left_kind) + render_diff_side_gpu(gpu, area_x + half + 1, y, half - 1, gutter_width, columns, row.right_number, row.right_text, row.right_kind) + } + y += SDL_LINE_HEIGHT + } + + // Center divider on top of the row backgrounds. + gpu_rect(gpu, area_x + half, SDL_EDITOR_TOP, 1, area_bottom - SDL_EDITOR_TOP, 52, 54, 62, 255) +} + +render_diff_side_gpu :: proc(gpu: ^GPU_Renderer, x, y, width, gutter_width: f32, columns, number: int, text: string, kind: u8) { + background: [3]u8 + has_background := true + switch kind { + case '-': + background = DIFF_REMOVED_BG + case '+': + background = DIFF_ADDED_BG + case 0: + background = DIFF_FILLER_BG + case: + has_background = false + } + if has_background { + gpu_rect(gpu, x, y - 3, width, SDL_LINE_HEIGHT + 1, background[0], background[1], background[2], 255) + } + if kind == 0 do return + + if number > 0 { + gpu_text(gpu, x + 6, y, fmt_diff_number(number), 116, 120, 128, 255) + } + marker_color := [3]u8{210, 214, 222} + if kind == '-' do marker_color = {235, 140, 145} + if kind == '+' do marker_color = {160, 210, 140} + gpu_text_limited(gpu, x + gutter_width, y, text, columns, marker_color[0], marker_color[1], marker_color[2], 255) +} + +fmt_diff_number :: proc(number: int) -> string { + buf: [8]u8 + value := number + index := len(buf) + for value > 0 && index > 0 { + index -= 1 + buf[index] = '0' + u8(value % 10) + value /= 10 + } + out := make([]u8, 4, context.temp_allocator) + for i in 0 ..< 4 { + out[i] = ' ' + } + digits := len(buf) - index + copy(out[max_int(4 - digits, 0):], buf[index:]) + return string(out) +} diff --git a/client/odin/editor.odin b/client/odin/editor.odin index 3ad1dea..d31c067 100644 --- a/client/odin/editor.odin +++ b/client/odin/editor.odin @@ -14,6 +14,8 @@ Diagnostic :: struct { Editor_Buffer :: struct { path: string, daemon_path: string, + scratch: bool, // in-memory tab (e.g. a git diff); never saved or synced + diff: ^Diff_Document, // when set, the tab renders a split diff view original_storage: []u8, buffer: Buffer, cursor: Cursor, @@ -60,6 +62,36 @@ editor_open_or_focus_file :: proc(editor: ^Editor, path: string) -> bool { return editor_open_file(editor, path) } +// Opens (or replaces) an in-memory tab that is not backed by a file. +editor_open_scratch :: proc(editor: ^Editor, name, contents: string) { + data := make([]u8, len(contents)) + copy(data, transmute([]u8)contents) + + for &buffer, index in editor.buffers { + if buffer.scratch && buffer.path == name { + editor_buffer_destroy(&buffer) + buffer = Editor_Buffer{ + path = strings.clone(name), + daemon_path = strings.clone(""), + original_storage = data, + buffer = buffer_make(string(data)), + scratch = true, + } + editor.active = index + return + } + } + + append(&editor.buffers, Editor_Buffer{ + path = strings.clone(name), + daemon_path = strings.clone(""), + original_storage = data, + buffer = buffer_make(string(data)), + scratch = true, + }) + editor.active = len(editor.buffers) - 1 +} + editor_replace_active_file :: proc(editor: ^Editor, path: string) -> bool { active := editor_active_buffer(editor) if active == nil do return false @@ -257,7 +289,7 @@ editor_insert_newline_auto_indent :: proc(active: ^Editor_Buffer) { editor_save_active :: proc(editor: ^Editor) -> bool { active := editor_active_buffer(editor) - if active == nil do return false + if active == nil || active.scratch do return false text := buffer_bytes(&active.buffer) defer delete(text) @@ -276,7 +308,7 @@ editor_save_active :: proc(editor: ^Editor) -> bool { editor_save_all :: proc(editor: ^Editor) -> bool { ok := true for &buffer in editor.buffers { - if !buffer.dirty do continue + if !buffer.dirty || buffer.scratch do continue text := buffer_bytes(&buffer.buffer) err := os.write_entire_file(buffer.path, text[:]) @@ -432,6 +464,10 @@ editor_destroy :: proc(editor: ^Editor) { } editor_buffer_destroy :: proc(editor_buffer: ^Editor_Buffer) { + if editor_buffer.diff != nil { + diff_document_destroy(editor_buffer.diff) + editor_buffer.diff = nil + } delete(editor_buffer.path) delete(editor_buffer.daemon_path) buffer_destroy(&editor_buffer.buffer) diff --git a/client/odin/git_panel.odin b/client/odin/git_panel.odin new file mode 100644 index 0000000..b61d215 --- /dev/null +++ b/client/odin/git_panel.odin @@ -0,0 +1,592 @@ +package main + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:sync" +import "core:thread" +import "core:time" + +// Git history browser for the bottom panel's Git tab. History and commit +// details come from spawned `git` processes whose output is read on a +// background thread and parsed in the per-frame pump, so the UI never blocks. + +GIT_PANEL_LOG_LIMIT :: 200 +GIT_PANEL_DETAIL_LIMIT :: 2000 +GIT_PANEL_ROW_HEIGHT :: 16 + +Git_Commit :: struct { + hash: string, + date: string, + author: string, + subject: string, +} + +Git_Load_Kind :: enum { + None, + Log, + Show, + Diff, +} + +Git_File :: struct { + status: u8, // M/A/D/R/C/T + path: string, +} + +// Reusable async runner for git commands: output is read on a background +// thread; poll it each frame until the full output is available. +Git_Loader :: struct { + process: os.Process, + process_active: bool, + stdout: ^os.File, + reader: ^thread.Thread, + reader_done: bool, + mutex: sync.Mutex, + pending: [dynamic]u8, + raw: [dynamic]u8, + active: bool, +} + +git_loader_destroy :: proc(loader: ^Git_Loader) { + git_loader_cancel(loader) + delete(loader.pending) + delete(loader.raw) +} + +git_loader_cancel :: proc(loader: ^Git_Loader) { + if !loader.active do return + if loader.process_active { + _ = os.process_terminate(loader.process) + _, _ = os.process_wait(loader.process) + loader.process_active = false + } + if loader.reader != nil { + // The child is dead, so the reader sees EOF and exits. + thread.join(loader.reader) + thread.destroy(loader.reader) + loader.reader = nil + } + if loader.stdout != nil { + _ = os.close(loader.stdout) + loader.stdout = nil + } + clear(&loader.pending) + clear(&loader.raw) + loader.active = false + loader.reader_done = false +} + +git_loader_reader_thread :: proc(data: rawptr) { + loader := (^Git_Loader)(data) + buf: [4096]u8 + for { + n, err := os.read(loader.stdout, buf[:]) + if err != nil || n <= 0 do break + if sync.mutex_guard(&loader.mutex) { + for b in buf[:n] { + append(&loader.pending, b) + } + } + } + loader.reader_done = true +} + +// stderr shares the pipe so git errors are visible in the output. +git_loader_spawn :: proc(loader: ^Git_Loader, command: []string) -> bool { + git_loader_cancel(loader) + + stdout_r, stdout_w, pipe_err := os.pipe() + if pipe_err != nil do return false + process, start_err := os.process_start(os.Process_Desc{ + command = command, + stdout = stdout_w, + stderr = stdout_w, + }) + _ = os.close(stdout_w) + if start_err != nil { + _ = os.close(stdout_r) + return false + } + + loader.process = process + loader.process_active = true + loader.stdout = stdout_r + loader.active = true + loader.reader_done = false + loader.reader = thread.create_and_start_with_data(rawptr(loader), git_loader_reader_thread) + return true +} + +// Drains output; returns (temp-allocated output, true) once the command has +// finished, cleaning the loader up. +git_loader_poll :: proc(loader: ^Git_Loader) -> (string, bool) { + if !loader.active do return "", false + if sync.mutex_guard(&loader.mutex) { + for b in loader.pending { + append(&loader.raw, b) + } + clear(&loader.pending) + } + if !loader.reader_done do return "", false + // One more drain: the reader's final chunk may land just before done. + if sync.mutex_guard(&loader.mutex) { + for b in loader.pending { + append(&loader.raw, b) + } + clear(&loader.pending) + } + output := strings.clone(string(loader.raw[:]), context.temp_allocator) + git_loader_cancel(loader) + return output, true +} + +Git_Panel :: struct { + commits: [dynamic]Git_Commit, + selected: int, + list_scroll: int, + files: [dynamic]Git_File, + file_selected: int, + file_scroll: int, + diff_text: string, // finished diff, handed to an editor tab + diff_ready: bool, + diff_path: string, // path the pending diff is for + diff_hash: string, + detail_hash: string, // hash the detail pane shows (or is loading) + message: string, + loaded_root: string, // workspace the commits were loaded from + load_kind: Git_Load_Kind, + loader: Git_Loader, +} + +git_panel_destroy :: proc(panel: ^Git_Panel) { + git_loader_destroy(&panel.loader) + panel.load_kind = .None + git_panel_clear_commits(panel) + delete(panel.commits) + git_panel_clear_detail(panel) + delete(panel.files) + delete(panel.diff_text) + delete(panel.diff_path) + delete(panel.diff_hash) + delete(panel.detail_hash) + delete(panel.message) + delete(panel.loaded_root) +} + +git_panel_clear_commits :: proc(panel: ^Git_Panel) { + for commit in panel.commits { + delete(commit.hash) + delete(commit.date) + delete(commit.author) + delete(commit.subject) + } + clear(&panel.commits) +} + +git_panel_clear_detail :: proc(panel: ^Git_Panel) { + for file in panel.files { + delete(file.path) + } + clear(&panel.files) + panel.file_selected = 0 + panel.file_scroll = 0 + git_panel_clear_diff(panel) +} + +git_panel_clear_diff :: proc(panel: ^Git_Panel) { + delete(panel.diff_text) + panel.diff_text = "" + panel.diff_ready = false + delete(panel.diff_path) + panel.diff_path = "" + delete(panel.diff_hash) + panel.diff_hash = "" +} + +git_panel_set_message :: proc(panel: ^Git_Panel, message: string) { + delete(panel.message) + panel.message = strings.clone(message) +} + +git_panel_spawn :: proc(panel: ^Git_Panel, kind: Git_Load_Kind, command: []string) { + if git_loader_spawn(&panel.loader, command) { + panel.load_kind = kind + } else { + panel.load_kind = .None + git_panel_set_message(panel, "git: failed to start (is git installed?)") + } +} + +git_panel_request_log :: proc(panel: ^Git_Panel, workspace: string) { + git_panel_clear_commits(panel) + panel.selected = 0 + panel.list_scroll = 0 + git_panel_clear_detail(panel) + delete(panel.detail_hash) + panel.detail_hash = "" + delete(panel.loaded_root) + panel.loaded_root = strings.clone(workspace) + git_panel_set_message(panel, "Loading git history...") + + limit := fmt.tprintf("-n%d", GIT_PANEL_LOG_LIMIT) + git_panel_spawn(panel, .Log, []string{"git", "-C", workspace, "log", limit, "--date=short", "--pretty=format:%h\t%ad\t%an\t%s"}) +} + +git_panel_request_show :: proc(panel: ^Git_Panel, workspace, hash: string) { + if panel.detail_hash == hash do return + git_panel_clear_detail(panel) + delete(panel.detail_hash) + panel.detail_hash = strings.clone(hash) + git_panel_spawn(panel, .Show, []string{"git", "-C", workspace, "show", "--name-status", "--format=", hash}) +} + +git_panel_request_diff :: proc(panel: ^Git_Panel, workspace, hash, path: string) { + git_panel_clear_diff(panel) + panel.diff_path = strings.clone(path) + panel.diff_hash = strings.clone(hash) + // --name-status paths are repo-root relative, but a plain pathspec is + // resolved against the workspace directory — which may be a subdirectory + // of the repository. :(top) anchors the pathspec to the repo root. + pathspec := fmt.tprintf(":(top)%s", path) + git_panel_spawn(panel, .Diff, []string{"git", "-C", workspace, "show", "--format=", "--patch", hash, "--", pathspec}) +} + +// Loads history once per workspace; explicit refreshes go through +// git_panel_request_log (panel reopen). +git_panel_activate :: proc(panel: ^Git_Panel, workspace: string) { + if panel.loaded_root == workspace do return + git_panel_request_log(panel, workspace) +} + +// Polls the loader; parses the output once the command finished. Per frame. +git_panel_pump :: proc(panel: ^Git_Panel) { + if panel.load_kind == .None do return + output, done := git_loader_poll(&panel.loader) + if !done do return + + kind := panel.load_kind + panel.load_kind = .None + + switch kind { + case .Log: + git_panel_parse_log(panel, output) + case .Show: + git_panel_parse_files(panel, output) + case .Diff: + git_panel_parse_diff(panel, output) + case .None: + } +} + +git_panel_parse_log :: proc(panel: ^Git_Panel, output: string) { + trimmed := strings.trim_space(output) + if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { + git_panel_set_message(panel, "Not a git repository") + return + } + + git_panel_clear_commits(panel) + lines, _ := strings.split_lines(output, context.temp_allocator) + for line in lines { + if len(strings.trim_space(line)) == 0 do continue + fields := strings.split_n(line, "\t", 4, context.temp_allocator) + if len(fields) < 4 do continue + append(&panel.commits, Git_Commit{ + hash = strings.clone(fields[0]), + date = strings.clone(fields[1]), + author = strings.clone(fields[2]), + subject = strings.clone(fields[3]), + }) + } + + if len(panel.commits) == 0 { + git_panel_set_message(panel, "No commits") + } else { + git_panel_set_message(panel, "") + panel.selected = clamp_int(panel.selected, 0, len(panel.commits) - 1) + } +} + +git_panel_parse_files :: proc(panel: ^Git_Panel, output: string) { + git_panel_clear_detail(panel) + lines, _ := strings.split_lines(output, context.temp_allocator) + for line in lines { + trimmed := strings.trim_right(line, "\r") + if len(trimmed) == 0 do continue + fields := strings.split(trimmed, "\t", context.temp_allocator) + if len(fields) < 2 do continue + status := fields[0][0] + // Renames/copies list "oldnew"; show the new path. + path := fields[len(fields) - 1] + append(&panel.files, Git_File{status = status, path = strings.clone(path)}) + } + panel.file_selected = -1 +} + +git_panel_parse_diff :: proc(panel: ^Git_Panel, output: string) { + delete(panel.diff_text) + text := output + if len(strings.trim_space(text)) == 0 { + text = "(no changes for this file in this commit)\n" + } + panel.diff_text = strings.clone(text) + panel.diff_ready = true +} + +// The editor tab name for a finished diff: " @ ". +git_panel_diff_tab_name :: proc(panel: ^Git_Panel, allocator := context.temp_allocator) -> string { + base := panel.diff_path + if slash := strings.last_index_byte(base, '/'); slash >= 0 { + base = base[slash + 1:] + } + return fmt.aprintf("%s @ %s", base, panel.diff_hash, allocator = allocator) +} + +// --------------------------------------------------------------------------- +// Layout inside the bottom panel body + +// Left column: commit list; right column: diff of the selected commit. +git_panel_list_width :: proc(panel_width: f32) -> f32 { + return max_f32(min_f32(panel_width * 0.4, 520), 240) +} + +git_panel_visible_rows :: proc(body_height: f32) -> int { + return max_int(int((body_height - 8) / GIT_PANEL_ROW_HEIGHT), 1) +} + +// Vertical layout of the detail pane: commit header, then the changed-file +// list. Shared by render, click and wheel handling. +Git_Detail_Layout :: struct { + files_y: f32, + file_rows: int, +} + +git_detail_layout :: proc(panel: ^Git_Panel, body_y, body_height: f32) -> Git_Detail_Layout { + layout: Git_Detail_Layout + header_height := f32(2 * GIT_PANEL_ROW_HEIGHT + 6) + layout.files_y = body_y + header_height + layout.file_rows = max_int(int((body_y + body_height - layout.files_y - 4) / GIT_PANEL_ROW_HEIGHT), 1) + return layout +} + +git_status_color :: proc(status: u8) -> (u8, u8, u8) { + switch status { + case 'A': + return 152, 195, 121 + case 'D': + return 224, 108, 117 + case 'R', 'C': + return 97, 175, 239 + case 'M', 'T': + return 229, 192, 123 + } + return 150, 154, 162 +} + +render_git_panel_body :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, panel: ^Git_Panel, x, y, width, height: f32) { + if len(panel.message) > 0 && len(panel.commits) == 0 { + gpu_text_limited(gpu, x + 14, y + 8, panel.message, int((width - 28) / max_f32(gpu.font_advance, 1)), 150, 154, 162, 255) + return + } + + advance := max_f32(gpu.font_advance, 1) + list_width := git_panel_list_width(width) + visible := git_panel_visible_rows(height) + panel.list_scroll = clamp_int(panel.list_scroll, 0, max_int(len(panel.commits) - visible, 0)) + + // Commit list. + list_columns := max_int(int((list_width - 24) / advance), 8) + row_y := y + 4 + for offset in 0 ..< visible { + index := panel.list_scroll + offset + if index >= len(panel.commits) do break + commit := panel.commits[index] + row_hovered := view.mouse_x >= x && view.mouse_x < x + list_width && view.mouse_y >= row_y && view.mouse_y < row_y + GIT_PANEL_ROW_HEIGHT + if index == panel.selected { + gpu_rect(gpu, x + 4, row_y, list_width - 8, GIT_PANEL_ROW_HEIGHT, 49, 56, 70, 255) + gpu_rect(gpu, x + 4, row_y, 2, GIT_PANEL_ROW_HEIGHT, 77, 155, 230, 255) + } else if row_hovered { + gpu_rect(gpu, x + 4, row_y, list_width - 8, GIT_PANEL_ROW_HEIGHT, 42, 44, 50, 255) + } + hash_width := f32(len(commit.hash)) * advance + date_width := f32(len(commit.date)) * advance + gpu_text(gpu, x + 12, row_y + 2, commit.hash, 120, 190, 250, 255) + gpu_text(gpu, x + 12 + hash_width + advance, row_y + 2, commit.date, 150, 154, 162, 255) + subject_x := x + 12 + hash_width + date_width + 2 * advance + subject_columns := max_int(int((x + list_width - 12 - subject_x) / advance), 4) + gpu_text_limited(gpu, subject_x, row_y + 2, commit.subject, subject_columns, 218, 222, 230, 255) + row_y += GIT_PANEL_ROW_HEIGHT + } + + // Divider + detail pane. + detail_x := x + list_width + gpu_rect(gpu, detail_x, y, 1, height, 48, 50, 56, 255) + detail_columns := max_int(int((width - list_width - 28) / advance), 8) + + if panel.selected < 0 || panel.selected >= len(panel.commits) do return + if len(panel.detail_hash) == 0 { + gpu_text_limited(gpu, detail_x + 14, y + 8, "Select a commit to see its changes", detail_columns, 150, 154, 162, 255) + return + } + + // Commit header from the already-parsed commit entry. + commit := panel.commits[panel.selected] + gpu_text_limited(gpu, detail_x + 14, y + 4, commit.subject, detail_columns, 220, 223, 228, 255) + meta := fmt.tprintf("%s %s %s", commit.hash, commit.date, commit.author) + gpu_text_limited(gpu, detail_x + 14, y + 4 + GIT_PANEL_ROW_HEIGHT, meta, detail_columns, 150, 154, 162, 255) + + layout := git_detail_layout(panel, y, height) + + // Changed files; clicking one opens its diff in an editor tab. + if len(panel.files) == 0 { + hint := "Loading commit..." if panel.load_kind == .Show else "No changed files" + gpu_text_limited(gpu, detail_x + 14, layout.files_y, hint, detail_columns, 150, 154, 162, 255) + return + } + panel.file_scroll = clamp_int(panel.file_scroll, 0, max_int(len(panel.files) - layout.file_rows, 0)) + file_y := layout.files_y + for offset in 0 ..< layout.file_rows { + index := panel.file_scroll + offset + if index >= len(panel.files) do break + file := panel.files[index] + row_hovered := view.mouse_x >= detail_x && view.mouse_x < x + width && view.mouse_y >= file_y && view.mouse_y < file_y + GIT_PANEL_ROW_HEIGHT + if index == panel.file_selected { + gpu_rect(gpu, detail_x + 4, file_y, width - list_width - 8, GIT_PANEL_ROW_HEIGHT, 49, 56, 70, 255) + gpu_rect(gpu, detail_x + 4, file_y, 2, GIT_PANEL_ROW_HEIGHT, 77, 155, 230, 255) + } else if row_hovered { + gpu_rect(gpu, detail_x + 4, file_y, width - list_width - 8, GIT_PANEL_ROW_HEIGHT, 42, 44, 50, 255) + } + status := [1]u8{file.status} + red, green, blue := git_status_color(file.status) + gpu_text(gpu, detail_x + 14, file_y + 2, string(status[:]), red, green, blue, 255) + gpu_text_limited(gpu, detail_x + 14 + 2 * advance, file_y + 2, file.path, max_int(detail_columns - 2, 4), 218, 222, 230, 255) + file_y += GIT_PANEL_ROW_HEIGHT + } +} + +// --------------------------------------------------------------------------- +// Self-test: `editor --git-selftest [dir]` loads history + a commit diff from +// a real repository through the async loader and reports what it parsed. + +git_panel_selftest :: proc(workspace: string) -> bool { + panel := Git_Panel{} + defer git_panel_destroy(&panel) + + wait_for_load :: proc(panel: ^Git_Panel) -> bool { + for _ in 0 ..< 200 { + git_panel_pump(panel) + if panel.load_kind == .None do return true + time.sleep(50 * time.Millisecond) + } + return false + } + + git_panel_request_log(&panel, workspace) + if !wait_for_load(&panel) { + fmt.println("FAIL git log timed out") + return false + } + if len(panel.commits) == 0 { + fmt.printf("FAIL no commits parsed (%s)\n", panel.message) + return false + } + first := panel.commits[0] + fmt.printf("ok %d commits; first: %s %s %s %q\n", len(panel.commits), first.hash, first.date, first.author, first.subject) + + git_panel_request_show(&panel, workspace, first.hash) + if !wait_for_load(&panel) { + fmt.println("FAIL git show timed out") + return false + } + if len(panel.files) == 0 { + fmt.println("FAIL changed files not parsed") + return false + } + fmt.printf("ok %d changed files; first: %c %s\n", len(panel.files), rune(panel.files[0].status), panel.files[0].path) + + // Simulate clicking every file of every commit; each diff must load + // with real contents. + checked := 0 + for commit_index in 0 ..< len(panel.commits) { + hash := strings.clone(panel.commits[commit_index].hash, context.temp_allocator) + delete(panel.detail_hash) + panel.detail_hash = "" + git_panel_request_show(&panel, workspace, hash) + if !wait_for_load(&panel) { + fmt.printf("FAIL git show timed out for %s\n", hash) + return false + } + for file_index in 0 ..< len(panel.files) { + git_panel_request_diff(&panel, workspace, panel.detail_hash, panel.files[file_index].path) + if !wait_for_load(&panel) { + fmt.printf("FAIL diff timed out: %s %s\n", hash, panel.files[file_index].path) + return false + } + if !panel.diff_ready || !strings.has_prefix(panel.diff_text, "diff --git") { + fmt.printf("FAIL empty diff: %s %s -> %q\n", hash, panel.files[file_index].path, panel.diff_text[:min_int(len(panel.diff_text), 60)]) + return false + } + checked += 1 + } + } + fmt.printf("ok %d file diffs loaded across %d commits\n", checked, len(panel.commits)) + + // The split-view parser must produce aligned rows; use a modification + // diff (HEAD commit) so context, removed and added rows all appear. + git_panel_request_show(&panel, workspace, panel.commits[0].hash) + if !wait_for_load(&panel) || len(panel.files) == 0 { + fmt.println("FAIL reloading HEAD commit") + return false + } + git_panel_request_diff(&panel, workspace, panel.detail_hash, panel.files[0].path) + if !wait_for_load(&panel) { + fmt.println("FAIL reloading HEAD diff") + return false + } + doc := diff_document_make(panel.diff_text) + defer diff_document_destroy(doc) + headers, contexts, removed, added, fillers, misaligned := 0, 0, 0, 0, 0, 0 + for row in doc.rows { + if row.header { + headers += 1 + continue + } + if row.left_kind == ' ' && row.right_kind == ' ' { + contexts += 1 + if row.left_text != row.right_text do misaligned += 1 + } + if row.left_kind == '-' do removed += 1 + if row.right_kind == '+' do added += 1 + if row.left_kind == 0 || row.right_kind == 0 do fillers += 1 + } + if len(doc.rows) == 0 || headers == 0 || misaligned > 0 { + fmt.printf("FAIL diff parse: rows=%d headers=%d misaligned=%d\n", len(doc.rows), headers, misaligned) + return false + } + fmt.printf("ok split view: %d rows (%d hunks, %d ctx, %d del, %d add, %d fill)\n", len(doc.rows), headers, contexts, removed, added, fillers) + + // A workspace that is a subdirectory of the repository must work too: + // that is where root-relative --name-status paths bite. + subdir := fmt.tprintf("%s/client", workspace) + if os.is_dir(subdir) { + git_panel_request_log(&panel, subdir) + if !wait_for_load(&panel) || len(panel.commits) == 0 { + fmt.println("FAIL subdir workspace: log") + return false + } + git_panel_request_show(&panel, subdir, panel.commits[0].hash) + if !wait_for_load(&panel) || len(panel.files) == 0 { + fmt.println("FAIL subdir workspace: files") + return false + } + git_panel_request_diff(&panel, subdir, panel.detail_hash, panel.files[0].path) + if !wait_for_load(&panel) || !strings.has_prefix(panel.diff_text, "diff --git") { + fmt.printf("FAIL subdir workspace: diff -> %q\n", panel.diff_text[:min_int(len(panel.diff_text), 60)]) + return false + } + fmt.printf("ok subdir workspace diff: %d bytes for %s\n", len(panel.diff_text), panel.diff_path) + } + fmt.println("git selftest: PASS") + return true +} diff --git a/client/odin/git_status.odin b/client/odin/git_status.odin new file mode 100644 index 0000000..a79bcf5 --- /dev/null +++ b/client/odin/git_status.odin @@ -0,0 +1,471 @@ +package main + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:time" +import SDL "vendor:sdl3" + +// Left-sidebar Git view: working-tree status with staging, unstaging and +// committing. All git commands run through the async Git_Loader; finishing +// an action re-runs `git status` so the lists stay current. + +GIT_STATUS_ROW_HEIGHT :: 17 +GIT_STATUS_INPUT_Y :: SDL_TOP_BAR_HEIGHT + 34 +GIT_STATUS_INPUT_HEIGHT :: 24 +GIT_STATUS_BUTTON_Y :: GIT_STATUS_INPUT_Y + GIT_STATUS_INPUT_HEIGHT + 8 +GIT_STATUS_BUTTON_HEIGHT :: 22 +GIT_STATUS_LIST_TOP :: GIT_STATUS_BUTTON_Y + GIT_STATUS_BUTTON_HEIGHT + 10 + +Git_Status_Entry :: struct { + staged: bool, + status: u8, // M/A/D/R/C/T/? (worktree or index status letter) + path: string, +} + +Git_Status_Op :: enum { + None, + Status, + Action, // stage or unstage + Commit, +} + +Git_Status_Panel :: struct { + entries: [dynamic]Git_Status_Entry, + branch: string, + message: string, // status line under the header + scroll: int, + input: [dynamic]u8, // commit message + input_focused: bool, + loaded_root: string, + committed: bool, // a commit just landed; history panels should refresh + op: Git_Status_Op, + loader: Git_Loader, +} + +git_status_destroy :: proc(panel: ^Git_Status_Panel) { + git_loader_destroy(&panel.loader) + git_status_clear_entries(panel) + delete(panel.entries) + delete(panel.branch) + delete(panel.message) + delete(panel.input) + delete(panel.loaded_root) +} + +git_status_clear_entries :: proc(panel: ^Git_Status_Panel) { + for entry in panel.entries { + delete(entry.path) + } + clear(&panel.entries) +} + +git_status_set_message :: proc(panel: ^Git_Status_Panel, message: string) { + delete(panel.message) + panel.message = strings.clone(message) +} + +git_status_request :: proc(panel: ^Git_Status_Panel, workspace: string) { + delete(panel.loaded_root) + panel.loaded_root = strings.clone(workspace) + git_status_set_message(panel, "Loading status...") + if git_loader_spawn(&panel.loader, []string{"git", "-C", workspace, "status", "--porcelain=v1", "--branch"}) { + panel.op = .Status + } else { + panel.op = .None + git_status_set_message(panel, "git: failed to start") + } +} + +git_status_toggle_entry :: proc(panel: ^Git_Status_Panel, workspace: string, index: int) { + if panel.op != .None do return + if index < 0 || index >= len(panel.entries) do return + entry := panel.entries[index] + pathspec := fmt.tprintf(":(top)%s", entry.path) + command: []string + if entry.staged { + command = []string{"git", "-C", workspace, "restore", "--staged", "--", pathspec} + } else { + command = []string{"git", "-C", workspace, "add", "--", pathspec} + } + if git_loader_spawn(&panel.loader, command) { + panel.op = .Action + } +} + +git_status_commit :: proc(panel: ^Git_Status_Panel, workspace: string) { + if panel.op != .None do return + message := strings.trim_space(string(panel.input[:])) + if len(message) == 0 { + git_status_set_message(panel, "Enter a commit message") + return + } + staged := 0 + for entry in panel.entries { + if entry.staged do staged += 1 + } + if staged == 0 { + git_status_set_message(panel, "Nothing staged to commit") + return + } + if git_loader_spawn(&panel.loader, []string{"git", "-C", workspace, "commit", "-m", message}) { + panel.op = .Commit + git_status_set_message(panel, "Committing...") + } +} + +git_status_pump :: proc(panel: ^Git_Status_Panel) { + if panel.op == .None do return + output, done := git_loader_poll(&panel.loader) + if !done do return + + op := panel.op + panel.op = .None + + switch op { + case .Status: + git_status_parse(panel, output) + case .Action: + trimmed := strings.trim_space(output) + if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { + git_status_set_message(panel, trimmed) + } + git_status_request(panel, panel.loaded_root) + case .Commit: + trimmed := strings.trim_space(output) + if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { + git_status_set_message(panel, trimmed) + } else { + clear(&panel.input) + panel.committed = true + } + git_status_request(panel, panel.loaded_root) + case .None: + } +} + +git_status_parse :: proc(panel: ^Git_Status_Panel, output: string) { + trimmed := strings.trim_space(output) + if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { + git_status_clear_entries(panel) + git_status_set_message(panel, "Not a git repository") + return + } + + git_status_clear_entries(panel) + delete(panel.branch) + panel.branch = strings.clone("") + + lines, _ := strings.split_lines(output, context.temp_allocator) + for line in lines { + if len(line) < 3 do continue + if strings.has_prefix(line, "## ") { + branch := line[3:] + if dots := strings.index(branch, "..."); dots >= 0 { + branch = branch[:dots] + } + delete(panel.branch) + panel.branch = strings.clone(branch) + continue + } + index_status := line[0] + worktree_status := line[1] + path := line[3:] + // Renames list "old -> new"; show the new name. + if arrow := strings.index(path, " -> "); arrow >= 0 { + path = path[arrow + 4:] + } + if index_status != ' ' && index_status != '?' { + append(&panel.entries, Git_Status_Entry{staged = true, status = index_status, path = strings.clone(path)}) + } + if worktree_status != ' ' { + status := worktree_status + append(&panel.entries, Git_Status_Entry{staged = false, status = status, path = strings.clone(path)}) + } + } + + if len(panel.entries) == 0 { + git_status_set_message(panel, "No changes") + } else { + git_status_set_message(panel, "") + } +} + +// --------------------------------------------------------------------------- +// Flattened rows: section headers + entries, shared by render and clicks. + +Git_Status_Row :: struct { + is_entry: bool, + entry_index: int, + label: string, // section label for header rows +} + +git_status_rows :: proc(panel: ^Git_Status_Panel) -> []Git_Status_Row { + rows := make([dynamic]Git_Status_Row, context.temp_allocator) + staged_count := 0 + for entry in panel.entries { + if entry.staged do staged_count += 1 + } + unstaged_count := len(panel.entries) - staged_count + + if staged_count > 0 { + append(&rows, Git_Status_Row{label = fmt.tprintf("STAGED (%d)", staged_count)}) + for entry, index in panel.entries { + if entry.staged do append(&rows, Git_Status_Row{is_entry = true, entry_index = index}) + } + } + if unstaged_count > 0 { + append(&rows, Git_Status_Row{label = fmt.tprintf("CHANGES (%d)", unstaged_count)}) + for entry, index in panel.entries { + if !entry.staged do append(&rows, Git_Status_Row{is_entry = true, entry_index = index}) + } + } + return rows[:] +} + +git_status_visible_rows :: proc(view: ^SDL_View) -> int { + return max_int((editor_content_bottom(view) - GIT_STATUS_LIST_TOP - 4) / GIT_STATUS_ROW_HEIGHT, 1) +} + +// --------------------------------------------------------------------------- +// Interaction + +git_status_panel_active :: proc(view: ^SDL_View) -> bool { + return view.explorer_visible && view.left_tab == .Git +} + +git_status_input_rect :: proc(view: ^SDL_View) -> (x, y, width, height: f32) { + x = f32(content_left_edge() + 10) + y = GIT_STATUS_INPUT_Y + width = f32(left_sidebar_width(view) - content_left_edge() - 20) + height = GIT_STATUS_INPUT_HEIGHT + return +} + +git_status_button_rect :: proc(view: ^SDL_View) -> (x, y, width, height: f32) { + width = f32(gpu_text_width("Commit")) + 24 + x = f32(left_sidebar_width(view)) - width - 10 + y = GIT_STATUS_BUTTON_Y + height = GIT_STATUS_BUTTON_HEIGHT + return +} + +handle_git_status_click :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, workspace: string, x, y: f32) -> bool { + if !git_status_panel_active(view) do return false + if x < f32(content_left_edge()) || x >= f32(left_sidebar_width(view)) do return false + if y < SDL_TOP_BAR_HEIGHT || y >= f32(editor_content_bottom(view)) do return false + + input_x, input_y, input_w, input_h := git_status_input_rect(view) + if x >= input_x && x < input_x + input_w && y >= input_y && y < input_y + input_h { + panel.input_focused = true + return true + } + panel.input_focused = false + + button_x, button_y, button_w, button_h := git_status_button_rect(view) + if x >= button_x && x < button_x + button_w && y >= button_y && y < button_y + button_h { + git_status_commit(panel, workspace) + return true + } + + if y >= GIT_STATUS_LIST_TOP { + rows := git_status_rows(panel) + row := panel.scroll + int((y - GIT_STATUS_LIST_TOP) / GIT_STATUS_ROW_HEIGHT) + if row >= 0 && row < len(rows) && rows[row].is_entry { + git_status_toggle_entry(panel, workspace, rows[row].entry_index) + } + } + return true +} + +handle_git_status_wheel :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, x, y: f32, wheel_y: int) -> bool { + if !git_status_panel_active(view) do return false + if x < f32(content_left_edge()) || x >= f32(left_sidebar_width(view)) do return false + rows := git_status_rows(panel) + max_scroll := max_int(len(rows) - git_status_visible_rows(view), 0) + panel.scroll = clamp_int(panel.scroll - wheel_y * 3, 0, max_scroll) + return true +} + +// Commit-message keyboard input. Returns true when the key was consumed. +handle_git_status_key :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, workspace: string, key: SDL.Keycode, mod: SDL.Keymod) -> bool { + if !git_status_panel_active(view) || !panel.input_focused do return false + switch key { + case SDL.K_ESCAPE: + panel.input_focused = false + case SDL.K_BACKSPACE: + if len(panel.input) > 0 { + resize(&panel.input, len(panel.input) - 1) + } + case SDL.K_RETURN, SDL.K_KP_ENTER: + git_status_commit(panel, workspace) + } + return true +} + +git_status_input_text :: proc(panel: ^Git_Status_Panel, text: string) { + for b in transmute([]u8)text { + if b >= 32 && b < 127 { + append(&panel.input, b) + } + } +} + +// --------------------------------------------------------------------------- +// Rendering + +render_git_status_panel_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, panel: ^Git_Status_Panel) { + left := f32(content_left_edge()) + width := f32(left_sidebar_width(view)) - left + advance := max_f32(gpu.font_advance, 1) + + header := "GIT" + if len(panel.branch) > 0 { + header = fmt.tprintf("GIT [%s]", panel.branch) + } + gpu_text_limited(gpu, left + 14, SDL_TREE_HEADER_Y, header, max_int(int((width - 28) / advance), 4), 150, 154, 162, 255) + gpu_rect(gpu, left + 14, SDL_TREE_HEADER_Y + 18, width - 28, 1, 52, 54, 60, 255) + + // Commit message input. + input_x, input_y, input_w, input_h := git_status_input_rect(view) + gpu_rect(gpu, input_x, input_y, input_w, input_h, 18, 20, 25, 255) + if panel.input_focused { + gpu_rect_outline(gpu, input_x, input_y, input_w, input_h, 77, 155, 230, 255) + } else { + gpu_rect_outline(gpu, input_x, input_y, input_w, input_h, 58, 62, 72, 255) + } + input_columns := max_int(int((input_w - 20) / advance), 4) + if len(panel.input) == 0 && !panel.input_focused { + gpu_text_limited(gpu, input_x + 8, input_y + 5, "Commit message", input_columns, 110, 114, 124, 255) + } else { + text := string(panel.input[:]) + if len(text) > input_columns && input_columns > 3 { + text = fmt.tprintf("...%s", text[len(text) - (input_columns - 3):]) + } + gpu_text(gpu, input_x + 8, input_y + 5, text, 225, 228, 235, 255) + if panel.input_focused { + caret_x := input_x + 8 + f32(len(text)) * advance + gpu_rect(gpu, caret_x + 1, input_y + 4, 1, input_h - 8, 230, 230, 230, 255) + } + } + + // Commit button. + button_x, button_y, button_w, button_h := git_status_button_rect(view) + gpu_rect(gpu, button_x, button_y, button_w, button_h, 45, 84, 128, 255) + gpu_text(gpu, button_x + 12, button_y + 4, "Commit", 226, 236, 248, 255) + + if len(panel.message) > 0 { + gpu_text_limited(gpu, left + 14, GIT_STATUS_BUTTON_Y + 4, panel.message, max_int(int((button_x - left - 22) / advance), 4), 150, 154, 162, 255) + } + + // Entries. + rows := git_status_rows(panel) + visible := git_status_visible_rows(view) + panel.scroll = clamp_int(panel.scroll, 0, max_int(len(rows) - visible, 0)) + row_y := f32(GIT_STATUS_LIST_TOP) + for offset in 0 ..< visible { + index := panel.scroll + offset + if index >= len(rows) do break + row := rows[index] + if !row.is_entry { + gpu_text_limited(gpu, left + 14, row_y + 2, row.label, max_int(int((width - 28) / advance), 4), 150, 154, 162, 255) + row_y += GIT_STATUS_ROW_HEIGHT + continue + } + entry := panel.entries[row.entry_index] + row_hovered := view.mouse_x >= left && view.mouse_x < left + width && view.mouse_y >= row_y && view.mouse_y < row_y + GIT_STATUS_ROW_HEIGHT + if row_hovered { + gpu_rect(gpu, left + 4, row_y, width - 8, GIT_STATUS_ROW_HEIGHT, 42, 44, 50, 255) + } + status := entry.status + if status == '?' do status = 'A' + red, green, blue := git_status_color(status) + marker := [1]u8{entry.status} + gpu_text(gpu, left + 14, row_y + 2, string(marker[:]), red, green, blue, 255) + path_columns := max_int(int((width - 28 - 2 * advance) / advance), 4) + gpu_text_limited(gpu, left + 14 + 2 * advance, row_y + 2, entry.path, path_columns, 218, 222, 230, 255) + row_y += GIT_STATUS_ROW_HEIGHT + } +} + +// --------------------------------------------------------------------------- +// Self-test: `editor --git-status-selftest ` exercises the status, +// stage, unstage and commit cycle against a scratch repository. + +git_status_selftest :: proc(workspace: string) -> bool { + panel := Git_Status_Panel{} + defer git_status_destroy(&panel) + + wait :: proc(panel: ^Git_Status_Panel) -> bool { + for _ in 0 ..< 200 { + git_status_pump(panel) + if panel.op == .None do return true + time.sleep(50 * time.Millisecond) + } + return false + } + + check :: proc(ok: ^bool, name: string, condition: bool) { + if condition { + fmt.printf("ok %s\n", name) + } else { + fmt.printf("FAIL %s\n", name) + ok^ = false + } + } + + ok := true + git_status_request(&panel, workspace) + if !wait(&panel) { + fmt.println("FAIL status timed out") + return false + } + check(&ok, "status parsed", len(panel.entries) > 0) + check(&ok, "untracked entry", len(panel.entries) > 0 && !panel.entries[0].staged && panel.entries[0].status == '?') + + git_status_toggle_entry(&panel, workspace, 0) + if !wait(&panel) { + fmt.println("FAIL stage timed out") + return false + } + staged := 0 + for entry in panel.entries { + if entry.staged do staged += 1 + } + check(&ok, "file staged", staged == 1) + + git_status_input_text(&panel, "selftest commit") + git_status_commit(&panel, workspace) + if !wait(&panel) { + fmt.println("FAIL commit timed out") + return false + } + check(&ok, "commit landed", panel.committed && len(panel.entries) == 0 && len(panel.input) == 0) + check(&ok, "branch known", len(panel.branch) > 0) + + // Modify the committed file, stage it, then unstage it again. + if len(panel.entries) == 0 { + _ = os.write_entire_file(fmt.tprintf("%s/file.txt", workspace), transmute([]u8)string("changed\n")) + git_status_request(&panel, workspace) + if !wait(&panel) { + fmt.println("FAIL modified status timed out") + return false + } + check(&ok, "modification seen", len(panel.entries) == 1 && !panel.entries[0].staged && panel.entries[0].status == 'M') + git_status_toggle_entry(&panel, workspace, 0) + if !wait(&panel) { + fmt.println("FAIL stage timed out") + return false + } + check(&ok, "modification staged", len(panel.entries) == 1 && panel.entries[0].staged) + git_status_toggle_entry(&panel, workspace, 0) + if !wait(&panel) { + fmt.println("FAIL unstage timed out") + return false + } + check(&ok, "modification unstaged", len(panel.entries) == 1 && !panel.entries[0].staged) + } + + fmt.println(ok ? "git status selftest: PASS" : "git status selftest: FAIL") + return ok +} diff --git a/client/odin/gpu_renderer.odin b/client/odin/gpu_renderer.odin index 45233cc..845d863 100644 --- a/client/odin/gpu_renderer.odin +++ b/client/odin/gpu_renderer.odin @@ -37,16 +37,32 @@ GPU_Renderer :: struct { text_transfer_buffer: ^SDL.GPUTransferBuffer, font_texture: ^SDL.GPUTexture, font_sampler: ^SDL.GPUSampler, - font_chars: [95]stbtt.bakedchar, + font_chars: [95]stbtt.packedchar, + font_extra: map[rune]stbtt.packedchar, font_advance: f32, vertices: [dynamic]GPU_Vertex, text_vertices: [dynamic]GPU_Text_Vertex, + batches: [dynamic]GPU_Batch, max_vertices: int, max_text_vertices: int, width: int, height: int, } +// Draw batches preserve submission order across the two pipelines, so text +// queued before an opaque rect stays underneath it. Consecutive quads of the +// same kind merge into one draw call. +GPU_Batch_Kind :: enum { + Rect, + Text, +} + +GPU_Batch :: struct { + kind: GPU_Batch_Kind, + first: int, + count: int, +} + // Compiled SPIR-V is embedded at build time so the binary never depends on // finding shader files at runtime. Regenerate with scripts/compile-shaders.sh // after editing the GLSL sources, then rebuild. @@ -57,7 +73,7 @@ GPU_TEXT_FRAG_SPV :: #load("shaders/compiled/text.frag.spv") GPU_MAX_VERTICES :: 240000 GPU_MAX_TEXT_VERTICES :: 120000 -GPU_FONT_ATLAS_SIZE :: 512 +GPU_FONT_ATLAS_SIZE :: 1024 GPU_FONT_PIXEL_HEIGHT :: 15.0 GPU_FONT_BASELINE_OFFSET :: 11.0 GPU_FONT_CELL_PADDING :: 1.0 @@ -226,6 +242,8 @@ gpu_renderer_destroy :: proc(gpu: ^GPU_Renderer) { SDL.DestroyGPUDevice(gpu.device) } delete(gpu.vertices) + delete(gpu.batches) + delete(gpu.font_extra) delete(gpu.text_vertices) gpu^ = {} } @@ -249,6 +267,7 @@ gpu_create_shader :: proc(device: ^SDL.GPUDevice, name: string, code: []u8, stag gpu_begin :: proc(gpu: ^GPU_Renderer) { clear(&gpu.vertices) clear(&gpu.text_vertices) + clear(&gpu.batches) w, h: i32 if SDL.GetWindowSize(gpu.window, &w, &h) { gpu.width = int(w) @@ -323,6 +342,35 @@ gpu_text_limited :: proc(gpu: ^GPU_Renderer, x, y: f32, text: string, max_chars: } } +// Draws a single glyph, returning false when the font has no glyph so the +// caller can substitute an ASCII approximation. +gpu_rune :: proc(gpu: ^GPU_Renderer, x, y: f32, r: rune, red, green, blue, a: u8) -> bool { + if r >= 32 && r < 127 { + buf := [1]u8{u8(r)} + gpu_text(gpu, x, y, string(buf[:]), red, green, blue, a) + return true + } + glyph, ok := gpu.font_extra[r] + if !ok do return false + + glyph_w := f32(glyph.x1 - glyph.x0) + glyph_h := f32(glyph.y1 - glyph.y0) + if glyph_w <= 0 || glyph_h <= 0 do return true + + xpos := round_f32(x) + ypos := y + GPU_FONT_BASELINE_OFFSET + 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_f32(red, green, blue, a)) + return true +} + gpu_text_width :: proc(text: string) -> int { if len(text) == 0 do return 0 width: f32 = 0 @@ -415,21 +463,27 @@ gpu_present :: proc(gpu: ^GPU_Renderer) { 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) + uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}} + bound := false + bound_kind := GPU_Batch_Kind.Rect + for batch in gpu.batches { + if batch.count == 0 do continue + if !bound || bound_kind != batch.kind { + SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms)) + switch batch.kind { + case .Rect: + SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline) + SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1) + case .Text: + binding := SDL.GPUTextureSamplerBinding{texture = gpu.font_texture, sampler = gpu.font_sampler} + SDL.BindGPUGraphicsPipeline(pass, gpu.text_pipeline) + SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1) + SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1) + } + bound = true + bound_kind = batch.kind + } + SDL.DrawGPUPrimitives(pass, u32(batch.count), 1, u32(batch.first), 0) } SDL.EndGPURenderPass(pass) _ = SDL.SubmitGPUCommandBuffer(command) @@ -443,11 +497,68 @@ gpu_create_font_atlas :: proc(gpu: ^GPU_Renderer) -> bool { 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) + // Extra ranges cover what terminal programs commonly draw: Latin-1, + // general punctuation, arrows, box drawing/blocks/geometric shapes, + // check marks and braille (spinners). + Extra_Range :: struct { + first: int, + count: int, + } + extra_ranges := [?]Extra_Range{ + {0x00A1, 95}, + {0x2010, 24}, + {0x2190, 4}, + {0x2500, 256}, + {0x2713, 6}, + {0x2800, 256}, + } + total_extra := 0 + for r in extra_ranges { + total_extra += r.count + } + extra_chars := make([]stbtt.packedchar, total_extra) + defer delete(extra_chars) + + ranges: [1 + len(extra_ranges)]stbtt.pack_range + ranges[0] = stbtt.pack_range{ + font_size = GPU_FONT_PIXEL_HEIGHT, + first_unicode_codepoint_in_range = 32, + num_chars = i32(len(gpu.font_chars)), + chardata_for_range = &gpu.font_chars[0], + } + offset := 0 + for r, index in extra_ranges { + ranges[index + 1] = stbtt.pack_range{ + font_size = GPU_FONT_PIXEL_HEIGHT, + first_unicode_codepoint_in_range = i32(r.first), + num_chars = i32(r.count), + chardata_for_range = &extra_chars[offset], + } + offset += r.count + } + + pack: stbtt.pack_context + if stbtt.PackBegin(&pack, raw_data(atlas), GPU_FONT_ATLAS_SIZE, GPU_FONT_ATLAS_SIZE, 0, 1, nil) == 0 { + fmt.println("font pack failed:", font_path) return false } + stbtt.PackSetOversampling(&pack, 1, 1) + // Returns 0 when some codepoints are missing from the font; those are + // filtered out below via FindGlyphIndex, so partial packs are fine. + _ = stbtt.PackFontRanges(&pack, raw_data(font_bytes), 0, &ranges[0], i32(len(ranges))) + stbtt.PackEnd(&pack) + + font: stbtt.fontinfo + has_font_info := bool(stbtt.InitFont(&font, raw_data(font_bytes), stbtt.GetFontOffsetForIndex(raw_data(font_bytes), 0))) + offset = 0 + for r in extra_ranges { + for i in 0 ..< r.count { + code := rune(r.first + i) + if has_font_info && stbtt.FindGlyphIndex(&font, code) == 0 do continue + gpu.font_extra[code] = extra_chars[offset + i] + } + offset += r.count + } widest_advance: f32 = 0 for char in gpu.font_chars { glyph_width := char.xoff + f32(char.x1 - char.x0) @@ -518,8 +629,19 @@ gpu_read_font_file :: proc() -> (bytes: []u8, path: string, owned: bool) { return GPU_EMBEDDED_FONT, "embedded NotoSansMono-Regular.ttf", false } +gpu_batch_current :: proc(gpu: ^GPU_Renderer, kind: GPU_Batch_Kind, first: int) -> ^GPU_Batch { + if len(gpu.batches) > 0 { + last := &gpu.batches[len(gpu.batches) - 1] + if last.kind == kind do return last + } + append(&gpu.batches, GPU_Batch{kind = kind, first = first}) + return &gpu.batches[len(gpu.batches) - 1] +} + 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 + batch := gpu_batch_current(gpu, .Rect, len(gpu.vertices)) + batch.count += 6 append(&gpu.vertices, GPU_Vertex{{x0, y0}, c}) append(&gpu.vertices, GPU_Vertex{{x1, y0}, c}) append(&gpu.vertices, GPU_Vertex{{x0, y1}, c}) @@ -530,6 +652,8 @@ gpu_push_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1: f32, c: [4]f32) { 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 + batch := gpu_batch_current(gpu, .Text, len(gpu.text_vertices)) + batch.count += 6 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}) diff --git a/client/odin/main.odin b/client/odin/main.odin index 7893597..caac48f 100644 --- a/client/odin/main.odin +++ b/client/odin/main.odin @@ -7,45 +7,69 @@ 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]") + + if len(args) >= 2 && args[1] == "--terminal-selftest" { + if !terminal_selftest() { + os.exit(1) + } return } - if args[1] == "--sdl" { - workspace := "." - if len(args) >= 3 { - workspace = args[2] + if len(args) >= 3 && args[1] == "--git-status-selftest" { + if !git_status_selftest(args[2]) { + os.exit(1) } - 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] + return + } + + if len(args) >= 2 && args[1] == "--git-selftest" { + workspace := "." if len(args) < 3 else args[2] + if !git_panel_selftest(workspace) { + os.exit(1) + } + return + } + + // Legacy smoke-test mode: a bare numeric first argument is a daemon port. + if len(args) >= 2 && args[1] != "--sdl" { + if port, is_port := strconv.parse_int(args[1]); is_port { + workspace := "." + if len(args) >= 3 { + workspace = args[2] } + run_smoke_client(int(port), workspace) + return } - if len(args) >= 5 { - file = args[4] + } + + // SDL editor mode (default). All arguments are optional and positional in + // any order: a directory is the workspace, a number is a daemon port, and + // anything else is a file to open. `--sdl` is accepted for compatibility. + sdl_args := args[1:] + if len(sdl_args) > 0 && sdl_args[0] == "--sdl" { + sdl_args = sdl_args[1:] + } + + workspace := "" + port := 0 + file := "" + for arg in sdl_args { + if parsed_port, is_port := strconv.parse_int(arg); is_port && port == 0 { + port = int(parsed_port) + } else if os.is_dir(arg) && len(workspace) == 0 { + workspace = arg + } else if len(file) == 0 { + file = arg + } else { + fmt.println("usage: odin run client/odin -- [workspace] [port] [file]") + fmt.println(" or: odin run client/odin -- [workspace] (smoke test)") + return } - 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_sdl_editor(workspace, port, file) +} +run_smoke_client :: proc(port: int, workspace: string) { run_buffer_smoke() editor := run_editor_smoke(workspace) defer editor_destroy(&editor) diff --git a/client/odin/sdl_app.odin b/client/odin/sdl_app.odin index b49d75f..bcc5dac 100644 --- a/client/odin/sdl_app.odin +++ b/client/odin/sdl_app.odin @@ -4,7 +4,9 @@ import "core:c" import "core:fmt" import "core:os" import "base:runtime" +import "core:slice" import "core:strings" +import "core:sync" import "core:time" import json "core:encoding/json" import SDL "vendor:sdl3" @@ -12,6 +14,8 @@ import SDL "vendor:sdl3" SDL_View :: struct { first_line: int, tree_first: int, + mouse_x: f32, + mouse_y: f32, mouse_selecting: bool, tab_dragging: bool, tab_drag_index: int, @@ -22,7 +26,10 @@ SDL_View :: struct { sidebar_width: int, right_sidebar_width: int, explorer_visible: bool, + left_tab: Left_Tab, gradle_sidebar_visible: bool, + terminal_visible: bool, + terminal_height: int, cached_workspace: string, saved_active_file: int, saved_open_files: [dynamic]Saved_Open_File, @@ -33,21 +40,75 @@ Saved_Open_File :: struct { cursor: int, } +Left_Tab :: enum { + Files, + Git, +} + Resize_Panel :: enum { None, Left_Sidebar, Right_Sidebar, + Bottom_Panel, } Project_File :: struct { - path: string, - label: string, - depth: int, - is_dir: bool, + path: string, + name: string, + depth: int, + is_dir: bool, + expanded: bool, } Project_Tree :: struct { - files: [dynamic]Project_File, + workspace: string, + files: [dynamic]Project_File, + expanded: map[string]bool, +} + +// State for the system folder-selection dialog. SDL may invoke the dialog +// callback on a different thread, so the selection is handed to the main +// loop under a mutex. +Folder_Dialog_State :: struct { + window: ^SDL.Window, + mutex: sync.Mutex, + active: bool, + pending: bool, + path: [dynamic]u8, +} + +Context_Menu :: struct { + open: bool, + // Daemon state snapshotted when the menu opens, so rendering and click + // handling grey out the same items. + daemon_ok: bool, + x: f32, + y: f32, +} + +Context_Menu_Action :: enum { + Cut, + Copy, + Paste, + Definition, + References, + Rename, +} + +Context_Menu_Item :: struct { + label: string, + action: Context_Menu_Action, + separator_before: bool, +} + +@(rodata) +context_menu_items := [?]Context_Menu_Item{ + {label = "Cut", action = .Cut}, + {label = "Copy", action = .Copy}, + {label = "Paste", action = .Paste}, + {label = "Go to Definition", action = .Definition, separator_before = true}, + {label = "Find References", action = .References}, + {label = "Rename...", action = .Rename}, } Completion_Popup :: struct { @@ -70,6 +131,11 @@ Hover_Tooltip :: struct { line: int, column: int, contents: string, + // Mouse-dwell hovers anchor to the hovered identifier and dismiss when + // the mouse leaves its column span; keyboard hovers anchor to the cursor. + from_mouse: bool, + word_start: int, + word_end: int, } Definition_Jump :: struct { @@ -128,9 +194,11 @@ Command_Palette :: struct { } Command_Id :: enum { + Open_Folder, Reload_Workspace, Save, Save_All, + Close_Tab, Find, Diagnostics, Gradle_Tasks, @@ -149,9 +217,11 @@ Command_Item :: struct { } COMMAND_ITEMS := [?]Command_Item{ + {.Open_Folder, "Open Folder"}, {.Reload_Workspace, "Reload Workspace"}, {.Save, "Save File"}, {.Save_All, "Save All"}, + {.Close_Tab, "Close Tab"}, {.Find, "Find in File"}, {.Diagnostics, "Toggle Diagnostics"}, {.Gradle_Tasks, "Toggle Gradle Tasks"}, @@ -168,6 +238,7 @@ Gradle_Task_Item :: struct { path: string, name: string, description: string, + group: string, } Gradle_Tasks_Panel :: struct { @@ -175,11 +246,27 @@ Gradle_Tasks_Panel :: struct { pending_id: int, pending_run: bool, selected: int, + scroll: int, items: [dynamic]Gradle_Task_Item, + collapsed: map[string]bool, message: string, output: [dynamic]string, } +// One visible row of the Gradle tree: project -> group -> task. +Gradle_Row_Kind :: enum { + Project, + Group, + Task, +} + +Gradle_Row :: struct { + kind: Gradle_Row_Kind, + label: string, + key: string, // collapse-state key for project and group rows + item_index: int, +} + Syntax_Kind :: enum { Plain, Keyword, @@ -219,7 +306,9 @@ SDL_RIGHT_SIDEBAR_DEFAULT_WIDTH :: 340 SDL_RIGHT_SIDEBAR_MIN_WIDTH :: 220 SDL_RIGHT_SIDEBAR_MAX_WIDTH :: 620 SDL_RESIZE_HANDLE_WIDTH :: 6 +SDL_TOOL_STRIP_WIDTH :: 26 SDL_DAEMON_SYNC_DEBOUNCE_MS :: 250 +SDL_HOVER_DWELL_MS :: 450 SDL_GUTTER_WIDTH :: 72 SDL_EDITOR_TOP :: SDL_TOP_BAR_HEIGHT + SDL_TAB_BAR_HEIGHT SDL_EDITOR_TEXT_Y :: SDL_EDITOR_TOP + 14 @@ -232,6 +321,7 @@ ui_state_load :: proc() -> SDL_View { window_height = SDL_WINDOW_HEIGHT, sidebar_width = SDL_SIDEBAR_DEFAULT_WIDTH, right_sidebar_width = SDL_RIGHT_SIDEBAR_DEFAULT_WIDTH, + terminal_height = SDL_TERMINAL_DEFAULT_HEIGHT, explorer_visible = true, } @@ -264,6 +354,15 @@ ui_state_load :: proc() -> SDL_View { if visible, has_visible := json_get_bool(value, "gradleSidebarVisible"); has_visible { view.gradle_sidebar_visible = visible } + if visible, has_visible := json_get_bool(value, "terminalVisible"); has_visible { + view.terminal_visible = visible + } + if height, has_height := json_get_int(value, "terminalHeight"); has_height { + view.terminal_height = clamp_int(height, SDL_TERMINAL_MIN_HEIGHT, 2000) + } + if tab, has_tab := json_get_int(value, "leftTab"); has_tab { + view.left_tab = Left_Tab(clamp_int(tab, 0, len(Left_Tab) - 1)) + } if workspace, has_workspace := json_get_string(value, "workspace"); has_workspace { view.cached_workspace = strings.clone(workspace) } @@ -298,12 +397,12 @@ ui_state_save :: proc(view: ^SDL_View) { append(&open_files_json, '[') for file, index in view.saved_open_files { if index > 0 do append(&open_files_json, ',') - item := fmt.tprintf("{\"path\":%s,\"cursor\":%d}", json_quote(file.path), file.cursor) + item := fmt.tprintf("{{\"path\":%s,\"cursor\":%d}}", json_quote(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\": %s,\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, json_quote(view.cached_workspace), view.saved_active_file, string(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 \"terminalVisible\": %v,\n \"terminalHeight\": %d,\n \"leftTab\": %d,\n \"workspace\": %s,\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.terminal_visible, view.terminal_height, int(view.left_tab), json_quote(view.cached_workspace), view.saved_active_file, string(open_files_json[:])) _ = os.write_entire_file(path, transmute([]byte)text) } @@ -342,9 +441,38 @@ ui_state_dir :: proc(allocator: runtime.Allocator) -> (string, bool) { return fmt.aprintf("%s/native-kotlin-editor", cache_dir), true } +// The left tool strip owns the leftmost pixels below the top bar; sidebar +// content starts after it. +content_left_edge :: proc() -> int { + return SDL_TOOL_STRIP_WIDTH +} + 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) + width := SDL_TOOL_STRIP_WIDTH + if view.explorer_visible { + width += clamp_int(view.sidebar_width, SDL_SIDEBAR_MIN_WIDTH, SDL_SIDEBAR_MAX_WIDTH) + } + return width +} + +// The tool strip owns the rightmost pixels below the top bar, so panels and +// editor content end at this edge instead of the window edge. +content_right_edge :: proc(window_width: int) -> int { + return window_width - SDL_TOOL_STRIP_WIDTH +} + +// Effective bottom-panel height: user-resizable, kept within the window. +bottom_panel_height :: proc(view: ^SDL_View) -> int { + max_height := max_int(view.window_height - SDL_STATUS_BAR_HEIGHT - SDL_EDITOR_TOP - 120, SDL_TERMINAL_MIN_HEIGHT) + return clamp_int(view.terminal_height, SDL_TERMINAL_MIN_HEIGHT, max_height) +} + +// Bottom edge of the editor area: the bottom panel takes space above the +// status bar when visible. +editor_content_bottom :: proc(view: ^SDL_View) -> int { + bottom := view.window_height - SDL_STATUS_BAR_HEIGHT + if view.terminal_visible do bottom -= bottom_panel_height(view) + return bottom } right_sidebar_width_view :: proc(view: ^SDL_View, window_width: int) -> int { @@ -410,14 +538,29 @@ editor_restore_initial_files :: proc(editor: ^Editor, view: ^SDL_View, workspace } } - path := fmt.tprintf("%s/src/main/kotlin/dev/nativeeditor/daemon/Main.kt", workspace) - return editor_open_file(editor, path) + first_file, found := workspace_find_first_file(workspace, 0) + if !found do return false + defer delete(first_file) + return editor_open_file(editor, first_file) } -run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { +run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: string) { view := ui_state_load() defer ui_state_destroy(&view) + // No workspace argument means reopen the last workspace, falling back to + // the current directory on first launch or if it no longer exists. + resolved_workspace := initial_workspace + if len(resolved_workspace) == 0 { + if len(view.cached_workspace) > 0 && os.is_dir(view.cached_workspace) { + resolved_workspace = view.cached_workspace + } else { + resolved_workspace = "." + } + } + workspace := strings.clone(resolved_workspace) + defer delete(workspace) + editor := Editor{} defer editor_destroy(&editor) @@ -464,9 +607,13 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { default_cursor := SDL.GetDefaultCursor() text_cursor := SDL.CreateSystemCursor(.TEXT) resize_cursor := SDL.CreateSystemCursor(.EW_RESIZE) + ns_resize_cursor := SDL.CreateSystemCursor(.NS_RESIZE) + pointer_cursor := SDL.CreateSystemCursor(.POINTER) defer { if text_cursor != nil do SDL.DestroyCursor(text_cursor) if resize_cursor != nil do SDL.DestroyCursor(resize_cursor) + if ns_resize_cursor != nil do SDL.DestroyCursor(ns_resize_cursor) + if pointer_cursor != nil do SDL.DestroyCursor(pointer_cursor) } refresh_view_size(window, &view) @@ -491,6 +638,19 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { diagnostics_panel := Diagnostics_Panel{} command_palette := Command_Palette{} defer command_palette_destroy(&command_palette) + context_menu := Context_Menu{} + terminal := Terminal_Panel{} + defer terminal_destroy(&terminal) + git_panel := Git_Panel{} + defer git_panel_destroy(&git_panel) + git_status := Git_Status_Panel{} + defer git_status_destroy(&git_status) + terminal.open = view.terminal_visible + if terminal.open { + _ = terminal_start(&terminal, workspace) + } + dialog := Folder_Dialog_State{window = window} + defer folder_dialog_destroy(&dialog) tasks_panel := Gradle_Tasks_Panel{} tasks_panel.open = view.gradle_sidebar_visible if tasks_panel.open { @@ -517,6 +677,9 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { _ = daemon_begin_owned_start(workspace, &owned_daemon, &daemon_port_line, &editor, &daemon_start_pending) } + mouse_moved_at := time.now() + mouse_dwell_handled := false + running := true for running { event: SDL.Event @@ -525,12 +688,34 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { 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) { + if context_menu.open { + context_menu.open = false + if event.key.key == SDL.K_ESCAPE do continue + } + if event.key.key == SDL.K_GRAVE && (event.key.mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + terminal_toggle(&terminal, &git_panel, &view, workspace) + continue + } + if handle_git_status_key(&git_status, &view, workspace, event.key.key, event.key.mod) { + continue + } + if terminal_handle_key(&terminal, event.key.key, event.key.mod) { + continue + } + if handle_sdl_key(&editor, &view, &tree, workspace, &dialog,&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 git_status.input_focused && git_status_panel_active(&view) { + git_status_input_text(&git_status, text) + continue + } + if terminal.open && terminal.focused { + terminal_send(&terminal, text) + continue + } if rename.open { rename_panel_insert(&rename, text) continue @@ -544,7 +729,7 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { continue } active := editor_active_buffer(&editor) - if active != nil { + if active != nil && active.diff == nil { editor_insert_text(active, text) close_stale_edit_overlays(&completion, &hover, &references, &rename) ensure_cursor_visible(&editor, &view) @@ -552,21 +737,50 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { } } case .MOUSE_WHEEL: + view.mouse_x = event.wheel.mouse_x + view.mouse_y = event.wheel.mouse_y + mouse_moved_at = time.now() + mouse_dwell_handled = false + if hover.open && hover.from_mouse { + hover.open = false + } 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_terminal_wheel(&terminal, &git_panel, &view, &tasks_panel, event.wheel.mouse_x, event.wheel.mouse_y, int(event.wheel.integer_y)) { + // Scrolled the terminal history. } 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 handle_git_status_wheel(&git_status, &view, event.wheel.mouse_x, event.wheel.mouse_y, int(event.wheel.integer_y)) { + // Scrolled the git status list. } 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_RIGHT { + if gpu.available { + _ = handle_editor_right_click(&context_menu, &editor, &view, &gpu, &tasks_panel, &daemon, event.button.x, event.button.y) + } + continue + } if event.button.button != SDL.BUTTON_LEFT do continue - if handle_panel_resize_down(&view, &tasks_panel, event.button.x, event.button.y) { + if handle_context_menu_click(&context_menu, &editor, &view, &completion, &hover, &definition, &references, &rename, &daemon, &daemon_sync_state, 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) { + } else if handle_panel_resize_down(&view, &tasks_panel, event.button.x, event.button.y) { + view.mouse_selecting = false + set_editor_cursor(&view, &editor, &tree, &tasks_panel, &command_palette, &context_menu, &references, &diagnostics_panel, event.button.x, event.button.y, default_cursor, text_cursor, resize_cursor, ns_resize_cursor, pointer_cursor) + } else if gpu.available && handle_toolbar_click(&dialog, workspace, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if gpu.available && handle_bottom_panel_click(&terminal, &git_panel, &view, &tasks_panel, workspace, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if gpu.available && handle_tool_strip_click(&view, &tasks_panel, &terminal, &git_panel, workspace, &editor, &daemon, &daemon_sync_state, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if gpu.available && handle_left_strip_click(&view, &git_status, workspace, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if gpu.available && handle_git_status_click(&git_status, &view, workspace, event.button.x, event.button.y) { + view.mouse_selecting = false + } else if handle_command_palette_click(&command_palette, &editor, &view, &tree, workspace, &dialog,&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 @@ -574,7 +788,7 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { 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) { + } else if handle_gradle_tasks_click(&view, &tasks_panel, &daemon, event.button.x, event.button.y, int(event.button.clicks)) { view.mouse_selecting = false } else if handle_overlay_outside_click(&command_palette, &completion, &references, &diagnostics_panel) { view.mouse_selecting = false @@ -583,6 +797,8 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { } 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 { + terminal.focused = false + git_status.input_focused = false view.mouse_selecting = handle_editor_text_mouse(&editor, &view, &gpu, event.button.x, event.button.y, false) } case .MOUSE_BUTTON_UP: @@ -596,19 +812,48 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { view.mouse_selecting = false } case .MOUSE_MOTION: - if view.resizing_panel != .None && (event.motion.state & SDL.BUTTON_LMASK) != {} { + view.mouse_x = event.motion.x + view.mouse_y = event.motion.y + mouse_moved_at = time.now() + mouse_dwell_handled = false + if hover.open && hover.from_mouse { + line, column, over_text := editor_line_col_from_mouse(&editor, &view, &gpu, event.motion.x, event.motion.y) + if !over_text || line != hover.line || column < hover.word_start || column >= hover.word_end { + hover.open = false + } + } + if view.resizing_panel == .Bottom_Panel && (event.motion.state & SDL.BUTTON_LMASK) != {} { + handle_bottom_panel_resize_motion(&view, event.motion.y) + } else 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) + set_editor_cursor(&view, &editor, &tree, &tasks_panel, &command_palette, &context_menu, &references, &diagnostics_panel, event.motion.x, event.motion.y, default_cursor, text_cursor, resize_cursor, ns_resize_cursor, pointer_cursor) } } refresh_view_size(window, &view) + if gpu.available && daemon.connected && !mouse_dwell_handled && !hover.open && + !completion.open && !context_menu.open && !command_palette.open && + !references.open && !rename.open && !search.open && + time.diff(mouse_moved_at, time.now()) >= SDL_HOVER_DWELL_MS * time.Millisecond && + editor_text_hit(&view, &tasks_panel, view.mouse_x, view.mouse_y) { + mouse_dwell_handled = true + if daemon_sync_state.pending { + daemon_sync_now(&daemon_sync_state, &daemon, &editor, false) + } + _ = hover_tooltip_open_at_mouse(&hover, &editor, &view, &gpu, &daemon, view.mouse_x, view.mouse_y) + } + + if selected, has_selected := folder_dialog_take(&dialog); has_selected { + attempt_open_folder(&editor, &view, &tree, &navigation, &workspace, selected, &daemon, &daemon_sync_state, &owned_daemon, &daemon_port_line, &daemon_start_pending, owned_daemon_enabled) + delete(selected) + } + if owned_daemon_enabled && !daemon.connected && !daemon_start_pending && !daemon_connect_pending { if daemon_initialized { daemon_initialized = false @@ -675,10 +920,30 @@ run_sdl_editor :: proc(workspace: string, daemon_port: int, file_path: string) { 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) + gradle_tasks_panel_apply_event(&tasks_panel, &daemon) + gradle_tasks_panel_apply_response(&tasks_panel, &daemon) + terminal_pump(&terminal) + git_panel_pump(&git_panel) + git_status_pump(&git_status) + if git_status_panel_active(&view) && git_status.loaded_root != workspace { + git_status_request(&git_status, workspace) + } + if git_status.committed { + // A new commit exists: force the history panel to reload when + // (or while) it is visible. + git_status.committed = false + delete(git_panel.loaded_root) + git_panel.loaded_root = strings.clone("") + } + if git_panel.diff_ready { + git_panel.diff_ready = false + editor_open_diff(&editor, git_panel_diff_tab_name(&git_panel), git_panel.diff_text) + } + if terminal.open && terminal.active_tab == .Git { + git_panel_activate(&git_panel, workspace) + } if gpu.available { - render_sdl_editor_gpu(&gpu, &editor, &view, &tree, &completion, &hover, &references, &rename, &search, &diagnostics_panel, &command_palette, &tasks_panel) + render_sdl_editor_gpu(&gpu, &editor, &view, &tree, &completion, &hover, &references, &rename, &search, &diagnostics_panel, &command_palette, &context_menu, &tasks_panel, &terminal, &git_panel, &git_status) } else { render_sdl_editor(renderer, &editor, &view, &tree, &completion, &hover, &references, &rename) } @@ -735,12 +1000,53 @@ hover_tooltip_open :: proc(hover: ^Hover_Tooltip, editor: ^Editor, daemon: ^Daem line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset) hover.open = true + hover.from_mouse = false hover.line = line hover.column = column hover.pending_id = daemon_send_hover(daemon, active.path, line, column) hover_tooltip_set_contents(hover, "Loading hover...") } +buffer_word_span_at :: proc(buffer: ^Buffer, line, column: int) -> (start_col, end_col: int, ok: bool) { + bytes := buffer_line_bytes(buffer, line) + defer delete(bytes) + if column < 0 || column >= len(bytes) do return 0, 0, false + if !is_identifier_byte(bytes[column]) do return 0, 0, false + start := column + for start > 0 && is_identifier_byte(bytes[start - 1]) { + start -= 1 + } + end := column + for end < len(bytes) && is_identifier_byte(bytes[end]) { + end += 1 + } + return start, end, true +} + +// Opens a hover request for the identifier under the mouse; returns false +// when the mouse is not over an identifier. +hover_tooltip_open_at_mouse :: proc(hover: ^Hover_Tooltip, editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, daemon: ^Daemon_Client, x, y: f32) -> bool { + active := editor_active_buffer(editor) + if active == nil || active.scratch do return false + + line, column, ok := editor_line_col_from_mouse(editor, view, gpu, x, y) + if !ok do return false + word_start, word_end, has_word := buffer_word_span_at(&active.buffer, line, column) + if !has_word do return false + + hover.open = true + hover.from_mouse = true + hover.line = line + hover.column = column + hover.word_start = word_start + hover.word_end = word_end + hover.pending_id = daemon_send_hover(daemon, active.path, line, column) + // No "loading" placeholder for dwell hovers: the tooltip stays invisible + // until real contents arrive, so hovering plain code shows nothing. + hover_tooltip_set_contents(hover, "") + return true +} + definition_jump_request :: proc(definition: ^Definition_Jump, editor: ^Editor, daemon: ^Daemon_Client) { active := editor_active_buffer(editor) if active == nil do return @@ -1069,12 +1375,18 @@ ascii_lower :: proc(b: u8) -> u8 { 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) { +command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, dialog: ^Folder_Dialog_State,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 .Open_Folder: + folder_dialog_show(dialog, workspace) + case .Close_Tab: + if editor_close_active(editor) { + scroll_sdl_view(editor, view, 0) + } case .Reload_Workspace: workspace_reload(editor, view, tree, workspace, daemon, sync) case .Save: @@ -1089,15 +1401,7 @@ command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view: 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) + gradle_panel_toggle(view, tasks_panel, editor, daemon, sync) case .Explorer: view.explorer_visible = !view.explorer_visible view.tree_first = 0 @@ -1121,9 +1425,22 @@ command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view: } } +gradle_panel_toggle :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, editor: ^Editor, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) { + 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) +} + gradle_tasks_panel_destroy :: proc(panel: ^Gradle_Tasks_Panel) { gradle_tasks_panel_clear(panel) delete(panel.items) + delete(panel.collapsed) delete(panel.message) gradle_tasks_panel_clear_output(panel) delete(panel.output) @@ -1134,8 +1451,14 @@ gradle_tasks_panel_clear :: proc(panel: ^Gradle_Tasks_Panel) { delete(item.path) delete(item.name) delete(item.description) + delete(item.group) } clear(&panel.items) + for key in panel.collapsed { + delete(key) + } + clear(&panel.collapsed) + panel.scroll = 0 } gradle_tasks_panel_clear_output :: proc(panel: ^Gradle_Tasks_Panel) { @@ -1245,9 +1568,8 @@ parse_gradle_run_event :: proc(event: string) -> (string, string, string, string 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) +gradle_tasks_panel_run :: proc(panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, index: int) { + if index < 0 || index >= len(panel.items) || panel.pending_id != 0 do return task := panel.items[index].path if len(task) == 0 do task = panel.items[index].name if len(task) == 0 do return @@ -1300,7 +1622,8 @@ parse_gradle_tasks_response :: proc(response: string, expected_id: int) -> ([dyn 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)}) + group, _ := json_get_string(task, "group") + append(&items, Gradle_Task_Item{path = strings.clone(path), name = strings.clone(name), description = strings.clone(description), group = strings.clone(group)}) } } return items, true @@ -1413,6 +1736,149 @@ find_bytes_reverse_from :: proc(text: []u8, query: []u8, start: int) -> (int, bo return 0, false } +folder_dialog_destroy :: proc(dialog: ^Folder_Dialog_State) { + delete(dialog.path) +} + +folder_dialog_show :: proc(dialog: ^Folder_Dialog_State, workspace: string) { + if sync.mutex_guard(&dialog.mutex) { + if dialog.active do return + dialog.active = true + } + location: cstring + if absolute, err := os.get_absolute_path(workspace, context.temp_allocator); err == nil { + location = strings.clone_to_cstring(absolute, context.temp_allocator) + } + SDL.ShowOpenFolderDialog(folder_dialog_callback, rawptr(dialog), dialog.window, location, false) +} + +folder_dialog_callback :: proc "c" (userdata: rawptr, filelist: [^]cstring, filter: c.int) { + context = runtime.default_context() + dialog := (^Folder_Dialog_State)(userdata) + if sync.mutex_guard(&dialog.mutex) { + dialog.active = false + dialog.pending = false + clear(&dialog.path) + // A nil list is a dialog error; an empty list means the user canceled. + if filelist != nil && filelist[0] != nil { + for b in transmute([]u8)string(filelist[0]) { + append(&dialog.path, b) + } + dialog.pending = true + } + } +} + +folder_dialog_take :: proc(dialog: ^Folder_Dialog_State) -> (string, bool) { + if sync.mutex_guard(&dialog.mutex) { + if dialog.pending { + dialog.pending = false + return strings.clone(string(dialog.path[:])), true + } + } + return "", false +} + +workspace_find_first_file :: proc(dir: string, depth: int) -> (string, bool) { + if depth > 6 do return "", false + + entries, err := os.read_all_directory_by_path(dir, context.allocator) + if err != nil do return "", false + defer os.file_info_slice_delete(entries, context.allocator) + + slice.sort_by(entries, proc(a, b: os.File_Info) -> bool { + a_dir := a.type == .Directory + b_dir := b.type == .Directory + if a_dir != b_dir do return !a_dir + return a.name < b.name + }) + + for entry in entries { + if project_tree_skip(entry.name) do continue + if entry.type != .Directory { + return strings.clone(fmt.tprintf("%s/%s", dir, entry.name)), true + } + } + for entry in entries { + if project_tree_skip(entry.name) do continue + if entry.type == .Directory { + path, ok := workspace_find_first_file(fmt.tprintf("%s/%s", dir, entry.name), depth + 1) + if ok do return path, true + } + } + return "", false +} + +attempt_open_folder :: proc( + editor: ^Editor, + view: ^SDL_View, + tree: ^Project_Tree, + navigation: ^Navigation_History, + workspace: ^string, + selected: string, + daemon: ^Daemon_Client, + sync: ^Daemon_Sync_State, + owned_daemon: ^Daemon_Process, + port_line: ^[dynamic]u8, + start_pending: ^bool, + owned_enabled: bool, +) { + resolved := strings.trim_space(selected) + if len(resolved) == 0 || !os.is_dir(resolved) { + editor_set_status(editor, "Not a directory") + return + } + for &buffer in editor.buffers { + if buffer.dirty { + editor_set_status(editor, "Save all buffers before switching folders") + return + } + } + first_file, found := workspace_find_first_file(resolved, 0) + if !found { + editor_set_status(editor, "Folder has no openable files") + return + } + defer delete(first_file) + + // Persist the old workspace's session before tearing it down. + ui_state_capture_editor(view, editor, workspace^) + ui_state_save(view) + + // Open the new buffer first so the editor never ends up with zero buffers. + old_count := len(editor.buffers) + if !editor_open_file(editor, first_file) { + editor_set_status(editor, "Failed to open a file in that folder") + return + } + for _ in 0 ..< old_count { + editor_buffer_destroy(&editor.buffers[0]) + ordered_remove(&editor.buffers, 0) + } + editor.active = 0 + view.first_line = 0 + + navigation_clear_stack(&navigation.back) + navigation_clear_stack(&navigation.forward) + + delete(workspace^) + workspace^ = strings.clone(resolved) + project_tree_destroy(tree) + tree^ = project_tree_load(workspace^) + view.tree_first = 0 + + // The daemon runs independently of the workspace, so an open connection + // just needs the new root; only start a daemon if none is running. + if daemon.connected { + daemon_send_workspace_open(daemon, workspace^) + daemon_sync_now(sync, daemon, editor, true) + } else if owned_enabled { + _ = daemon_begin_owned_start(workspace^, owned_daemon, port_line, editor, start_pending) + } + + editor_set_status(editor, fmt.tprintf("Opened folder: %s", workspace^)) +} + rename_panel_open :: proc(panel: ^Rename_Panel) { panel.open = true panel.pending_id = 0 @@ -1527,7 +1993,11 @@ hover_tooltip_apply_response :: proc(hover: ^Hover_Tooltip, daemon: ^Daemon_Clie hover.pending_id = 0 if len(contents) == 0 { - hover_tooltip_set_contents(hover, "No hover information") + if hover.from_mouse { + hover.open = false + } else { + hover_tooltip_set_contents(hover, "No hover information") + } } else { hover_tooltip_set_contents(hover, contents) } @@ -1675,53 +2145,91 @@ parse_completion_response :: proc(response: string, expected_id: int) -> ([dynam } project_tree_load :: proc(workspace: string) -> Project_Tree { - tree := Project_Tree{} - project_tree_append_dir(&tree, workspace, "", 0) + tree := Project_Tree{workspace = strings.clone(workspace)} + project_tree_rebuild(&tree) return tree } -project_tree_destroy :: proc(tree: ^Project_Tree) { +project_tree_clear_files :: proc(tree: ^Project_Tree) { for file in tree.files { delete(file.path) - delete(file.label) + delete(file.name) } + clear(&tree.files) +} + +project_tree_rebuild :: proc(tree: ^Project_Tree) { + project_tree_clear_files(tree) + project_tree_append_dir(tree, tree.workspace, 0) +} + +project_tree_destroy :: proc(tree: ^Project_Tree) { + project_tree_clear_files(tree) delete(tree.files) tree.files = nil + for key, _ in tree.expanded { + delete(key) + } + delete(tree.expanded) + tree.expanded = nil + delete(tree.workspace) + tree.workspace = "" +} + +project_tree_toggle_dir :: proc(tree: ^Project_Tree, index: int) { + if index < 0 || index >= len(tree.files) do return + item := tree.files[index] + if !item.is_dir do return + + if _, found := tree.expanded[item.path]; found { + tree.expanded[item.path] = !item.expanded + } else { + tree.expanded[strings.clone(item.path)] = true + } + project_tree_rebuild(tree) } 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 + project_tree_rebuild(tree) + scroll_project_tree(tree, view, 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 +project_tree_append_dir :: proc(tree: ^Project_Tree, dir: string, depth: int) { + if depth > 12 || len(tree.files) >= 5000 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) + slice.sort_by(entries, proc(a, b: os.File_Info) -> bool { + a_dir := a.type == .Directory + b_dir := b.type == .Directory + if a_dir != b_dir do return a_dir + return a.name < b.name + }) + for entry in entries { - if len(tree.files) >= 300 do return + if len(tree.files) >= 5000 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) + is_dir := entry.type == .Directory + expanded := is_dir && tree.expanded[path] append(&tree.files, Project_File{ path = strings.clone(path), - label = strings.clone(label), + name = strings.clone(entry.name), depth = depth, - is_dir = entry.type == .Directory, + is_dir = is_dir, + expanded = expanded, }) - if entry.type == .Directory { - child_prefix := fmt.tprintf("%s ", prefix) - project_tree_append_dir(tree, path, child_prefix, depth + 1) + if expanded { + child_path := tree.files[len(tree.files) - 1].path + project_tree_append_dir(tree, child_path, depth + 1) } } } @@ -1730,11 +2238,19 @@ 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) { +set_editor_cursor :: proc(view: ^SDL_View, editor: ^Editor, tree: ^Project_Tree, tasks_panel: ^Gradle_Tasks_Panel, command_palette: ^Command_Palette, context_menu: ^Context_Menu, references: ^References_Panel, diagnostics_panel: ^Diagnostics_Panel, x, y: f32, default_cursor, text_cursor, resize_cursor, ns_resize_cursor, pointer_cursor: ^SDL.Cursor) { + if view.resizing_panel == .Bottom_Panel || (view.resizing_panel == .None && bottom_panel_resize_hit(view, tasks_panel, x, y)) { + if ns_resize_cursor != nil do _ = SDL.SetCursor(ns_resize_cursor) + return + } if view.resizing_panel != .None || panel_resize_hit(view, tasks_panel, x, y) { if resize_cursor != nil do _ = SDL.SetCursor(resize_cursor) return } + if ui_pointer_hit(view, editor, tree, tasks_panel, command_palette, context_menu, references, diagnostics_panel, x, y) { + if pointer_cursor != nil do _ = SDL.SetCursor(pointer_cursor) + return + } if editor_text_hit(view, tasks_panel, x, y) { if text_cursor != nil do _ = SDL.SetCursor(text_cursor) return @@ -1742,6 +2258,66 @@ set_editor_cursor :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, if default_cursor != nil do _ = SDL.SetCursor(default_cursor) } +// True when the mouse is over something that reacts to a click: tree rows, +// editor tabs, overlay list rows, or Gradle task rows. +ui_pointer_hit :: proc(view: ^SDL_View, editor: ^Editor, tree: ^Project_Tree, tasks_panel: ^Gradle_Tasks_Panel, command_palette: ^Command_Palette, context_menu: ^Context_Menu, references: ^References_Panel, diagnostics_panel: ^Diagnostics_Panel, x, y: f32) -> bool { + if toolbar_open_folder_hit(x, y) { + return true + } + if context_menu.open { + if index, on_item := context_menu_item_at(context_menu, x, y); on_item { + return context_menu_item_enabled(context_menu, editor, context_menu_items[index].action) + } + } + if tool_strip_gradle_hit(view.window_width, x, y) || tool_strip_terminal_hit(view.window_width, x, y) { + return true + } + if left_strip_hit(.Files, x, y) || left_strip_hit(.Git, x, y) { + return true + } + 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 + 60 && y < palette_y + height { + return true + } + } + if references.open { + visible := min_int(max_int(len(references.items), 1), 14) + height := f32(30 + visible * 16) + if x >= 700 && x < 700 + 380 && y >= 100 && y < 72 + height { + return true + } + } + if diagnostics_panel.open { + active := editor_active_buffer(editor) + if active != nil && len(active.diagnostics) > 0 { + height: f32 = 168 + sidebar_width := left_sidebar_width(view) + panel_y := f32(editor_content_bottom(view)) - height + if x >= f32(sidebar_width) && y >= panel_y + 34 && y < panel_y + height { + return true + } + } + } + if _, _, _, tab_ok := editor_tab_hit(editor, view, x, y); tab_ok { + return true + } + if _, tree_ok := project_tree_row_at(tree, view, x, y); tree_ok { + return true + } + if tasks_panel.open && len(tasks_panel.items) > 0 { + rows := gradle_panel_rows(tasks_panel) + if _, on_row := gradle_panel_row_at(tasks_panel, view, rows, x, y); on_row { + return true + } + } + return false +} + 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)) @@ -1751,7 +2327,7 @@ panel_resize_hit :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y } if tasks_panel != nil && tasks_panel.open { - right_edge := f32(view.window_width - right_sidebar_width_view(view, view.window_width)) + right_edge := f32(content_right_edge(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 } @@ -1759,7 +2335,22 @@ panel_resize_hit :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y return false } +// The bottom panel resizes vertically by dragging its top edge. +bottom_panel_resize_hit :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if !view.terminal_visible do return false + right := f32(content_right_edge(view.window_width)) + if tasks_panel != nil && tasks_panel.open { + right -= f32(right_sidebar_width_view(view, view.window_width)) + } + top := f32(view.window_height - SDL_STATUS_BAR_HEIGHT - bottom_panel_height(view)) + return x >= f32(left_sidebar_width(view)) && x < right && abs_f32(y - top) <= SDL_RESIZE_HANDLE_WIDTH +} + handle_panel_resize_down :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if bottom_panel_resize_hit(view, tasks_panel, x, y) { + view.resizing_panel = .Bottom_Panel + return true + } 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 { @@ -1769,7 +2360,7 @@ handle_panel_resize_down :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Pan } if tasks_panel != nil && tasks_panel.open { - right_edge := f32(view.window_width - right_sidebar_width_view(view, view.window_width)) + right_edge := f32(content_right_edge(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 @@ -1779,12 +2370,12 @@ handle_panel_resize_down :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Pan } 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 y < SDL_EDITOR_TEXT_Y || y >= f32(editor_content_bottom(view)) do return false if x < f32(editor_text_x(view)) do return false - right_limit := f32(view.window_width - 8) + right_limit := f32(content_right_edge(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) + right_limit = f32(content_right_edge(view.window_width) - right_sidebar_width_view(view, view.window_width) - 8) } return x < right_limit } @@ -1793,22 +2384,40 @@ 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) + view.sidebar_width = clamp_int(int(x) - content_left_edge(), 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) + view.right_sidebar_width = clamp_int(content_right_edge(view.window_width) - int(x), SDL_RIGHT_SIDEBAR_MIN_WIDTH, max_width) } } +handle_bottom_panel_resize_motion :: proc(view: ^SDL_View, y: f32) { + max_height := max_int(view.window_height - SDL_STATUS_BAR_HEIGHT - SDL_EDITOR_TOP - 120, SDL_TERMINAL_MIN_HEIGHT) + view.terminal_height = clamp_int(view.window_height - SDL_STATUS_BAR_HEIGHT - int(y), SDL_TERMINAL_MIN_HEIGHT, max_height) +} + +project_tree_row_at :: proc(tree: ^Project_Tree, view: ^SDL_View, x, y: f32) -> (int, bool) { + if !view.explorer_visible || view.left_tab != .Files do return 0, false + if x < f32(content_left_edge()) || x >= f32(left_sidebar_width(view)) do return 0, false + if y < SDL_TREE_FIRST_Y do return 0, false + index := view.tree_first + int((y - SDL_TREE_FIRST_Y) / SDL_TREE_ROW_HEIGHT) + if index < 0 || index >= len(tree.files) do return 0, false + return index, true +} + 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 + index, ok := project_tree_row_at(tree, view, x, y) + if !ok do return false item := tree.files[index] - if item.is_dir do return false + if item.is_dir { + project_tree_toggle_dir(tree, index) + scroll_project_tree(tree, view, 0) + return false + } if !editor_open_or_focus_file(editor, item.path) do return false view.first_line = 0 return true @@ -1838,7 +2447,7 @@ handle_editor_tab_drag :: proc(editor: ^Editor, view: ^SDL_View, x, y: f32) { 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 { +handle_command_palette_click :: proc(palette: ^Command_Palette, editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, dialog: ^Folder_Dialog_State,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)) @@ -1859,7 +2468,7 @@ handle_command_palette_click :: proc(palette: ^Command_Palette, editor: ^Editor, 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) + command_palette_accept(palette, editor, view, tree, workspace, dialog, completion, hover, definition, references, rename, search, diagnostics_panel, tasks_panel, daemon, sync) } return true } @@ -1928,7 +2537,7 @@ handle_diagnostics_click :: proc(panel: ^Diagnostics_Panel, editor: ^Editor, vie 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 + panel_y := f32(editor_content_bottom(view)) - 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 @@ -1949,45 +2558,147 @@ handle_diagnostics_click :: proc(panel: ^Diagnostics_Panel, editor: ^Editor, vie return true } -handle_gradle_tasks_click :: proc(view: ^SDL_View, panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { +// Task-list layout shared by rendering and click handling: rows are 18px, +// or 30px when the task has a description line, and the list area sits +// between the panel header (92px) and the output section. +gradle_panel_output_height :: proc(panel: ^Gradle_Tasks_Panel, panel_height: f32) -> f32 { + if len(panel.output) == 0 do return 0 + return min_f32(160, max_f32(72, panel_height * 0.32)) +} + +// The task list is an IntelliJ-style tree: one collapsible node per Gradle +// project, with that project's tasks as children. +SDL_GRADLE_ROW_HEIGHT :: 17 +SDL_GRADLE_LIST_TOP :: 52 + +gradle_task_project :: proc(path: string) -> string { + last := strings.last_index_byte(path, ':') + if last <= 0 do return ":" + return path[:last] +} + +gradle_task_group :: proc(item: Gradle_Task_Item) -> string { + if len(item.group) == 0 do return "other" + return item.group +} + +// Looks up a node's collapse state, seeding the IntelliJ-like default on +// first sight: projects start expanded, groups start collapsed. +gradle_panel_node_collapsed :: proc(panel: ^Gradle_Tasks_Panel, key: string, default_collapsed: bool) -> bool { + if key not_in panel.collapsed { + panel.collapsed[strings.clone(key)] = default_collapsed + } + return panel.collapsed[key] +} + +append_unique_string :: proc(list: ^[dynamic]string, value: string) { + for existing in list { + if existing == value do return + } + append(list, value) +} + +// Flattens the visible tree rows. Slices point into panel.items strings and +// the result is temp-allocated, so use it within the same frame only. +gradle_panel_rows :: proc(panel: ^Gradle_Tasks_Panel) -> []Gradle_Row { + rows := make([dynamic]Gradle_Row, context.temp_allocator) + projects := make([dynamic]string, context.temp_allocator) + for item in panel.items { + append_unique_string(&projects, gradle_task_project(item.path)) + } + slice.sort(projects[:]) + + for project in projects { + append(&rows, Gradle_Row{kind = .Project, label = project, key = project}) + if gradle_panel_node_collapsed(panel, project, false) do continue + + groups := make([dynamic]string, context.temp_allocator) + for item in panel.items { + if gradle_task_project(item.path) != project do continue + append_unique_string(&groups, gradle_task_group(item)) + } + slice.sort(groups[:]) + + for group in groups { + group_key := fmt.tprintf("%s|%s", project, group) + append(&rows, Gradle_Row{kind = .Group, label = group, key = group_key}) + if gradle_panel_node_collapsed(panel, group_key, true) do continue + for item, index in panel.items { + if gradle_task_project(item.path) != project || gradle_task_group(item) != group do continue + label := item.name + if len(label) == 0 do label = item.path + append(&rows, Gradle_Row{kind = .Task, label = label, item_index = index}) + } + } + } + return rows[:] +} + +gradle_panel_toggle_node :: proc(panel: ^Gradle_Tasks_Panel, key: string) { + // Rows are built before any click lands on them, so the key is present. + if key in panel.collapsed { + panel.collapsed[key] = !panel.collapsed[key] + } +} + +gradle_panel_geometry :: proc(view: ^SDL_View, window_width, window_height: int) -> (x, y, width, height: f32) { + width = f32(right_sidebar_width_view(view, window_width)) + height = f32(window_height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + x = f32(content_right_edge(window_width)) - width + y = SDL_TOP_BAR_HEIGHT + return +} + +gradle_panel_visible_rows :: proc(panel: ^Gradle_Tasks_Panel, panel_height: f32) -> int { + list_height := panel_height - SDL_GRADLE_LIST_TOP - gradle_panel_output_height(panel, panel_height) - 6 + return max_int(int(list_height / SDL_GRADLE_ROW_HEIGHT), 1) +} + +// Maps a point to a row of the flattened tree; rows must come from +// gradle_panel_rows this frame. +gradle_panel_row_at :: proc(panel: ^Gradle_Tasks_Panel, view: ^SDL_View, rows: []Gradle_Row, x, y: f32) -> (int, bool) { + panel_x, panel_y, width, height := gradle_panel_geometry(view, view.window_width, view.window_height) + if x < panel_x || x >= panel_x + width do return 0, false + list_top := panel_y + SDL_GRADLE_LIST_TOP + if y < list_top do return 0, false + visible := gradle_panel_visible_rows(panel, height) + row := int((y - list_top) / SDL_GRADLE_ROW_HEIGHT) + if row >= visible do return 0, false + index := panel.scroll + row + if index < 0 || index >= len(rows) do return 0, false + return index, true +} + +handle_gradle_tasks_click :: proc(view: ^SDL_View, panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, x, y: f32, clicks: int) -> 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 + panel_x, panel_y, width, height := gradle_panel_geometry(view, view.window_width, view.window_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 + rows := gradle_panel_rows(panel) + index, on_row := gradle_panel_row_at(panel, view, rows, x, y) + if !on_row 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 + row := rows[index] + if row.kind == .Task { + panel.selected = row.item_index + if clicks >= 2 { + gradle_tasks_panel_run(panel, daemon, row.item_index) + } + } else { + gradle_panel_toggle_node(panel, row.key) } 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_x, panel_y, width, height := gradle_panel_geometry(view, view.window_width, view.window_height) + if x < panel_x || x >= panel_x + width || y < panel_y || y >= panel_y + height do return false - panel.selected = clamp_int(panel.selected - wheel_y * 3, 0, len(panel.items) - 1) + rows := gradle_panel_rows(panel) + max_scroll := max_int(len(rows) - gradle_panel_visible_rows(panel, height), 0) + panel.scroll = clamp_int(panel.scroll - wheel_y * 3, 0, max_scroll) return true } @@ -2058,7 +2769,7 @@ handle_overlay_wheel :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Rendere 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 + panel_y := f32(editor_content_bottom(view)) - 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)) @@ -2106,7 +2817,7 @@ tab_width_for_label :: proc(label: string) -> int { 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 + if active == nil || active.diff != nil do return false text_x := f32(editor_text_x(view)) text_y: f32 = SDL_EDITOR_TEXT_Y @@ -2129,12 +2840,174 @@ handle_editor_text_mouse :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Ren return true } +SDL_CONTEXT_MENU_WIDTH :: 190 +SDL_CONTEXT_MENU_ROW_HEIGHT :: 22 +SDL_CONTEXT_MENU_PADDING :: 6 +SDL_CONTEXT_MENU_SEPARATOR :: 7 + +context_menu_height :: proc() -> f32 { + height := f32(2 * SDL_CONTEXT_MENU_PADDING) + for item in context_menu_items { + if item.separator_before do height += SDL_CONTEXT_MENU_SEPARATOR + height += SDL_CONTEXT_MENU_ROW_HEIGHT + } + return height +} + +context_menu_open_at :: proc(menu: ^Context_Menu, view: ^SDL_View, daemon: ^Daemon_Client, x, y: f32) { + menu.x = max_f32(min_f32(x, f32(view.window_width) - SDL_CONTEXT_MENU_WIDTH - 4), 0) + menu.y = max_f32(min_f32(y, f32(editor_content_bottom(view)) - context_menu_height() - 4), SDL_TOP_BAR_HEIGHT) + menu.daemon_ok = daemon.connected + menu.open = true +} + +context_menu_hit :: proc(menu: ^Context_Menu, x, y: f32) -> bool { + return x >= menu.x && x < menu.x + SDL_CONTEXT_MENU_WIDTH && y >= menu.y && y < menu.y + context_menu_height() +} + +context_menu_item_at :: proc(menu: ^Context_Menu, x, y: f32) -> (int, bool) { + if x < menu.x || x >= menu.x + SDL_CONTEXT_MENU_WIDTH do return 0, false + row_y := menu.y + SDL_CONTEXT_MENU_PADDING + for item, index in context_menu_items { + if item.separator_before do row_y += SDL_CONTEXT_MENU_SEPARATOR + if y >= row_y && y < row_y + SDL_CONTEXT_MENU_ROW_HEIGHT do return index, true + row_y += SDL_CONTEXT_MENU_ROW_HEIGHT + } + return 0, false +} + +context_menu_item_enabled :: proc(menu: ^Context_Menu, editor: ^Editor, action: Context_Menu_Action) -> bool { + active := editor_active_buffer(editor) + if active == nil do return false + switch action { + case .Cut, .Copy: + _, _, has_selection := editor_selection_range(active) + return has_selection + case .Paste: + return bool(SDL.HasClipboardText()) + case .Definition, .References, .Rename: + return menu.daemon_ok + } + return false +} + +context_menu_perform :: proc(action: Context_Menu_Action, editor: ^Editor, view: ^SDL_View, completion: ^Completion_Popup, hover: ^Hover_Tooltip, definition: ^Definition_Jump, references: ^References_Panel, rename: ^Rename_Panel, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) { + active := editor_active_buffer(editor) + if active == nil do return + + switch action { + case .Cut: + 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) + daemon_sync_schedule(sync) + } + case .Copy: + _ = editor_copy_selection_to_clipboard(active) + case .Paste: + if editor_paste_clipboard(active) { + close_stale_edit_overlays(completion, hover, references, rename) + ensure_cursor_visible(editor, view) + daemon_sync_schedule(sync) + } + 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) + } +} + +handle_context_menu_click :: proc(menu: ^Context_Menu, editor: ^Editor, view: ^SDL_View, completion: ^Completion_Popup, hover: ^Hover_Tooltip, definition: ^Definition_Jump, references: ^References_Panel, rename: ^Rename_Panel, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State, x, y: f32) -> bool { + if !menu.open do return false + if !context_menu_hit(menu, x, y) { + menu.open = false + return true + } + index, on_item := context_menu_item_at(menu, x, y) + if !on_item do return true + item := context_menu_items[index] + if !context_menu_item_enabled(menu, editor, item.action) do return true + menu.open = false + context_menu_perform(item.action, editor, view, completion, hover, definition, references, rename, daemon, sync) + return true +} + +editor_line_col_from_mouse :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, x, y: f32) -> (int, int, bool) { + active := editor_active_buffer(editor) + if active == nil do return 0, 0, false + text_x := f32(editor_text_x(view)) + if x < text_x || y < SDL_EDITOR_TEXT_Y do return 0, 0, false + line := view.first_line + int((y - SDL_EDITOR_TEXT_Y) / SDL_LINE_HEIGHT) + if line < 0 || line >= buffer_line_count(&active.buffer) do return 0, 0, false + column := text_column_from_pixel_x_gpu(gpu, &active.buffer, line, x - text_x) + return line, max_int(column, 0), true +} + +editor_offset_from_mouse :: proc(editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, x, y: f32) -> (int, bool) { + active := editor_active_buffer(editor) + if active == nil do return 0, false + line, column, ok := editor_line_col_from_mouse(editor, view, gpu, x, y) + if !ok do return 0, false + return buffer_line_col_to_offset(&active.buffer, line, column), true +} + +handle_editor_right_click :: proc(menu: ^Context_Menu, editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, tasks_panel: ^Gradle_Tasks_Panel, daemon: ^Daemon_Client, x, y: f32) -> bool { + if !editor_text_hit(view, tasks_panel, x, y) { + menu.open = false + return false + } + active := editor_active_buffer(editor) + if active == nil || active.diff != nil do return false + + // Keep the selection when right-clicking inside it; otherwise move the + // caret to the click, matching IDE behavior. + offset, has_offset := editor_offset_from_mouse(editor, view, gpu, x, y) + start, end, has_selection := editor_selection_range(active) + if !(has_selection && has_offset && offset >= start && offset < end) { + _ = handle_editor_text_mouse(editor, view, gpu, x, y, false) + } + context_menu_open_at(menu, view, daemon, x, y) + return true +} + +render_context_menu_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, menu: ^Context_Menu, editor: ^Editor) { + if !menu.open do return + + height := context_menu_height() + gpu_rect(gpu, menu.x, menu.y, SDL_CONTEXT_MENU_WIDTH, height, 25, 28, 36, 255) + gpu_rect_outline(gpu, menu.x, menu.y, SDL_CONTEXT_MENU_WIDTH, height, 58, 62, 72, 255) + + hovered_index, has_hover := context_menu_item_at(menu, view.mouse_x, view.mouse_y) + row_y := menu.y + SDL_CONTEXT_MENU_PADDING + for item, index in context_menu_items { + if item.separator_before { + gpu_rect(gpu, menu.x + 8, row_y + 3, SDL_CONTEXT_MENU_WIDTH - 16, 1, 52, 55, 63, 255) + row_y += SDL_CONTEXT_MENU_SEPARATOR + } + enabled := context_menu_item_enabled(menu, editor, item.action) + if enabled && has_hover && hovered_index == index { + gpu_rect(gpu, menu.x + 4, row_y, SDL_CONTEXT_MENU_WIDTH - 8, SDL_CONTEXT_MENU_ROW_HEIGHT, 49, 56, 70, 255) + } + if enabled { + gpu_text(gpu, menu.x + 14, row_y + 4, item.label, 220, 223, 228, 255) + } else { + gpu_text(gpu, menu.x + 14, row_y + 4, item.label, 120, 124, 132, 255) + } + row_y += SDL_CONTEXT_MENU_ROW_HEIGHT + } +} + 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 { +handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, dialog: ^Folder_Dialog_State,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 @@ -2165,7 +3038,7 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo 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) + command_palette_accept(command_palette, editor, view, tree, workspace, dialog, completion, hover, definition, references, rename, search, diagnostics_panel, tasks_panel, daemon, sync) return false } } @@ -2231,35 +3104,39 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo 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 tasks_panel.open && key == SDL.K_ESCAPE { + tasks_panel.open = false + view.gradle_sidebar_visible = false + ui_state_save(view) + return false + } + + // Diff tabs are read-only: allow closing, the palette, and scrolling; + // swallow everything else so nothing edits the hidden buffer. + if active.diff != nil { + ctrl := (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE + shift := (mod & SDL.KMOD_SHIFT) != SDL.KMOD_NONE + switch { + case key == SDL.K_W && ctrl: + if editor_close_active(editor) { + scroll_sdl_view(editor, view, 0) + } + case key == SDL.K_P && ctrl && shift: + command_palette_open(command_palette) + case key == SDL.K_UP: + diff_view_scroll(active, view, -1) + case key == SDL.K_DOWN: + diff_view_scroll(active, view, 1) + case key == SDL.K_PAGEUP: + diff_view_scroll(active, view, -visible_editor_lines(view)) + case key == SDL.K_PAGEDOWN: + diff_view_scroll(active, view, visible_editor_lines(view)) + case key == SDL.K_HOME && ctrl: + active.diff.scroll = 0 + case key == SDL.K_END && ctrl: + diff_view_scroll(active, view, len(active.diff.rows)) } + return false } if key == SDL.K_LEFT && (mod & SDL.KMOD_ALT) != SDL.KMOD_NONE { navigation_go_back(navigation, editor, view) @@ -2297,6 +3174,10 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo search_panel_open(search) return false } + if key == SDL.K_O && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE { + folder_dialog_show(dialog, workspace) + 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 @@ -2653,6 +3534,10 @@ finish_selection_for_keyboard_move :: proc(active: ^Editor_Buffer, mod: SDL.Keym scroll_sdl_view :: proc(editor: ^Editor, view: ^SDL_View, delta: int) { active := editor_active_buffer(editor) if active == nil do return + if active.diff != nil { + diff_view_scroll(active, view, delta) + 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) @@ -2681,7 +3566,7 @@ refresh_view_size :: proc(window: ^SDL.Window, view: ^SDL_View) { } visible_editor_lines :: proc(view: ^SDL_View) -> int { - available := view.window_height - SDL_EDITOR_TEXT_Y - SDL_STATUS_BAR_HEIGHT - 8 + available := editor_content_bottom(view) - SDL_EDITOR_TEXT_Y - 8 return max_int(available / SDL_LINE_HEIGHT, 1) } @@ -2779,7 +3664,7 @@ render_sdl_editor :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_V _ = 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) { +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, context_menu: ^Context_Menu, tasks_panel: ^Gradle_Tasks_Panel, terminal: ^Terminal_Panel, git: ^Git_Panel, git_status: ^Git_Status_Panel) { active := editor_active_buffer(editor) if active == nil do return @@ -2788,9 +3673,9 @@ render_sdl_editor_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_Vi 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) + render_left_sidebar_gpu(gpu, view, tree, git_status, active.path) - editor_height := f32(gpu.height - SDL_EDITOR_TOP - SDL_STATUS_BAR_HEIGHT) + editor_height := f32(editor_content_bottom(view) - SDL_EDITOR_TOP) 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) @@ -2799,48 +3684,52 @@ render_sdl_editor_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_Vi 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 + if active.diff != nil { + render_diff_view_gpu(gpu, view, active, tasks_panel) + } else { + 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 + 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[:]) + 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} + 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 } - - 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 { @@ -2856,6 +3745,7 @@ render_sdl_editor_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_Vi render_status_bar_gpu(gpu, "saved", false) } + render_terminal_panel_gpu(gpu, view, terminal, git, tasks_panel) render_completion_popup_gpu(gpu, editor, view, completion) render_hover_tooltip_gpu(gpu, editor, view, hover) render_references_panel_gpu(gpu, references) @@ -2863,7 +3753,9 @@ render_sdl_editor_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_Vi 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_tool_strip_gpu(gpu, tasks_panel, terminal) + render_command_palette_gpu(gpu, view, command_palette) + render_context_menu_gpu(gpu, view, context_menu, editor) render_metrics_overlay_gpu(gpu, editor, view) gpu_present(gpu) } @@ -2873,9 +3765,9 @@ visible_text_columns :: proc(view: ^SDL_View) -> int { } visible_gpu_text_columns :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel) -> int { - right_limit := gpu.width - 16 + right_limit := content_right_edge(gpu.width) - 16 if tasks_panel != nil && tasks_panel.open { - right_limit = gpu.width - right_sidebar_width_view(view, gpu.width) - 16 + right_limit = content_right_edge(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) } @@ -3256,7 +4148,172 @@ render_search_panel_gpu :: proc(gpu: ^GPU_Renderer, panel: ^Search_Panel) { } } -render_command_palette_gpu :: proc(gpu: ^GPU_Renderer, palette: ^Command_Palette) { +SDL_TOOLBAR_OPEN_FOLDER_LABEL :: "Open Folder" + +// Geometry is shared by rendering, click handling, and hover cursor so the +// three always agree. +toolbar_open_folder_button :: proc() -> (x, y, width, height: f32) { + x = 14 + f32(gpu_text_width("Native Kotlin Editor")) + 26 + y = 5 + width = f32(gpu_text_width(SDL_TOOLBAR_OPEN_FOLDER_LABEL)) + 20 + height = SDL_TOP_BAR_HEIGHT - 10 + return +} + +toolbar_open_folder_hit :: proc(x, y: f32) -> bool { + button_x, button_y, width, height := toolbar_open_folder_button() + return x >= button_x && x < button_x + width && y >= button_y && y < button_y + height +} + +handle_toolbar_click :: proc(dialog: ^Folder_Dialog_State, workspace: string, x, y: f32) -> bool { + if !toolbar_open_folder_hit(x, y) do return false + folder_dialog_show(dialog, workspace) + return true +} + +SDL_TOOL_STRIP_GRADLE_LABEL :: "Gradle" + +tool_strip_gradle_button :: proc(window_width: int) -> (x, y, width, height: f32) { + x = f32(window_width - SDL_TOOL_STRIP_WIDTH) + y = SDL_TOP_BAR_HEIGHT + 8 + width = SDL_TOOL_STRIP_WIDTH + height = f32(len(SDL_TOOL_STRIP_GRADLE_LABEL)) * 14 + 16 + return +} + +tool_strip_gradle_hit :: proc(window_width: int, x, y: f32) -> bool { + button_x, button_y, width, height := tool_strip_gradle_button(window_width) + return x >= button_x && x < button_x + width && y >= button_y && y < button_y + height +} + +SDL_TOOL_STRIP_TERMINAL_LABEL :: "Term" + +// Left tool strip: switches the sidebar between file navigation and the git +// staging view. +left_tab_label :: proc(tab: Left_Tab) -> string { + switch tab { + case .Files: + return "Files" + case .Git: + return "Git" + } + return "" +} + +left_strip_button :: proc(tab: Left_Tab) -> (x, y, width, height: f32) { + x = 0 + y = SDL_TOP_BAR_HEIGHT + 8 + width = SDL_TOOL_STRIP_WIDTH + for t in Left_Tab { + height = f32(len(left_tab_label(t))) * 14 + 16 + if t == tab do break + y += height + 8 + } + return +} + +left_strip_hit :: proc(tab: Left_Tab, x, y: f32) -> bool { + button_x, button_y, width, height := left_strip_button(tab) + return x >= button_x && x < button_x + width && y >= button_y && y < button_y + height +} + +left_tab_activate :: proc(view: ^SDL_View, tab: Left_Tab) { + if view.left_tab == tab { + view.explorer_visible = !view.explorer_visible + } else { + view.left_tab = tab + view.explorer_visible = true + } +} + +handle_left_strip_click :: proc(view: ^SDL_View, git_status: ^Git_Status_Panel, workspace: string, x, y: f32) -> bool { + if x >= f32(SDL_TOOL_STRIP_WIDTH) do return false + if y < SDL_TOP_BAR_HEIGHT || y >= f32(view.window_height - SDL_STATUS_BAR_HEIGHT) do return false + if left_strip_hit(.Files, x, y) { + left_tab_activate(view, .Files) + ui_state_save(view) + } else if left_strip_hit(.Git, x, y) { + left_tab_activate(view, .Git) + if git_status_panel_active(view) { + git_status_request(git_status, workspace) + } + ui_state_save(view) + } + // Clicks on the strip never fall through. + return true +} + +// The whole left column: strip background, its buttons, and the active +// sidebar panel (file tree or git status). +render_left_sidebar_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, tree: ^Project_Tree, git_status: ^Git_Status_Panel, active_path: string) { + height := f32(gpu.height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + gpu_rect(gpu, 0, SDL_TOP_BAR_HEIGHT, SDL_TOOL_STRIP_WIDTH, height, 37, 38, 43, 255) + gpu_rect(gpu, SDL_TOOL_STRIP_WIDTH - 1, SDL_TOP_BAR_HEIGHT, 1, height, 56, 58, 64, 255) + tool_strip_button_render(gpu, left_strip_button(.Files), left_tab_label(.Files), view.explorer_visible && view.left_tab == .Files) + tool_strip_button_render(gpu, left_strip_button(.Git), left_tab_label(.Git), view.explorer_visible && view.left_tab == .Git) + + if !view.explorer_visible do return + left := f32(content_left_edge()) + sidebar_width := f32(left_sidebar_width(view)) + gpu_rect(gpu, left, SDL_TOP_BAR_HEIGHT, sidebar_width - left, height, 35, 36, 40, 255) + gpu_rect(gpu, left, SDL_TOP_BAR_HEIGHT, sidebar_width - left, 1, 55, 57, 64, 255) + gpu_rect(gpu, sidebar_width - 2, SDL_TOP_BAR_HEIGHT, 4, height, 57, 60, 68, 255) + + if view.left_tab == .Files { + render_project_tree_gpu(gpu, tree, view, active_path) + } else { + render_git_status_panel_gpu(gpu, view, git_status) + } +} + +tool_strip_terminal_button :: proc(window_width: int) -> (x, y, width, height: f32) { + _, gradle_y, _, gradle_height := tool_strip_gradle_button(window_width) + x = f32(window_width - SDL_TOOL_STRIP_WIDTH) + y = gradle_y + gradle_height + 8 + width = SDL_TOOL_STRIP_WIDTH + height = f32(len(SDL_TOOL_STRIP_TERMINAL_LABEL)) * 14 + 16 + return +} + +tool_strip_terminal_hit :: proc(window_width: int, x, y: f32) -> bool { + button_x, button_y, width, height := tool_strip_terminal_button(window_width) + return x >= button_x && x < button_x + width && y >= button_y && y < button_y + height +} + +handle_tool_strip_click :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, terminal: ^Terminal_Panel, git: ^Git_Panel, workspace: string, editor: ^Editor, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State, x, y: f32) -> bool { + if x < f32(content_right_edge(view.window_width)) do return false + if y < SDL_TOP_BAR_HEIGHT || y >= f32(view.window_height - SDL_STATUS_BAR_HEIGHT) do return false + if tool_strip_gradle_hit(view.window_width, x, y) { + gradle_panel_toggle(view, tasks_panel, editor, daemon, sync) + } else if tool_strip_terminal_hit(view.window_width, x, y) { + terminal_toggle(terminal, git, view, workspace) + } + // Clicks on the strip never fall through to the editor behind it. + return true +} + +render_tool_strip_gpu :: proc(gpu: ^GPU_Renderer, tasks_panel: ^Gradle_Tasks_Panel, terminal: ^Terminal_Panel) { + x := f32(content_right_edge(gpu.width)) + y: f32 = SDL_TOP_BAR_HEIGHT + height := f32(gpu.height - SDL_TOP_BAR_HEIGHT - SDL_STATUS_BAR_HEIGHT) + gpu_rect(gpu, x, y, SDL_TOOL_STRIP_WIDTH, height, 37, 38, 43, 255) + gpu_rect(gpu, x, y, 1, height, 56, 58, 64, 255) + + tool_strip_button_render(gpu, tool_strip_gradle_button(gpu.width), SDL_TOOL_STRIP_GRADLE_LABEL, tasks_panel.open) + tool_strip_button_render(gpu, tool_strip_terminal_button(gpu.width), SDL_TOOL_STRIP_TERMINAL_LABEL, terminal.open) +} + +tool_strip_button_render :: proc(gpu: ^GPU_Renderer, button_x, button_y, button_width, button_height: f32, label: string, active: bool) { + if active { + gpu_rect(gpu, button_x + 2, button_y, button_width - 3, button_height, 58, 64, 82, 255) + } + char_x := button_x + (button_width - 8) * 0.5 + for i in 0 ..< len(label) { + gpu_text(gpu, char_x, button_y + 8 + f32(i) * 14, label[i:i + 1], 200, 206, 216, 255) + } +} + +render_command_palette_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, palette: ^Command_Palette) { if !palette.open do return width := f32(min_int(max_int(gpu.width - 280, 420), 720)) @@ -3288,14 +4345,20 @@ render_command_palette_gpu :: proc(gpu: ^GPU_Renderer, palette: ^Command_Palette continue } if rendered >= 8 do break + row_hovered := view.mouse_x >= x + 8 && view.mouse_x < x + width - 8 && view.mouse_y >= item_y - 5 && view.mouse_y < item_y + 17 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) + } else if row_hovered { + gpu_rect(gpu, x + 8, item_y - 5, width - 16, 22, 38, 42, 52, 255) } color := [3]u8{218, 222, 230} if seen != palette.selected { color = {166, 172, 184} } + if row_hovered && seen != palette.selected { + color = {200, 205, 215} + } 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 @@ -3314,7 +4377,7 @@ render_diagnostics_panel_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: height: f32 = 168 sidebar_width := left_sidebar_width(view) x := f32(sidebar_width) - y := f32(gpu.height - SDL_STATUS_BAR_HEIGHT) - height + y := f32(editor_content_bottom(view)) - 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) @@ -3338,9 +4401,12 @@ render_diagnostics_panel_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: item_y := y + 38 for index := first_index; index < len(active.diagnostics) && index < first_index + visible_items; index += 1 { diagnostic := active.diagnostics[index] + row_hovered := view.mouse_x >= x + 8 && view.mouse_x < x + width - 8 && view.mouse_y >= item_y - 4 && view.mouse_y < item_y + 14 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) + } else if row_hovered { + gpu_rect(gpu, x + 8, item_y - 4, width - 16, 20, 40, 43, 50, 255) } label := fmt.tprintf("%s %d:%d %s", diagnostic.severity, diagnostic.line + 1, diagnostic.column + 1, diagnostic.message) color := [3]u8{218, 222, 230} @@ -3355,51 +4421,51 @@ render_diagnostics_panel_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: 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 + x, y, width, height := gradle_panel_geometry(view, gpu.width, gpu.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) + gpu_text_limited(gpu, x + width - 44, y + 12, 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) + gpu_text_limited(gpu, x + 14, y + 32, panel.message, int((width - 28) / 8), 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) + gpu_text_limited(gpu, x + 14, y + SDL_GRADLE_LIST_TOP, "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) + output_height := gradle_panel_output_height(panel, height) + rows := gradle_panel_rows(panel) + visible := gradle_panel_visible_rows(panel, height) + panel.scroll = clamp_int(panel.scroll, 0, max_int(len(rows) - visible, 0)) + max_columns := max_int(int((width - 44) / max_f32(gpu.font_advance, 1)), 1) + row_y := y + SDL_GRADLE_LIST_TOP + for offset in 0 ..< visible { + index := panel.scroll + offset + if index >= len(rows) do break + row := rows[index] + row_hovered := view.mouse_x >= x + 4 && view.mouse_x < x + width - 4 && view.mouse_y >= row_y && view.mouse_y < row_y + SDL_GRADLE_ROW_HEIGHT + if row.kind == .Task && row.item_index == panel.selected { + gpu_rect(gpu, x + 4, row_y, width - 8, SDL_GRADLE_ROW_HEIGHT, 49, 56, 70, 255) + gpu_rect(gpu, x + 4, row_y, 2, SDL_GRADLE_ROW_HEIGHT, 77, 155, 230, 255) + } else if row_hovered { + gpu_rect(gpu, x + 4, row_y, width - 8, SDL_GRADLE_ROW_HEIGHT, 42, 44, 50, 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) + text_y := row_y + 2 + switch row.kind { + case .Project: + arrow := ">" if panel.collapsed[row.key] else "v" + gpu_text(gpu, x + 12, text_y, arrow, 140, 146, 158, 255) + gpu_text_limited(gpu, x + 24, text_y, row.label, max_columns, 200, 205, 215, 255) + case .Group: + arrow := ">" if panel.collapsed[row.key] else "v" + gpu_text(gpu, x + 26, text_y, arrow, 140, 146, 158, 255) + gpu_text_limited(gpu, x + 38, text_y, row.label, max_columns, 170, 176, 188, 255) + case .Task: + gpu_text_limited(gpu, x + 52, text_y, row.label, max_columns, 218, 222, 230, 255) } - item_y += 30 if len(item.description) > 0 else 18 + row_y += SDL_GRADLE_ROW_HEIGHT } if len(panel.output) > 0 { @@ -3515,7 +4581,11 @@ render_hover_tooltip :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SD _ = SDL.SetRenderDrawColor(renderer, 100, 125, 165, 255) _ = SDL.RenderRect(renderer, &rect) _ = SDL.SetRenderDrawColor(renderer, 220, 225, 235, 255) - text := fmt.ctprintf("%s", hover.contents) + first_line := hover.contents + if newline := strings.index_byte(first_line, '\n'); newline >= 0 { + first_line = first_line[:newline] + } + text := fmt.ctprintf("%s", first_line) _ = SDL.RenderDebugText(renderer, x + 10, y + 10, text) } @@ -3524,15 +4594,39 @@ render_hover_tooltip_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL 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) + // Hover contents can span several lines (signatures, docs), so the box + // is sized to the visible lines and each line is drawn separately — + // gpu_text would otherwise continue multi-line text below the box. + all_lines, _ := strings.split_lines(hover.contents, context.temp_allocator) + line_count := min_int(len(all_lines), 12) + lines := all_lines[:line_count] + for len(lines) > 0 && len(strings.trim_space(lines[len(lines) - 1])) == 0 { + lines = lines[:len(lines) - 1] + } + if len(lines) == 0 do return - 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) + max_line_width := 0 + for line in lines { + max_line_width = max_int(max_line_width, gpu_font_text_width(gpu, line)) + } + width := f32(max_int(180, min_int(560, max_line_width + 20))) + height := f32(18 + len(lines) * 16) + + anchor_line := min_int(hover.line, buffer_line_count(&active.buffer) - 1) + anchor_col := hover.word_start if hover.from_mouse else hover.column + x := editor_column_pixel_x_gpu(gpu, view, &active.buffer, anchor_line, anchor_col) + y := f32(SDL_EDITOR_TEXT_Y + (anchor_line - view.first_line + 2) * SDL_LINE_HEIGHT) + x = max_f32(min_f32(x, f32(content_right_edge(gpu.width)) - width - 4), 0) + y = max_f32(min_f32(y, f32(editor_content_bottom(view)) - height - 4), SDL_EDITOR_TOP) + + gpu_rect(gpu, x, y, width, height, 30, 34, 45, 255) + gpu_rect_outline(gpu, x, y, width, height, 100, 125, 165, 255) + max_columns := max_int(int((width - 20) / max_f32(gpu.font_advance, 1)), 1) + line_y := y + 10 + for line in lines { + gpu_text_limited(gpu, x + 10, line_y, line, max_columns, 220, 225, 235, 255) + line_y += 16 + } } render_completion_popup :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_View, popup: ^Completion_Popup) { @@ -3726,7 +4820,11 @@ render_project_tree :: proc(renderer: ^SDL.Renderer, tree: ^Project_Tree, view: _ = SDL.RenderFillRect(renderer, &row) } - label := fmt.ctprintf("%s%s", "/ " if item.is_dir else " ", item.label) + marker := " " + if item.is_dir { + marker = "v " if item.expanded else "> " + } + label := fmt.ctprintf("%s%s", marker, item.name) if item.is_dir { _ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255) } else if item.path == active_path { @@ -3734,45 +4832,66 @@ render_project_tree :: proc(renderer: ^SDL.Renderer, tree: ^Project_Tree, view: } else { _ = SDL.SetRenderDrawColor(renderer, 190, 198, 215, 255) } - _ = SDL.RenderDebugText(renderer, 12, y, label) + _ = SDL.RenderDebugText(renderer, 12 + f32(item.depth * 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 + if !view.explorer_visible || view.left_tab != .Files do return + left := f32(content_left_edge()) 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) + gpu_text(gpu, left + 14, SDL_TREE_HEADER_Y, "EXPLORER", 150, 154, 162, 255) + gpu_rect(gpu, left + 14, SDL_TREE_HEADER_Y + 18, f32(sidebar_width) - left - 28, 1, 52, 54, 60, 255) + + hovered_index, has_hover := project_tree_row_at(tree, view, view.mouse_x, view.mouse_y) 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) + is_active := item.path == active_path + if is_active { + gpu_rect(gpu, left + 8, y - 3, f32(sidebar_width) - left - 16, SDL_TREE_ROW_HEIGHT + 1, 49, 51, 58, 255) + gpu_rect(gpu, left + 8, y - 3, 2, SDL_TREE_ROW_HEIGHT + 1, 77, 155, 230, 255) + } else if has_hover && index == hovered_index { + gpu_rect(gpu, left + 8, y - 3, f32(sidebar_width) - left - 16, SDL_TREE_ROW_HEIGHT + 1, 42, 44, 50, 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) + indent := left + f32(16 + item.depth * 12) + text_budget := f32(sidebar_width) - indent - 24 + max_columns := max_int(int(text_budget / 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) + arrow := "v" if item.expanded else ">" + gpu_text(gpu, indent, y, arrow, 140, 146, 158, 255) + gpu_text_limited(gpu, indent + gpu.font_advance * 2, y, item.name, max_int(max_columns - 2, 1), 200, 205, 214, 255) + } else if is_active { + gpu_text_limited(gpu, indent + gpu.font_advance * 2, y, item.name, max_int(max_columns - 2, 1), 235, 237, 241, 255) } else { - gpu_text_limited(gpu, 16, y, label, max_columns, 190, 193, 201, 255) + gpu_text_limited(gpu, indent + gpu.font_advance * 2, y, item.name, max_int(max_columns - 2, 1), 185, 189, 198, 255) } y += SDL_TREE_ROW_HEIGHT } + + // Scrollbar when the tree overflows the sidebar. + total := len(tree.files) + visible := visible_tree_rows(view) + if total > visible { + track_top := f32(SDL_TREE_FIRST_Y - 4) + track_height := f32(visible * SDL_TREE_ROW_HEIGHT) + track_x := f32(sidebar_width - 8) + gpu_rect(gpu, track_x, track_top, 4, track_height, 40, 42, 48, 255) + thumb_height := max_f32(track_height * f32(visible) / f32(total), 24) + max_first := max_int(total - visible, 1) + thumb_y := track_top + (track_height - thumb_height) * f32(view.tree_first) / f32(max_first) + gpu_rect(gpu, track_x, thumb_y, 4, thumb_height, 92, 96, 106, 255) + } } 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) + mouse_in_bar := view.mouse_y >= SDL_TOP_BAR_HEIGHT && view.mouse_y < SDL_EDITOR_TOP for &buffer, index in editor.buffers { if tab_x >= max_x do break _, file_name := os.split_path(buffer.path) @@ -3781,20 +4900,29 @@ render_editor_tabs_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL_V width = max_x - tab_x } active := index == editor.active + hovered := mouse_in_bar && view.mouse_x >= tab_x && view.mouse_x < tab_x + width + close_hovered := hovered && view.mouse_x >= tab_x + width - 28 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 if hovered { + gpu_rect(gpu, tab_x, SDL_TOP_BAR_HEIGHT + 6, width, SDL_TAB_BAR_HEIGHT - 6, 48, 50, 56, 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) + title_color := [3]u8{220, 223, 228} if active else [3]u8{168, 172, 182} + if hovered && !active { + title_color = {198, 202, 211} + } + gpu_text_limited(gpu, tab_x + 16, SDL_TOP_BAR_HEIGHT + 12, tab_title, 34, title_color[0], title_color[1], title_color[2], 255) + if close_hovered { + gpu_rect(gpu, tab_x + width - 27, SDL_TOP_BAR_HEIGHT + 8, 15, SDL_TAB_BAR_HEIGHT - 14, 62, 65, 73, 255) + gpu_text(gpu, tab_x + width - 22, SDL_TOP_BAR_HEIGHT + 12, "x", 235, 237, 241, 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) + close_color := [3]u8{168, 172, 182} if active else [3]u8{124, 128, 138} + gpu_text(gpu, tab_x + width - 22, SDL_TOP_BAR_HEIGHT + 12, "x", close_color[0], close_color[1], close_color[2], 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 @@ -3822,6 +4950,11 @@ render_top_bar_gpu :: proc(gpu: ^GPU_Renderer, active: ^Editor_Buffer) { 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) + button_x, button_y, button_width, button_height := toolbar_open_folder_button() + gpu_rect(gpu, button_x, button_y, button_width, button_height, 56, 58, 66, 255) + gpu_rect_outline(gpu, button_x, button_y, button_width, button_height, 72, 76, 86, 255) + gpu_text(gpu, button_x + 10, button_y + 5, SDL_TOOLBAR_OPEN_FOLDER_LABEL, 200, 206, 216, 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) diff --git a/client/odin/terminal.odin b/client/odin/terminal.odin new file mode 100644 index 0000000..aa584f4 --- /dev/null +++ b/client/odin/terminal.odin @@ -0,0 +1,1251 @@ +package main + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:sync" +import "core:sys/linux" +import "core:sys/posix" +import "core:thread" +import "core:time" +import SDL "vendor:sdl3" + +// Bottom terminal panel: the user's shell on a PTY, driven by a VT100-subset +// emulator. The screen is a rows*cols cell grid with colors, cursor +// addressing, scroll regions and an alternate screen, so full-screen TUI +// programs work. Lines scrolling off the primary screen go to a scrollback. + +SDL_TERMINAL_DEFAULT_HEIGHT :: 320 +SDL_TERMINAL_MIN_HEIGHT :: 120 +SDL_TERMINAL_HEADER :: 26 +SDL_TERMINAL_LINE_HEIGHT :: 16 +SDL_TERMINAL_SCROLLBACK :: 1000 + +TIOCSWINSZ :: 0x5414 + +TERM_DEFAULT_COLOR :: 255 // sentinel palette index: use the theme default + +Pty_Winsize :: struct { + row: u16, + col: u16, + xpixel: u16, + ypixel: u16, +} + +Term_Cell :: struct { + ch: rune, + fg: u8, + bg: u8, +} + +Bottom_Tab :: enum { + Terminal, + Git, +} + +bottom_tab_label :: proc(tab: Bottom_Tab) -> string { + switch tab { + case .Terminal: + return "Terminal" + case .Git: + return "Git" + } + return "" +} + +Terminal_Escape_State :: enum { + Ground, + Escape, + Escape_Charset, // ESC ( or ESC ) — discard one byte + Csi, + Osc, +} + +Terminal_Panel :: struct { + open: bool, + focused: bool, + running: bool, + active_tab: Bottom_Tab, + master: posix.FD, + process: os.Process, + reader: ^thread.Thread, + mutex: sync.Mutex, + pending: [dynamic]u8, + + // Screen grid state. + rows: int, + cols: int, + cells: [dynamic]Term_Cell, + alt_cells: [dynamic]Term_Cell, + alt_active: bool, + cursor_row: int, + cursor_col: int, + saved_row: int, + saved_col: int, + scroll_top: int, // scroll region, inclusive + scroll_bot: int, + scrollback: [dynamic][dynamic]Term_Cell, + scroll_view: int, // lines scrolled up in the view; 0 = live + + // Current write attributes. + fg: u8, + bg: u8, + bold: bool, + reverse: bool, + + // Modes. + cursor_visible: bool, + app_cursor_keys: bool, + autowrap: bool, + + // Parser state. + escape: Terminal_Escape_State, + csi: [dynamic]u8, + utf8_need: int, + utf8_value: rune, + + pty_rows: int, + pty_cols: int, +} + +// --------------------------------------------------------------------------- +// PTY plumbing + +terminal_destroy :: proc(term: ^Terminal_Panel) { + terminal_stop(term) + if term.reader != nil { + thread.join(term.reader) + thread.destroy(term.reader) + term.reader = nil + } + delete(term.pending) + delete(term.cells) + delete(term.alt_cells) + for line in term.scrollback { + delete(line) + } + delete(term.scrollback) + delete(term.csi) +} + +terminal_stop :: proc(term: ^Terminal_Panel) { + // Closing the master first hangs up the shell's controlling terminal. + // Interactive shells ignore SIGTERM, so escalate to SIGKILL after a + // short grace period instead of waiting forever. + if term.master > 0 { + _ = posix.close(term.master) + term.master = 0 + } + if term.running { + term.running = false + _ = os.process_terminate(term.process) + state, wait_err := os.process_wait(term.process, 500 * time.Millisecond) + if wait_err != nil || !state.exited { + _ = os.process_kill(term.process) + _, _ = os.process_wait(term.process) + } + } +} + +terminal_start :: proc(term: ^Terminal_Panel, workspace: string) -> bool { + if term.running do return true + terminal_ensure_grid(term) + + master := posix.posix_openpt({.RDWR, .NOCTTY}) + if master < 0 { + terminal_append_note(term, "terminal: failed to open pty") + return false + } + if posix.grantpt(master) != .OK || posix.unlockpt(master) != .OK { + _ = posix.close(master) + terminal_append_note(term, "terminal: failed to set up pty") + return false + } + slave_name := posix.ptsname(master) + slave := posix.open(slave_name, {.RDWR, .NOCTTY}) + if slave < 0 { + _ = posix.close(master) + terminal_append_note(term, "terminal: failed to open pty slave") + return false + } + + slave_file := os.new_file(uintptr(slave), string(slave_name)) + if slave_file == nil { + _ = posix.close(slave) + _ = posix.close(master) + terminal_append_note(term, "terminal: failed to wrap pty slave") + return false + } + + shell := os.get_env("SHELL", context.temp_allocator) + if len(shell) == 0 do shell = "/bin/bash" + + env: [dynamic]string + defer delete(env) + if current, env_err := os.environ(context.temp_allocator); env_err == nil { + for entry in current { + if strings.has_prefix(entry, "TERM=") do continue + append(&env, entry) + } + } + append(&env, "TERM=xterm-256color") + + // setsid --ctty makes the pty a proper controlling terminal so the shell + // gets job control; fall back to a bare shell when setsid is missing. + process, start_err := os.process_start(os.Process_Desc{ + working_dir = workspace, + command = []string{"setsid", "--ctty", shell, "-i"}, + env = env[:], + stdin = slave_file, + stdout = slave_file, + stderr = slave_file, + }) + if start_err != nil { + process, start_err = os.process_start(os.Process_Desc{ + working_dir = workspace, + command = []string{shell, "-i"}, + env = env[:], + stdin = slave_file, + stdout = slave_file, + stderr = slave_file, + }) + } + _ = os.close(slave_file) + if start_err != nil { + _ = posix.close(master) + terminal_append_note(term, fmt.tprintf("terminal: failed to start %s", shell)) + return false + } + + if term.reader != nil { + thread.join(term.reader) + thread.destroy(term.reader) + term.reader = nil + } + + term.master = master + term.process = process + term.running = true + term.pty_rows = 0 + term.pty_cols = 0 + term.reader = thread.create_and_start_with_data(rawptr(term), terminal_reader_thread) + terminal_resize(term, term.rows, term.cols) + return true +} + +terminal_reader_thread :: proc(data: rawptr) { + term := (^Terminal_Panel)(data) + buf: [4096]u8 + for term.running { + n := posix.read(term.master, &buf[0], len(buf)) + if n <= 0 do break + if sync.mutex_guard(&term.mutex) { + for b in buf[:n] { + append(&term.pending, b) + } + } + } +} + +terminal_send :: proc(term: ^Terminal_Panel, bytes: string) { + if !term.running || len(bytes) == 0 do return + data := transmute([]u8)bytes + _ = posix.write(term.master, &data[0], len(data)) +} + +terminal_toggle :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, workspace: string) { + if term.open { + term.open = false + term.focused = false + } else { + term.open = true + if term.active_tab == .Terminal { + term.focused = true + // The shell keeps running while the panel is hidden; only start + // one if there is none yet (or the previous one exited). + _ = terminal_start(term, workspace) + } else { + git_panel_request_log(git, workspace) + } + } + view.terminal_visible = term.open + ui_state_save(view) +} + +// --------------------------------------------------------------------------- +// Grid + +terminal_blank_cell :: proc(term: ^Terminal_Panel) -> Term_Cell { + return Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = term.bg} +} + +terminal_ensure_grid :: proc(term: ^Terminal_Panel) { + if term.rows > 0 && term.cols > 0 do return + term.fg = TERM_DEFAULT_COLOR + term.bg = TERM_DEFAULT_COLOR + term.cursor_visible = true + term.autowrap = true + terminal_grid_reset(term, 24, 80) +} + +terminal_grid_reset :: proc(term: ^Terminal_Panel, rows, cols: int) { + term.rows = rows + term.cols = cols + resize(&term.cells, rows * cols) + resize(&term.alt_cells, rows * cols) + blank := Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = TERM_DEFAULT_COLOR} + for i in 0 ..< rows * cols { + term.cells[i] = blank + term.alt_cells[i] = blank + } + term.cursor_row = 0 + term.cursor_col = 0 + term.scroll_top = 0 + term.scroll_bot = rows - 1 +} + +terminal_screen :: proc(term: ^Terminal_Panel) -> ^[dynamic]Term_Cell { + return &term.alt_cells if term.alt_active else &term.cells +} + +terminal_cell_at :: proc(term: ^Terminal_Panel, row, col: int) -> ^Term_Cell { + screen := terminal_screen(term) + return &screen[row * term.cols + col] +} + +// Resizes the grid, keeping the top-left contents; TUIs repaint on the +// SIGWINCH that follows the ioctl. +terminal_resize :: proc(term: ^Terminal_Panel, rows, cols: int) { + terminal_ensure_grid(term) + if rows <= 0 || cols <= 0 do return + if rows != term.rows || cols != term.cols { + old_rows, old_cols := term.rows, term.cols + old_cells := make([]Term_Cell, old_rows * old_cols, context.temp_allocator) + copy(old_cells, term.cells[:]) + old_alt := make([]Term_Cell, old_rows * old_cols, context.temp_allocator) + copy(old_alt, term.alt_cells[:]) + + terminal_grid_reset(term, rows, cols) + for r in 0 ..< min_int(rows, old_rows) { + for c in 0 ..< min_int(cols, old_cols) { + term.cells[r * cols + c] = old_cells[r * old_cols + c] + term.alt_cells[r * cols + c] = old_alt[r * old_cols + c] + } + } + term.cursor_row = clamp_int(term.cursor_row, 0, rows - 1) + term.cursor_col = clamp_int(term.cursor_col, 0, cols - 1) + } + + if term.running && (rows != term.pty_rows || cols != term.pty_cols) { + term.pty_rows = rows + term.pty_cols = cols + size := Pty_Winsize{row = u16(rows), col = u16(cols)} + _ = linux.ioctl(linux.Fd(term.master), TIOCSWINSZ, uintptr(&size)) + } +} + +terminal_erase_cells :: proc(term: ^Terminal_Panel, row, from, to: int) { + blank := terminal_blank_cell(term) + for c in from ..< to { + terminal_cell_at(term, row, c)^ = blank + } +} + +// Scrolls the region up by one line; the top line of a full-width primary +// screen region is preserved in the scrollback. +terminal_scroll_up :: proc(term: ^Terminal_Panel) { + if !term.alt_active && term.scroll_top == 0 && term.scroll_bot == term.rows - 1 { + line := make([dynamic]Term_Cell, term.cols) + for c in 0 ..< term.cols { + line[c] = terminal_cell_at(term, 0, c)^ + } + append(&term.scrollback, line) + for len(term.scrollback) > SDL_TERMINAL_SCROLLBACK { + delete(term.scrollback[0]) + ordered_remove(&term.scrollback, 0) + } + } + for r in term.scroll_top ..< term.scroll_bot { + for c in 0 ..< term.cols { + terminal_cell_at(term, r, c)^ = terminal_cell_at(term, r + 1, c)^ + } + } + terminal_erase_cells(term, term.scroll_bot, 0, term.cols) +} + +terminal_scroll_down :: proc(term: ^Terminal_Panel) { + for r := term.scroll_bot; r > term.scroll_top; r -= 1 { + for c in 0 ..< term.cols { + terminal_cell_at(term, r, c)^ = terminal_cell_at(term, r - 1, c)^ + } + } + terminal_erase_cells(term, term.scroll_top, 0, term.cols) +} + +terminal_linefeed :: proc(term: ^Terminal_Panel) { + if term.cursor_row == term.scroll_bot { + terminal_scroll_up(term) + } else if term.cursor_row < term.rows - 1 { + term.cursor_row += 1 + } +} + +terminal_put_char :: proc(term: ^Terminal_Panel, ch: rune) { + if term.cursor_col >= term.cols { + if term.autowrap { + term.cursor_col = 0 + terminal_linefeed(term) + } else { + term.cursor_col = term.cols - 1 + } + } + cell := terminal_cell_at(term, term.cursor_row, term.cursor_col) + fg, bg := term.fg, term.bg + if term.reverse { + fg, bg = bg, fg + // Reversed defaults need concrete colors to actually swap visibly. + if fg == TERM_DEFAULT_COLOR do fg = 0 + if bg == TERM_DEFAULT_COLOR do bg = 7 + } + if term.bold && fg < 8 do fg += 8 + cell^ = Term_Cell{ch = ch, fg = fg, bg = bg} + term.cursor_col += 1 +} + +terminal_append_note :: proc(term: ^Terminal_Panel, note: string) { + terminal_ensure_grid(term) + for b in transmute([]u8)note { + terminal_consume_byte(term, b) + } + terminal_consume_byte(term, '\r') + terminal_consume_byte(term, '\n') +} + +// --------------------------------------------------------------------------- +// Parser + +// Folds pending pty output into the grid. Called once per frame. +terminal_pump :: proc(term: ^Terminal_Panel) { + chunk: [dynamic]u8 + defer delete(chunk) + if sync.mutex_guard(&term.mutex) { + if len(term.pending) == 0 do return + for b in term.pending { + append(&chunk, b) + } + clear(&term.pending) + } + + terminal_ensure_grid(term) + for b in chunk { + terminal_consume_byte(term, b) + } +} + +terminal_consume_byte :: proc(term: ^Terminal_Panel, b: u8) { + switch term.escape { + case .Escape: + switch b { + case '[': + term.escape = .Csi + clear(&term.csi) + case ']': + term.escape = .Osc + case '(', ')': + term.escape = .Escape_Charset + case '7': + term.saved_row = term.cursor_row + term.saved_col = term.cursor_col + term.escape = .Ground + case '8': + term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1) + term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1) + term.escape = .Ground + case 'D': + terminal_linefeed(term) + term.escape = .Ground + case 'E': + term.cursor_col = 0 + terminal_linefeed(term) + term.escape = .Ground + case 'M': + if term.cursor_row == term.scroll_top { + terminal_scroll_down(term) + } else if term.cursor_row > 0 { + term.cursor_row -= 1 + } + term.escape = .Ground + case 'c': + terminal_grid_reset(term, term.rows, term.cols) + term.fg = TERM_DEFAULT_COLOR + term.bg = TERM_DEFAULT_COLOR + term.bold = false + term.reverse = false + term.escape = .Ground + case: + term.escape = .Ground + } + return + case .Escape_Charset: + term.escape = .Ground + return + case .Csi: + if (b >= '0' && b <= '9') || b == ';' || b == '?' || b == '>' || b == '<' || b == '=' || b == ':' || b == ' ' { + if len(term.csi) < 64 do append(&term.csi, b) + return + } + if b >= 0x40 && b <= 0x7e { + terminal_csi_dispatch(term, b) + } + term.escape = .Ground + return + case .Osc: + if b == 0x07 { + term.escape = .Ground + } else if b == 0x1b { + // ST arrives as ESC \; the backslash is eaten by Escape state. + term.escape = .Escape + } + return + case .Ground: + } + + // UTF-8: continuation handling maps multi-byte characters to an ASCII + // approximation, since the font atlas covers ASCII only. + if term.utf8_need > 0 { + if b & 0xc0 == 0x80 { + term.utf8_value = (term.utf8_value << 6) | rune(b & 0x3f) + term.utf8_need -= 1 + if term.utf8_need == 0 { + terminal_put_char(term, term.utf8_value) + } + return + } + term.utf8_need = 0 + } + + switch { + case b == 0x1b: + term.escape = .Escape + case b == '\n' || b == 0x0b || b == 0x0c: + terminal_linefeed(term) + case b == '\r': + term.cursor_col = 0 + case b == 0x08: + term.cursor_col = max_int(term.cursor_col - 1, 0) + case b == '\t': + next := (term.cursor_col / 8 + 1) * 8 + term.cursor_col = min_int(next, term.cols - 1) + case b == 0x07, b == 0x0e, b == 0x0f, b == 0: + // Bell and charset shifts: ignore. + case b >= 0xc0: + switch { + case b & 0xe0 == 0xc0: + term.utf8_need = 1 + term.utf8_value = rune(b & 0x1f) + case b & 0xf0 == 0xe0: + term.utf8_need = 2 + term.utf8_value = rune(b & 0x0f) + case: + term.utf8_need = 3 + term.utf8_value = rune(b & 0x07) + } + case b >= 32 && b < 127: + terminal_put_char(term, rune(b)) + } +} + +terminal_ascii_for_rune :: proc(r: rune) -> u8 { + switch r { + case 0x00a0: + return ' ' + case 0x2018, 0x2019: + return '\'' + case 0x201c, 0x201d: + return '"' + case 0x2010 ..= 0x2015, 0x2212: + return '-' + case 0x2026: + return '.' + case 0x2022, 0x25cf, 0x25cb, 0x25c6, 0x25c7, 0x2b24: + return '*' + case 0x2190: + return '<' + case 0x2192: + return '>' + case 0x2191, 0x2193: + return '|' + case 0x2713, 0x2714: + return 'v' + case 0x2717, 0x2718: + return 'x' + case 0x2800: + return ' ' + case 0x2801 ..= 0x28ff: + // Braille patterns (spinners); the bundled font has no braille. + return '*' + } + // Box drawing: anything with vertical-only strokes becomes '|', pure + // horizontals '-', and joints/corners '+'; shaded blocks become '#'. + switch r { + case 0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2574, 0x2576, 0x2578, 0x257a, 0x2550: + return '-' + case 0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f, 0x2575, 0x2577, 0x2579, 0x257b, 0x2551: + return '|' + case 0x250c ..= 0x254b, 0x2552 ..= 0x256c, 0x256d ..= 0x2570: + return '+' + case 0x2571 ..= 0x2573: + return '/' + case 0x2580 ..= 0x259f, 0x25a0 ..= 0x25ab: + return '#' + } + return '?' +} + +terminal_csi_params :: proc(term: ^Terminal_Panel) -> ([dynamic]int, bool) { + params := make([dynamic]int, context.temp_allocator) + private := false + value := 0 + has_value := false + for b in term.csi { + switch { + case b >= '0' && b <= '9': + value = value * 10 + int(b - '0') + has_value = true + case b == ';' || b == ':': + append(¶ms, value if has_value else 0) + value = 0 + has_value = false + case b == '?': + private = true + } + } + if has_value || len(params) > 0 { + append(¶ms, value if has_value else 0) + } + return params, private +} + +terminal_csi_param :: proc(params: [dynamic]int, index, default_value: int) -> int { + if index >= len(params) || params[index] == 0 do return default_value + return params[index] +} + +terminal_csi_dispatch :: proc(term: ^Terminal_Panel, final: u8) { + params, private := terminal_csi_params(term) + + if private { + enable := final == 'h' + if final != 'h' && final != 'l' do return + for mode in params { + switch mode { + case 1: + term.app_cursor_keys = enable + case 7: + term.autowrap = enable + case 25: + term.cursor_visible = enable + case 47, 1047, 1049: + if enable && !term.alt_active { + term.saved_row = term.cursor_row + term.saved_col = term.cursor_col + term.alt_active = true + blank := Term_Cell{ch = ' ', fg = TERM_DEFAULT_COLOR, bg = TERM_DEFAULT_COLOR} + for i in 0 ..< len(term.alt_cells) { + term.alt_cells[i] = blank + } + term.cursor_row = 0 + term.cursor_col = 0 + term.scroll_top = 0 + term.scroll_bot = term.rows - 1 + term.scroll_view = 0 + } else if !enable && term.alt_active { + term.alt_active = false + term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1) + term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1) + term.scroll_top = 0 + term.scroll_bot = term.rows - 1 + } + } + } + return + } + + switch final { + case 'A': + term.cursor_row = max_int(term.cursor_row - terminal_csi_param(params, 0, 1), 0) + case 'B': + term.cursor_row = min_int(term.cursor_row + terminal_csi_param(params, 0, 1), term.rows - 1) + case 'C': + term.cursor_col = min_int(term.cursor_col + terminal_csi_param(params, 0, 1), term.cols - 1) + case 'D': + term.cursor_col = max_int(term.cursor_col - terminal_csi_param(params, 0, 1), 0) + case 'E': + term.cursor_col = 0 + term.cursor_row = min_int(term.cursor_row + terminal_csi_param(params, 0, 1), term.rows - 1) + case 'F': + term.cursor_col = 0 + term.cursor_row = max_int(term.cursor_row - terminal_csi_param(params, 0, 1), 0) + case 'G', '`': + term.cursor_col = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.cols - 1) + case 'd': + term.cursor_row = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1) + case 'H', 'f': + term.cursor_row = clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1) + term.cursor_col = clamp_int(terminal_csi_param(params, 1, 1) - 1, 0, term.cols - 1) + case 'J': + mode := 0 + if len(params) > 0 do mode = params[0] + switch mode { + case 0: + terminal_erase_cells(term, term.cursor_row, term.cursor_col, term.cols) + for r in term.cursor_row + 1 ..< term.rows { + terminal_erase_cells(term, r, 0, term.cols) + } + case 1: + terminal_erase_cells(term, term.cursor_row, 0, term.cursor_col + 1) + for r in 0 ..< term.cursor_row { + terminal_erase_cells(term, r, 0, term.cols) + } + case 2, 3: + for r in 0 ..< term.rows { + terminal_erase_cells(term, r, 0, term.cols) + } + } + case 'K': + mode := 0 + if len(params) > 0 do mode = params[0] + switch mode { + case 0: + terminal_erase_cells(term, term.cursor_row, term.cursor_col, term.cols) + case 1: + terminal_erase_cells(term, term.cursor_row, 0, term.cursor_col + 1) + case 2: + terminal_erase_cells(term, term.cursor_row, 0, term.cols) + } + case 'L': + count := terminal_csi_param(params, 0, 1) + if term.cursor_row >= term.scroll_top && term.cursor_row <= term.scroll_bot { + saved_top := term.scroll_top + term.scroll_top = term.cursor_row + for _ in 0 ..< count { + terminal_scroll_down(term) + } + term.scroll_top = saved_top + } + case 'M': + count := terminal_csi_param(params, 0, 1) + if term.cursor_row >= term.scroll_top && term.cursor_row <= term.scroll_bot { + saved_top := term.scroll_top + term.scroll_top = term.cursor_row + for _ in 0 ..< count { + terminal_scroll_up(term) + } + term.scroll_top = saved_top + } + case 'P': + count := min_int(terminal_csi_param(params, 0, 1), term.cols - term.cursor_col) + for c in term.cursor_col ..< term.cols { + source := c + count + if source < term.cols { + terminal_cell_at(term, term.cursor_row, c)^ = terminal_cell_at(term, term.cursor_row, source)^ + } else { + terminal_cell_at(term, term.cursor_row, c)^ = terminal_blank_cell(term) + } + } + case '@': + count := min_int(terminal_csi_param(params, 0, 1), term.cols - term.cursor_col) + for c := term.cols - 1; c >= term.cursor_col + count; c -= 1 { + terminal_cell_at(term, term.cursor_row, c)^ = terminal_cell_at(term, term.cursor_row, c - count)^ + } + terminal_erase_cells(term, term.cursor_row, term.cursor_col, min_int(term.cursor_col + count, term.cols)) + case 'X': + count := terminal_csi_param(params, 0, 1) + terminal_erase_cells(term, term.cursor_row, term.cursor_col, min_int(term.cursor_col + count, term.cols)) + case 'S': + for _ in 0 ..< terminal_csi_param(params, 0, 1) { + terminal_scroll_up(term) + } + case 'T': + for _ in 0 ..< terminal_csi_param(params, 0, 1) { + terminal_scroll_down(term) + } + case 'r': + top := clamp_int(terminal_csi_param(params, 0, 1) - 1, 0, term.rows - 1) + bottom := clamp_int(terminal_csi_param(params, 1, term.rows) - 1, 0, term.rows - 1) + if top < bottom { + term.scroll_top = top + term.scroll_bot = bottom + term.cursor_row = 0 + term.cursor_col = 0 + } + case 'm': + terminal_apply_sgr(term, params) + case 's': + term.saved_row = term.cursor_row + term.saved_col = term.cursor_col + case 'u': + term.cursor_row = clamp_int(term.saved_row, 0, term.rows - 1) + term.cursor_col = clamp_int(term.saved_col, 0, term.cols - 1) + case 'n': + // Device status reports: TUIs block waiting for these. + request := 0 + if len(params) > 0 do request = params[0] + if request == 5 { + terminal_send(term, "\x1b[0n") + } else if request == 6 { + terminal_send(term, fmt.tprintf("\x1b[%d;%dR", term.cursor_row + 1, term.cursor_col + 1)) + } + case 'c': + if len(term.csi) > 0 && term.csi[0] == '>' { + terminal_send(term, "\x1b[>0;0;0c") + } else { + terminal_send(term, "\x1b[?6c") + } + } +} + +terminal_apply_sgr :: proc(term: ^Terminal_Panel, params: [dynamic]int) { + if len(params) == 0 { + term.fg = TERM_DEFAULT_COLOR + term.bg = TERM_DEFAULT_COLOR + term.bold = false + term.reverse = false + return + } + + index := 0 + for index < len(params) { + p := params[index] + switch { + case p == 0: + term.fg = TERM_DEFAULT_COLOR + term.bg = TERM_DEFAULT_COLOR + term.bold = false + term.reverse = false + case p == 1: + term.bold = true + case p == 22: + term.bold = false + case p == 7: + term.reverse = true + case p == 27: + term.reverse = false + case p >= 30 && p <= 37: + term.fg = u8(p - 30) + case p == 39: + term.fg = TERM_DEFAULT_COLOR + case p >= 90 && p <= 97: + term.fg = u8(p - 90 + 8) + case p >= 40 && p <= 47: + term.bg = u8(p - 40) + case p == 49: + term.bg = TERM_DEFAULT_COLOR + case p >= 100 && p <= 107: + term.bg = u8(p - 100 + 8) + case p == 38 || p == 48: + color := TERM_DEFAULT_COLOR + if index + 1 < len(params) && params[index + 1] == 5 && index + 2 < len(params) { + color = clamp_int(params[index + 2], 0, 255) + index += 2 + } else if index + 1 < len(params) && params[index + 1] == 2 && index + 4 < len(params) { + color = terminal_nearest_256(params[index + 2], params[index + 3], params[index + 4]) + index += 4 + } + if p == 38 { + term.fg = u8(color) + } else { + term.bg = u8(color) + } + } + index += 1 + } +} + +terminal_nearest_256 :: proc(r, g, b: int) -> int { + scale :: proc(v: int) -> int { + return clamp_int((v * 5 + 127) / 255, 0, 5) + } + return 16 + 36 * scale(r) + 6 * scale(g) + scale(b) +} + +// --------------------------------------------------------------------------- +// Input + +// Translates control keys for the pty. Printable characters arrive through +// TEXT_INPUT instead. Returns true when the key was consumed. +terminal_handle_key :: proc(term: ^Terminal_Panel, key: SDL.Keycode, mod: SDL.Keymod) -> bool { + if !term.open || !term.focused do return false + + if key == SDL.K_ESCAPE { + term.focused = false + return true + } + + ctrl := (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE + if ctrl { + if key >= SDL.K_A && key <= SDL.K_Z { + buf := [1]u8{u8(key - SDL.K_A) + 1} + terminal_send(term, string(buf[:])) + return true + } + return false + } + + switch key { + case SDL.K_RETURN, SDL.K_KP_ENTER: + terminal_send(term, "\r") + case SDL.K_BACKSPACE: + terminal_send(term, "\x7f") + case SDL.K_TAB: + terminal_send(term, "\t") + case SDL.K_UP: + terminal_send(term, "\x1bOA" if term.app_cursor_keys else "\x1b[A") + case SDL.K_DOWN: + terminal_send(term, "\x1bOB" if term.app_cursor_keys else "\x1b[B") + case SDL.K_RIGHT: + terminal_send(term, "\x1bOC" if term.app_cursor_keys else "\x1b[C") + case SDL.K_LEFT: + terminal_send(term, "\x1bOD" if term.app_cursor_keys else "\x1b[D") + case SDL.K_HOME: + terminal_send(term, "\x1bOH" if term.app_cursor_keys else "\x1b[H") + case SDL.K_END: + terminal_send(term, "\x1bOF" if term.app_cursor_keys else "\x1b[F") + case SDL.K_DELETE: + terminal_send(term, "\x1b[3~") + case SDL.K_PAGEUP: + terminal_send(term, "\x1b[5~") + case SDL.K_PAGEDOWN: + terminal_send(term, "\x1b[6~") + case: + // Not a terminal key; TEXT_INPUT will deliver printable characters. + } + return true +} + +// --------------------------------------------------------------------------- +// Layout, view scrolling, rendering + +// The panel spans the editor area: from the file tree to the Gradle sidebar +// (or the tool strip when the sidebar is closed). +terminal_panel_geometry :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, window_width, window_height: int) -> (x, y, width, height: f32) { + right := content_right_edge(window_width) + if tasks_panel != nil && tasks_panel.open { + right -= right_sidebar_width_view(view, window_width) + } + x = f32(left_sidebar_width(view)) + width = f32(right) - x + height = f32(bottom_panel_height(view)) + y = f32(window_height - SDL_STATUS_BAR_HEIGHT) - height + return +} + +terminal_panel_hit :: proc(term: ^Terminal_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32) -> bool { + if !term.open do return false + panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height) + return x >= panel_x && x < panel_x + width && y >= panel_y && y < panel_y + height +} + +handle_terminal_wheel :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, x, y: f32, wheel_y: int) -> bool { + if !terminal_panel_hit(term, view, tasks_panel, x, y) do return false + + if term.active_tab == .Git { + panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height) + if x < panel_x + git_panel_list_width(width) { + git.list_scroll = clamp_int(git.list_scroll - wheel_y * 3, 0, max_int(len(git.commits) - 1, 0)) + return true + } + layout := git_detail_layout(git, panel_y + SDL_TERMINAL_HEADER, height - SDL_TERMINAL_HEADER) + git.file_scroll = clamp_int(git.file_scroll - wheel_y * 3, 0, max_int(len(git.files) - layout.file_rows, 0)) + return true + } + + if term.alt_active { + // Full-screen apps get the wheel as arrow keys, like most emulators. + arrow := ("\x1bOA" if term.app_cursor_keys else "\x1b[A") if wheel_y > 0 else ("\x1bOB" if term.app_cursor_keys else "\x1b[B") + for _ in 0 ..< 3 { + terminal_send(term, arrow) + } + return true + } + term.scroll_view = clamp_int(term.scroll_view + wheel_y * 3, 0, len(term.scrollback)) + return true +} + +// Theme palette: xterm 16 colors tuned slightly toward the editor theme, +// plus the standard 256-color cube and grayscale ramp. +terminal_palette :: proc(index: u8) -> (u8, u8, u8) { + if index < 16 { + @(static, rodata) + base := [16][3]u8{ + {40, 42, 48}, {224, 108, 117}, {152, 195, 121}, {229, 192, 123}, + {97, 175, 239}, {198, 120, 221}, {86, 182, 194}, {200, 205, 215}, + {110, 115, 126}, {236, 130, 138}, {170, 216, 140}, {240, 208, 144}, + {120, 190, 250}, {215, 145, 235}, {105, 200, 212}, {235, 238, 245}, + } + color := base[index] + return color[0], color[1], color[2] + } + if index < 232 { + value := int(index) - 16 + levels := [6]u8{0, 95, 135, 175, 215, 255} + return levels[value / 36], levels[(value / 6) % 6], levels[value % 6] + } + gray := u8(8 + (int(index) - 232) * 10) + return gray, gray, gray +} + +terminal_visible_rows :: proc(view: ^SDL_View) -> int { + return max_int((bottom_panel_height(view) - SDL_TERMINAL_HEADER - 8) / SDL_TERMINAL_LINE_HEIGHT, 1) +} + +// Header tab strip: shared geometry for rendering and click handling. +bottom_tab_rect :: proc(panel_x, panel_y: f32, tab: Bottom_Tab) -> (x, y, width, height: f32) { + x = panel_x + 10 + for t in Bottom_Tab { + width = f32(gpu_text_width(bottom_tab_label(t))) + 20 + if t == tab do break + x += width + 4 + } + y = panel_y + 2 + height = SDL_TERMINAL_HEADER - 4 + return +} + +// Handles all clicks inside the bottom panel: tab switching, terminal focus, +// and commit selection on the Git tab. +handle_bottom_panel_click :: proc(term: ^Terminal_Panel, git: ^Git_Panel, view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, workspace: string, x, y: f32) -> bool { + if !terminal_panel_hit(term, view, tasks_panel, x, y) do return false + panel_x, panel_y, width, height := terminal_panel_geometry(view, tasks_panel, view.window_width, view.window_height) + + if y < panel_y + SDL_TERMINAL_HEADER { + for tab in Bottom_Tab { + tab_x, tab_y, tab_width, tab_height := bottom_tab_rect(panel_x, panel_y, tab) + if x >= tab_x && x < tab_x + tab_width && y >= tab_y && y < tab_y + tab_height { + term.active_tab = tab + if tab == .Git { + term.focused = false + git_panel_activate(git, workspace) + } else { + term.focused = true + _ = terminal_start(term, workspace) + } + break + } + } + return true + } + + if term.active_tab == .Terminal { + term.focused = true + return true + } + + // Git tab: list column selects a commit; detail pane selects a file. + term.focused = false + body_y := panel_y + SDL_TERMINAL_HEADER + if x < panel_x + git_panel_list_width(width) { + row := int((y - body_y - 4) / GIT_PANEL_ROW_HEIGHT) + index := git.list_scroll + row + if row >= 0 && index >= 0 && index < len(git.commits) { + git.selected = index + git_panel_request_show(git, workspace, git.commits[index].hash) + } + return true + } + layout := git_detail_layout(git, body_y, height - SDL_TERMINAL_HEADER) + if y >= layout.files_y && y < layout.files_y + f32(layout.file_rows) * GIT_PANEL_ROW_HEIGHT { + row := int((y - layout.files_y) / GIT_PANEL_ROW_HEIGHT) + index := git.file_scroll + row + if index >= 0 && index < len(git.files) { + git.file_selected = index + git_panel_request_diff(git, workspace, git.detail_hash, git.files[index].path) + } + } + return true +} + +render_terminal_panel_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, term: ^Terminal_Panel, git: ^Git_Panel, tasks_panel: ^Gradle_Tasks_Panel) { + if !term.open do return + terminal_ensure_grid(term) + + x, y, width, height := terminal_panel_geometry(view, tasks_panel, gpu.width, gpu.height) + gpu_rect(gpu, x, y, width, height, 24, 25, 29, 255) + if term.focused && term.active_tab == .Terminal { + gpu_rect(gpu, x, y, width, 1, 77, 155, 230, 255) + } else { + gpu_rect(gpu, x, y, width, 1, 56, 58, 64, 255) + } + + // Tab strip. + status_x := x + for tab in Bottom_Tab { + tab_x, tab_y, tab_width, tab_height := bottom_tab_rect(x, y, tab) + active := term.active_tab == tab + if active { + gpu_rect(gpu, tab_x, tab_y, tab_width, tab_height, 35, 37, 44, 255) + gpu_rect(gpu, tab_x, y + SDL_TERMINAL_HEADER - 3, tab_width, 2, 77, 155, 230, 255) + gpu_text(gpu, tab_x + 10, tab_y + 5, bottom_tab_label(tab), 220, 223, 228, 255) + } else { + gpu_text(gpu, tab_x + 10, tab_y + 5, bottom_tab_label(tab), 150, 154, 162, 255) + } + status_x = tab_x + tab_width + 16 + } + if term.active_tab == .Terminal { + if !term.running { + gpu_text(gpu, status_x, y + 7, "(shell exited)", 170, 120, 120, 255) + } else if term.scroll_view > 0 { + gpu_text(gpu, status_x, y + 7, fmt.tprintf("(scrolled %d)", term.scroll_view), 150, 154, 162, 255) + } + } + + // Match the grid and pty size to the panel before drawing, so the shell + // reflows even while the Git tab is showing. + advance := max_f32(gpu.font_advance, 1) + rows := terminal_visible_rows(view) + cols := max_int(int((width - 24) / advance), 8) + terminal_resize(term, rows, cols) + + if term.active_tab == .Git { + render_git_panel_body(gpu, view, git, x, y + SDL_TERMINAL_HEADER, width, height - SDL_TERMINAL_HEADER) + return + } + + if term.alt_active do term.scroll_view = 0 + term.scroll_view = clamp_int(term.scroll_view, 0, len(term.scrollback)) + + text_x := x + 12 + line_y := y + SDL_TERMINAL_HEADER + // The view window ends scroll_view lines above the live screen bottom. + top := len(term.scrollback) - term.scroll_view + for r in 0 ..< rows { + global := top + r + row_cells: []Term_Cell + if global < len(term.scrollback) { + row_cells = term.scrollback[global][:] + } else { + screen_row := global - len(term.scrollback) + if screen_row >= rows do break + screen := terminal_screen(term) + row_cells = screen[screen_row * cols : (screen_row + 1) * cols] + } + + // Background runs first, then text runs grouped by color. + run_start := 0 + for c := 0; c <= len(row_cells); c += 1 { + flush := c == len(row_cells) + if !flush && row_cells[c].bg == row_cells[run_start].bg do continue + bg := row_cells[run_start].bg + if bg != TERM_DEFAULT_COLOR { + red, green, blue := terminal_palette(bg) + gpu_rect(gpu, text_x + f32(run_start) * advance, line_y - 1, f32(c - run_start) * advance, SDL_TERMINAL_LINE_HEIGHT, red, green, blue, 255) + } + run_start = c + } + for c in 0 ..< len(row_cells) { + cell := row_cells[c] + if cell.ch == ' ' || cell.ch == 0 do continue + red, green, blue := u8(210), u8(215), u8(224) + if cell.fg != TERM_DEFAULT_COLOR { + red, green, blue = terminal_palette(cell.fg) + } + cell_x := text_x + f32(c) * advance + if !gpu_rune(gpu, cell_x, line_y, cell.ch, red, green, blue, 255) { + fallback := [1]u8{terminal_ascii_for_rune(cell.ch)} + gpu_text(gpu, cell_x, line_y, string(fallback[:]), red, green, blue, 255) + } + } + + // Cursor block on the live screen. + if term.focused && term.cursor_visible && term.scroll_view == 0 && global - len(term.scrollback) == term.cursor_row { + cursor_x := text_x + f32(min_int(term.cursor_col, cols - 1)) * advance + gpu_rect(gpu, cursor_x, line_y - 1, advance, SDL_TERMINAL_LINE_HEIGHT, 145, 180, 235, 170) + } + line_y += SDL_TERMINAL_LINE_HEIGHT + } +} + +// --------------------------------------------------------------------------- +// Self-test: `editor --terminal-selftest` runs the emulator against known +// sequences without a pty and reports mismatches. + +terminal_selftest_row :: proc(term: ^Terminal_Panel, row: int) -> string { + builder: strings.Builder + strings.builder_init(&builder, context.temp_allocator) + for c in 0 ..< term.cols { + strings.write_rune(&builder, terminal_cell_at(term, row, c).ch) + } + return strings.trim_right(strings.to_string(builder), " ") +} + +terminal_selftest_feed :: proc(term: ^Terminal_Panel, bytes: string) { + for b in transmute([]u8)bytes { + terminal_consume_byte(term, b) + } +} + +terminal_selftest :: proc() -> bool { + term := Terminal_Panel{} + defer terminal_destroy(&term) + terminal_ensure_grid(&term) + ok := true + + check :: proc(ok: ^bool, name: string, condition: bool) { + if condition { + fmt.printf("ok %s\n", name) + } else { + fmt.printf("FAIL %s\n", name) + ok^ = false + } + } + + terminal_selftest_feed(&term, "hello\r\nworld") + check(&ok, "plain text", terminal_selftest_row(&term, 0) == "hello" && terminal_selftest_row(&term, 1) == "world") + check(&ok, "cursor after text", term.cursor_row == 1 && term.cursor_col == 5) + + terminal_selftest_feed(&term, "\x1b[2J\x1b[H") + check(&ok, "clear+home", terminal_selftest_row(&term, 0) == "" && term.cursor_row == 0 && term.cursor_col == 0) + + terminal_selftest_feed(&term, "\x1b[3;5Habc") + check(&ok, "cursor addressing", terminal_selftest_row(&term, 2) == " abc") + + terminal_selftest_feed(&term, "\x1b[31mR\x1b[0m") + check(&ok, "sgr color", terminal_cell_at(&term, 2, 7).ch == 'R' && terminal_cell_at(&term, 2, 7).fg == 1 && term.fg == TERM_DEFAULT_COLOR) + + terminal_selftest_feed(&term, "\x1b[38;5;208mX") + check(&ok, "sgr 256", terminal_cell_at(&term, 2, 8).fg == 208) + + terminal_selftest_feed(&term, "\x1b[3;1H\x1b[K") + check(&ok, "erase line", terminal_selftest_row(&term, 2) == "") + + terminal_selftest_feed(&term, "\x1b[Hline1\r\nline2") + terminal_selftest_feed(&term, "\x1b[?1049halt-screen") + check(&ok, "alt screen", term.alt_active && terminal_selftest_row(&term, 0) == "alt-screen") + terminal_selftest_feed(&term, "\x1b[?1049l") + check(&ok, "primary restored", !term.alt_active && terminal_selftest_row(&term, 0) == "line1" && terminal_selftest_row(&term, 1) == "line2") + + terminal_selftest_feed(&term, "\x1b[2J\x1b[H") + for i in 0 ..< term.rows { + terminal_selftest_feed(&term, fmt.tprintf("row%d", i)) + if i < term.rows - 1 do terminal_selftest_feed(&term, "\r\n") + } + terminal_selftest_feed(&term, "\r\nextra") + check(&ok, "scroll into scrollback", len(term.scrollback) > 0 && terminal_selftest_row(&term, term.rows - 1) == "extra") + top_line := term.scrollback[len(term.scrollback) - 1] + check(&ok, "scrollback content", top_line[0].ch == 'r' && top_line[1].ch == 'o' && top_line[2].ch == 'w' && top_line[3].ch == '0') + + terminal_selftest_feed(&term, "\x1b[2J\x1b[H\xe2\x94\x80\xe2\x94\x82\xe2\x94\x8c") + check(&ok, "utf8 runes stored", terminal_selftest_row(&term, 0) == "\u2500\u2502\u250c") + + terminal_selftest_feed(&term, "\x1b]0;window title\x07after") + check(&ok, "osc ignored", strings.has_suffix(terminal_selftest_row(&term, 0), "after")) + + terminal_selftest_feed(&term, "\x1b[2J\x1b[Habcdef\x1b[1;3H\x1b[2P") + check(&ok, "delete chars", terminal_selftest_row(&term, 0) == "abef") + + terminal_selftest_feed(&term, "\x1b[2J\x1b[HAB\x1b[1;1H\x1b[2@") + check(&ok, "insert chars", terminal_selftest_row(&term, 0) == " AB") + + fmt.println(ok ? "terminal selftest: PASS" : "terminal selftest: FAIL") + return ok +} diff --git a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt index 84ad71b..dc238cf 100644 --- a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt +++ b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt @@ -102,6 +102,7 @@ private data class GradleTaskInfo( val path: String, val name: String, val description: String?, + val group: String?, ) private class ClientSession( @@ -601,6 +602,7 @@ private fun collectTasks(project: GradleProject): List { path = task.path, name = task.name, description = task.description, + group = runCatching { task.group }.getOrNull(), ) } current.children.forEach(::visit) @@ -1107,6 +1109,7 @@ private fun tasksJson(tasks: List): JsonElement = buildJsonArray put("path", task.path) put("name", task.name) put("description", task.description) + put("group", task.group) }) } }