bl
This commit is contained in:
parent
41adeeb47f
commit
5b65c4c0e4
11 changed files with 4237 additions and 297 deletions
20
README.md
20
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.
|
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
|
```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:
|
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:
|
||||||
|
|
||||||
Auto-started daemons are restarted automatically if the process exits or the TCP connection drops. Explicit-port mode does not restart external daemons.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
odin run client/odin -- --sdl /path/to/workspace <port> /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 <port> /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 -- <port>`) 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.
|
SDL mode shows a simple workspace file tree in the left sidebar. Click a file to open or focus it.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,16 +111,49 @@ daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) {
|
||||||
return child, port, true
|
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) {
|
daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) {
|
||||||
child := Daemon_Process{}
|
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()
|
stdout_r, stdout_w, pipe_err := os.pipe()
|
||||||
if pipe_err != nil {
|
if pipe_err != nil {
|
||||||
fmt.println("daemon pipe failed:", pipe_err)
|
fmt.println("daemon pipe failed:", pipe_err)
|
||||||
return child, false
|
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{
|
process, start_err := os.process_start(os.Process_Desc{
|
||||||
command = command,
|
command = command,
|
||||||
stdout = stdout_w,
|
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) {
|
daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) {
|
||||||
active := editor_active_buffer(editor)
|
active := editor_active_buffer(editor)
|
||||||
if active == nil do return
|
if active == nil || active.scratch do return
|
||||||
|
|
||||||
text := editor_active_text(editor)
|
text := editor_active_text(editor)
|
||||||
defer delete(text)
|
defer delete(text)
|
||||||
|
|
|
||||||
267
client/odin/diff_view.odin
Normal file
267
client/odin/diff_view.odin
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ Diagnostic :: struct {
|
||||||
Editor_Buffer :: struct {
|
Editor_Buffer :: struct {
|
||||||
path: string,
|
path: string,
|
||||||
daemon_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,
|
original_storage: []u8,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
cursor: Cursor,
|
cursor: Cursor,
|
||||||
|
|
@ -60,6 +62,36 @@ editor_open_or_focus_file :: proc(editor: ^Editor, path: string) -> bool {
|
||||||
return editor_open_file(editor, path)
|
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 {
|
editor_replace_active_file :: proc(editor: ^Editor, path: string) -> bool {
|
||||||
active := editor_active_buffer(editor)
|
active := editor_active_buffer(editor)
|
||||||
if active == nil do return false
|
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 {
|
editor_save_active :: proc(editor: ^Editor) -> bool {
|
||||||
active := editor_active_buffer(editor)
|
active := editor_active_buffer(editor)
|
||||||
if active == nil do return false
|
if active == nil || active.scratch do return false
|
||||||
|
|
||||||
text := buffer_bytes(&active.buffer)
|
text := buffer_bytes(&active.buffer)
|
||||||
defer delete(text)
|
defer delete(text)
|
||||||
|
|
@ -276,7 +308,7 @@ editor_save_active :: proc(editor: ^Editor) -> bool {
|
||||||
editor_save_all :: proc(editor: ^Editor) -> bool {
|
editor_save_all :: proc(editor: ^Editor) -> bool {
|
||||||
ok := true
|
ok := true
|
||||||
for &buffer in editor.buffers {
|
for &buffer in editor.buffers {
|
||||||
if !buffer.dirty do continue
|
if !buffer.dirty || buffer.scratch do continue
|
||||||
|
|
||||||
text := buffer_bytes(&buffer.buffer)
|
text := buffer_bytes(&buffer.buffer)
|
||||||
err := os.write_entire_file(buffer.path, text[:])
|
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) {
|
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.path)
|
||||||
delete(editor_buffer.daemon_path)
|
delete(editor_buffer.daemon_path)
|
||||||
buffer_destroy(&editor_buffer.buffer)
|
buffer_destroy(&editor_buffer.buffer)
|
||||||
|
|
|
||||||
592
client/odin/git_panel.odin
Normal file
592
client/odin/git_panel.odin
Normal file
|
|
@ -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 "old<TAB>new"; 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: "<basename> @ <hash>".
|
||||||
|
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
|
||||||
|
}
|
||||||
471
client/odin/git_status.odin
Normal file
471
client/odin/git_status.odin
Normal file
|
|
@ -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 <dir>` 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
|
||||||
|
}
|
||||||
|
|
@ -37,16 +37,32 @@ GPU_Renderer :: struct {
|
||||||
text_transfer_buffer: ^SDL.GPUTransferBuffer,
|
text_transfer_buffer: ^SDL.GPUTransferBuffer,
|
||||||
font_texture: ^SDL.GPUTexture,
|
font_texture: ^SDL.GPUTexture,
|
||||||
font_sampler: ^SDL.GPUSampler,
|
font_sampler: ^SDL.GPUSampler,
|
||||||
font_chars: [95]stbtt.bakedchar,
|
font_chars: [95]stbtt.packedchar,
|
||||||
|
font_extra: map[rune]stbtt.packedchar,
|
||||||
font_advance: f32,
|
font_advance: f32,
|
||||||
vertices: [dynamic]GPU_Vertex,
|
vertices: [dynamic]GPU_Vertex,
|
||||||
text_vertices: [dynamic]GPU_Text_Vertex,
|
text_vertices: [dynamic]GPU_Text_Vertex,
|
||||||
|
batches: [dynamic]GPU_Batch,
|
||||||
max_vertices: int,
|
max_vertices: int,
|
||||||
max_text_vertices: int,
|
max_text_vertices: int,
|
||||||
width: int,
|
width: int,
|
||||||
height: 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
|
// 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
|
// finding shader files at runtime. Regenerate with scripts/compile-shaders.sh
|
||||||
// after editing the GLSL sources, then rebuild.
|
// 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_VERTICES :: 240000
|
||||||
GPU_MAX_TEXT_VERTICES :: 120000
|
GPU_MAX_TEXT_VERTICES :: 120000
|
||||||
GPU_FONT_ATLAS_SIZE :: 512
|
GPU_FONT_ATLAS_SIZE :: 1024
|
||||||
GPU_FONT_PIXEL_HEIGHT :: 15.0
|
GPU_FONT_PIXEL_HEIGHT :: 15.0
|
||||||
GPU_FONT_BASELINE_OFFSET :: 11.0
|
GPU_FONT_BASELINE_OFFSET :: 11.0
|
||||||
GPU_FONT_CELL_PADDING :: 1.0
|
GPU_FONT_CELL_PADDING :: 1.0
|
||||||
|
|
@ -226,6 +242,8 @@ gpu_renderer_destroy :: proc(gpu: ^GPU_Renderer) {
|
||||||
SDL.DestroyGPUDevice(gpu.device)
|
SDL.DestroyGPUDevice(gpu.device)
|
||||||
}
|
}
|
||||||
delete(gpu.vertices)
|
delete(gpu.vertices)
|
||||||
|
delete(gpu.batches)
|
||||||
|
delete(gpu.font_extra)
|
||||||
delete(gpu.text_vertices)
|
delete(gpu.text_vertices)
|
||||||
gpu^ = {}
|
gpu^ = {}
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +267,7 @@ gpu_create_shader :: proc(device: ^SDL.GPUDevice, name: string, code: []u8, stag
|
||||||
gpu_begin :: proc(gpu: ^GPU_Renderer) {
|
gpu_begin :: proc(gpu: ^GPU_Renderer) {
|
||||||
clear(&gpu.vertices)
|
clear(&gpu.vertices)
|
||||||
clear(&gpu.text_vertices)
|
clear(&gpu.text_vertices)
|
||||||
|
clear(&gpu.batches)
|
||||||
w, h: i32
|
w, h: i32
|
||||||
if SDL.GetWindowSize(gpu.window, &w, &h) {
|
if SDL.GetWindowSize(gpu.window, &w, &h) {
|
||||||
gpu.width = int(w)
|
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 {
|
gpu_text_width :: proc(text: string) -> int {
|
||||||
if len(text) == 0 do return 0
|
if len(text) == 0 do return 0
|
||||||
width: f32 = 0
|
width: f32 = 0
|
||||||
|
|
@ -415,21 +463,27 @@ gpu_present :: proc(gpu: ^GPU_Renderer) {
|
||||||
store_op = .STORE,
|
store_op = .STORE,
|
||||||
}
|
}
|
||||||
pass := SDL.BeginGPURenderPass(command, &target, 1, nil)
|
pass := SDL.BeginGPURenderPass(command, &target, 1, nil)
|
||||||
if vertex_count > 0 {
|
|
||||||
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
|
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))
|
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
|
||||||
|
switch batch.kind {
|
||||||
|
case .Rect:
|
||||||
SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline)
|
SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline)
|
||||||
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1)
|
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1)
|
||||||
SDL.DrawGPUPrimitives(pass, u32(vertex_count), 1, 0, 0)
|
case .Text:
|
||||||
}
|
|
||||||
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}
|
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.BindGPUGraphicsPipeline(pass, gpu.text_pipeline)
|
||||||
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1)
|
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1)
|
||||||
SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1)
|
SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1)
|
||||||
SDL.DrawGPUPrimitives(pass, u32(text_vertex_count), 1, 0, 0)
|
}
|
||||||
|
bound = true
|
||||||
|
bound_kind = batch.kind
|
||||||
|
}
|
||||||
|
SDL.DrawGPUPrimitives(pass, u32(batch.count), 1, u32(batch.first), 0)
|
||||||
}
|
}
|
||||||
SDL.EndGPURenderPass(pass)
|
SDL.EndGPURenderPass(pass)
|
||||||
_ = SDL.SubmitGPUCommandBuffer(command)
|
_ = SDL.SubmitGPUCommandBuffer(command)
|
||||||
|
|
@ -443,11 +497,68 @@ gpu_create_font_atlas :: proc(gpu: ^GPU_Renderer) -> bool {
|
||||||
atlas := make([]u8, atlas_size)
|
atlas := make([]u8, atlas_size)
|
||||||
defer delete(atlas)
|
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])
|
// Extra ranges cover what terminal programs commonly draw: Latin-1,
|
||||||
if baked <= 0 {
|
// general punctuation, arrows, box drawing/blocks/geometric shapes,
|
||||||
fmt.println("font bake failed:", font_path)
|
// 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
|
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
|
widest_advance: f32 = 0
|
||||||
for char in gpu.font_chars {
|
for char in gpu.font_chars {
|
||||||
glyph_width := char.xoff + f32(char.x1 - char.x0)
|
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
|
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) {
|
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
|
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{{x0, y0}, c})
|
||||||
append(&gpu.vertices, GPU_Vertex{{x1, y0}, c})
|
append(&gpu.vertices, GPU_Vertex{{x1, y0}, c})
|
||||||
append(&gpu.vertices, GPU_Vertex{{x0, y1}, c})
|
append(&gpu.vertices, GPU_Vertex{{x0, y1}, c})
|
||||||
|
|
@ -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) {
|
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
|
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{{x0, y0}, {s0, t0}, c})
|
||||||
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y0}, {s1, t0}, c})
|
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y0}, {s1, t0}, c})
|
||||||
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y1}, {s0, t1}, c})
|
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y1}, {s0, t1}, c})
|
||||||
|
|
|
||||||
|
|
@ -7,45 +7,69 @@ import "core:strconv"
|
||||||
|
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
args := os.args
|
args := os.args
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.println("usage: odin run client/odin -- <port> [workspace]")
|
if len(args) >= 2 && args[1] == "--terminal-selftest" {
|
||||||
fmt.println(" or: odin run client/odin -- --sdl [workspace] [port] [file]")
|
if !terminal_selftest() {
|
||||||
|
os.exit(1)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if args[1] == "--sdl" {
|
if len(args) >= 3 && args[1] == "--git-status-selftest" {
|
||||||
|
if !git_status_selftest(args[2]) {
|
||||||
|
os.exit(1)
|
||||||
|
}
|
||||||
|
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 := "."
|
workspace := "."
|
||||||
if len(args) >= 3 {
|
if len(args) >= 3 {
|
||||||
workspace = args[2]
|
workspace = args[2]
|
||||||
}
|
}
|
||||||
|
run_smoke_client(int(port), workspace)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
port := 0
|
||||||
file := ""
|
file := ""
|
||||||
if len(args) >= 4 {
|
for arg in sdl_args {
|
||||||
parsed_port, parsed_ok := strconv.parse_int(args[3])
|
if parsed_port, is_port := strconv.parse_int(arg); is_port && port == 0 {
|
||||||
if parsed_ok {
|
|
||||||
port = int(parsed_port)
|
port = int(parsed_port)
|
||||||
|
} else if os.is_dir(arg) && len(workspace) == 0 {
|
||||||
|
workspace = arg
|
||||||
|
} else if len(file) == 0 {
|
||||||
|
file = arg
|
||||||
} else {
|
} else {
|
||||||
file = args[3]
|
fmt.println("usage: odin run client/odin -- [workspace] [port] [file]")
|
||||||
|
fmt.println(" or: odin run client/odin -- <port> [workspace] (smoke test)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(args) >= 5 {
|
|
||||||
file = args[4]
|
|
||||||
}
|
|
||||||
run_sdl_editor(workspace, port, file)
|
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_smoke_client :: proc(port: int, workspace: string) {
|
||||||
run_buffer_smoke()
|
run_buffer_smoke()
|
||||||
editor := run_editor_smoke(workspace)
|
editor := run_editor_smoke(workspace)
|
||||||
defer editor_destroy(&editor)
|
defer editor_destroy(&editor)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1251
client/odin/terminal.odin
Normal file
1251
client/odin/terminal.odin
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -102,6 +102,7 @@ private data class GradleTaskInfo(
|
||||||
val path: String,
|
val path: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
val group: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private class ClientSession(
|
private class ClientSession(
|
||||||
|
|
@ -601,6 +602,7 @@ private fun collectTasks(project: GradleProject): List<GradleTaskInfo> {
|
||||||
path = task.path,
|
path = task.path,
|
||||||
name = task.name,
|
name = task.name,
|
||||||
description = task.description,
|
description = task.description,
|
||||||
|
group = runCatching { task.group }.getOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
current.children.forEach(::visit)
|
current.children.forEach(::visit)
|
||||||
|
|
@ -1107,6 +1109,7 @@ private fun tasksJson(tasks: List<GradleTaskInfo>): JsonElement = buildJsonArray
|
||||||
put("path", task.path)
|
put("path", task.path)
|
||||||
put("name", task.name)
|
put("name", task.name)
|
||||||
put("description", task.description)
|
put("description", task.description)
|
||||||
|
put("group", task.group)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue