bl
This commit is contained in:
parent
41adeeb47f
commit
5b65c4c0e4
11 changed files with 4237 additions and 297 deletions
|
|
@ -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)
|
||||
|
|
|
|||
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 {
|
||||
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)
|
||||
|
|
|
|||
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,
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -7,45 +7,69 @@ import "core:strconv"
|
|||
|
||||
main :: proc() {
|
||||
args := os.args
|
||||
if len(args) < 2 {
|
||||
fmt.println("usage: odin run client/odin -- <port> [workspace]")
|
||||
fmt.println(" or: odin run client/odin -- --sdl [workspace] [port] [file]")
|
||||
|
||||
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 -- <port> [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)
|
||||
|
|
|
|||
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
Loading…
Add table
Add a link
Reference in a new issue