From de842fdc2de24d2b9184403de169d5d47849ac39 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sun, 13 Sep 2026 00:52:02 -0600 Subject: [PATCH] updated http library to return error and response structs; updated how the GUI works to have a 'wait' UI; updated Makefile to ensure API is started before starting GUI --- Makefile | 35 ++++- README.md | 9 ++ gui/lib/local/http/src/http.odin | 92 ++++++++---- gui/lib/local/http/test/http_test.odin | 9 +- gui/src/data.odin | 199 +++++++++++++++++++++++-- gui/src/main.odin | 117 +++++++++++++-- gui/src/start_screen.odin | 196 ++++++++++++++++++++++++ gui/src/ui.odin | 90 +++++++++++ 8 files changed, 684 insertions(+), 63 deletions(-) create mode 100644 gui/src/start_screen.odin create mode 100644 gui/src/ui.odin diff --git a/Makefile b/Makefile index 064264a..23d5c75 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,17 @@ RESOURCE_DIR := $(CURDIR)/resources/dev GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json +# The API binds here for local dev (see api/src/config.rs); `make run` passes +# it through and waits for $(API_HEALTH_URL) to respond before launching the +# GUI, so the renderer never races the API on startup. A bind host of +# 0.0.0.0 is probed via 127.0.0.1. Adjust API_WAIT_TIMEOUT (seconds) if the +# API takes longer than 60s to become healthy on a given machine. +API_BIND_ADDR ?= 127.0.0.1:8080 +API_HEALTH_URL := http://$(subst 0.0.0.0,127.0.0.1,$(API_BIND_ADDR))/health +API_WAIT_TIMEOUT ?= 60 .PHONY: help setup run run-web build build-debug build-web test clean fmt \ - renovate-validate + api-wait renovate-validate help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @@ -39,9 +47,10 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps) ## ---- Renderer (Odin) ----------------------------------------------------- -run: ## Run the native app (gui/) - @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ +run: ## Run the native app (gui/); waits for the API to be healthy first + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ + $(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \ kill $$api_pid 2>/dev/null @@ -56,12 +65,28 @@ build-debug: ## Debug build (gui/ + api/) build-web: ## WebAssembly build -> build/web (gui/, needs emscripten) @$(MAKE) -C $(GUI) build-web -run-web: ## Start WASM build + API server for web dev - @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ +run-web: ## Start WASM build + API server for web dev (waits for API health) + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ + $(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \ kill $$api_pid 2>/dev/null +## ---- Dev helpers ---------------------------------------------------------- + +api-wait: ## Poll the API health endpoint until it responds (or times out) + @echo "Waiting for API at $(API_HEALTH_URL) ..."; \ + elapsed=0; \ + while ! curl -sf "$(API_HEALTH_URL)" >/dev/null 2>&1; do \ + elapsed=$$((elapsed + 1)); \ + if [ "$${elapsed}" -ge "$(API_WAIT_TIMEOUT)" ]; then \ + echo "Error: API at $(API_HEALTH_URL) not healthy after $(API_WAIT_TIMEOUT)s" >&2; \ + exit 1; \ + fi; \ + sleep 1; \ + done; \ + echo "API is up." + ## ---- Aggregates ---------------------------------------------------------- test: ## Test all projects (odin + cargo + go) diff --git a/README.md b/README.md index af603a9..8e1446b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A fun personal project with three interlocking goals: Early scaffolding. The application currently: - Opens a resizable 3D raylib window with an orbital camera (zoom + rotate + pan). +- Starts on a launch screen where you configure the API URL, pick a region, hit **Refresh catalogs** to pull the catalog list, and press **Explore** to enter the 3D view (TAB returns to the launch screen). - Renders a procedurally generated point cloud standing in for the galaxy catalog (real DESI data ingestion is the next milestone). - Compiles natively, to WebAssembly, and is deployed to an on-prem Kubernetes cluster as a placeholder web service. @@ -95,6 +96,14 @@ The defaults point at `resources/dev/gui.env.example` (renderer's `API_URL`), `resources/dev/desi_subset.json` (a small JSON catalog subset served by the API's `/api/v1/catalogs` and `/api/v1/objects` endpoints). +`make run` and `make run-web` start the API in the background and then wait for +its `/health` endpoint before launching the GUI (`make api-wait`), so the +renderer never races the API on startup. Override the bind address and worst +case wait with `API_BIND_ADDR` (default `127.0.0.1:8080`) and +`API_WAIT_TIMEOUT` (default 60s). The earlier you click **Refresh** on the GUI's +launch screen, the more likely you are to catch the API mid-boot; a slow API can +also be re-polled by pressing **Refresh** again. + Project-specific targets live in their own `Makefile` and are reached with `make -C `: diff --git a/gui/lib/local/http/src/http.odin b/gui/lib/local/http/src/http.odin index e7a63f3..752da5b 100644 --- a/gui/lib/local/http/src/http.odin +++ b/gui/lib/local/http/src/http.odin @@ -7,8 +7,8 @@ import "core:strings" import "core:time" DEFAULT_HTTP_PORT :: 80 -MAX_REDIRECTS :: 5 -HTTP_TIMEOUT :: 5 * time.Second +MAX_REDIRECTS :: 5 +HTTP_TIMEOUT :: 5 * time.Second Response :: struct { status: int, @@ -18,42 +18,79 @@ Response :: struct { chunked: bool, } -http_get :: proc(url: string) -> (data: string, ok: bool) { +Error :: struct { + code: int, + message: string, +} + +Error_Code :: enum int { + Invalid_Url, + Dial_Failed, + Send_Failed, + Invalid_Response, + Invalid_Redirect, + Invalid_Chunked, + Too_Many_Redirects, +} + +error_make :: proc(code: Error_Code) -> ^Error { + err := new(Error) + err.code = int(code) + + switch code { + case .Invalid_Url: + err.message = "invalid url" + case .Dial_Failed: + err.message = "could not connect to host" + case .Send_Failed: + err.message = "could not send request" + case .Invalid_Response: + err.message = "could not parse response" + case .Invalid_Redirect: + err.message = "invalid redirect" + case .Invalid_Chunked: + err.message = "invalid chunked response" + case .Too_Many_Redirects: + err.message = "too many redirects" + } + + return err +} + +http_get :: proc(url: string) -> (resp: Response, err: ^Error) { current := url owns := false for _ in 0 ..= MAX_REDIRECTS { - next_url, body, _, request_ok := perform_request(current) + next_url, next_resp, request_err := perform_request(current) if owns do delete(current) - if !request_ok { - if next_url != "" do delete(next_url) - if body != "" do delete(body) - return "", false + if request_err != nil { + return {}, request_err } if next_url != "" { - if body != "" do delete(body) current = next_url owns = true continue } - return body, true + return next_resp, nil } - return "", false + if owns do delete(current) + return {}, error_make(.Too_Many_Redirects) } -perform_request :: proc(url: string) -> (next_url: string, body: string, status: int, ok: bool) { +perform_request :: proc(url: string) -> (next_url: string, resp: Response, err: ^Error) { host, port, path, parse_ok := parse_http_url(url) if !parse_ok { - return "", "", 0, false + return "", {}, error_make(.Invalid_Url) } conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port) if dial_err != nil { - return "", "", 0, false + return "", {}, error_make(.Dial_Failed) } defer net.close(conn) @@ -75,7 +112,7 @@ perform_request :: proc(url: string) -> (next_url: string, body: string, status: ) if _, send_err := net.send_tcp(conn, transmute([]u8)request); send_err != nil { - return "", "", 0, false + return "", {}, error_make(.Send_Failed) } buf: [dynamic]u8 @@ -87,34 +124,35 @@ perform_request :: proc(url: string) -> (next_url: string, body: string, status: if recv_err != nil || n == 0 do break } - resp, parsed := parse_response(buf[:]) - if !parsed { - return "", "", 0, false + parsed, response_ok := parse_response(buf[:]) + if !response_ok { + return "", {}, error_make(.Invalid_Response) } + resp = parsed if resp.status >= 300 && resp.status < 400 && resp.location != "" { resolved := resolve_redirect(url, resp.location) if resolved == "" { - return "", "", resp.status, false + return "", {}, error_make(.Invalid_Redirect) } - return resolved, "", resp.status, true + resp.body = "" + return resolved, resp, nil } if resp.chunked { decoded, decode_ok := decode_chunked(resp.body) if !decode_ok { - return "", "", resp.status, false + return "", {}, error_make(.Invalid_Chunked) } - return "", decoded, resp.status, true + resp.body = decoded + return "", resp, nil } if resp.content_length >= 0 && resp.content_length < len(resp.body) { resp.body = resp.body[:resp.content_length] } - if resp.body == "" { - return "", "", resp.status, true - } - return "", strings.clone(resp.body), resp.status, true + resp.body = strings.clone(resp.body) + return "", resp, nil } parse_response :: proc(raw: []byte) -> (resp: Response, ok: bool) { @@ -255,7 +293,7 @@ decode_chunked :: proc(data: string) -> (body: string, ok: bool) { if len(decoded) == 0 { return "", true } - return string(decoded[:]), true + return strings.clone(string(decoded[:])), true } fold_eq :: proc(a, b: string) -> bool { diff --git a/gui/lib/local/http/test/http_test.odin b/gui/lib/local/http/test/http_test.odin index 758e688..13868ce 100644 --- a/gui/lib/local/http/test/http_test.odin +++ b/gui/lib/local/http/test/http_test.odin @@ -65,11 +65,12 @@ test_http_get_request :: proc(t: ^testing.T) { url := fmt.bprintf(url_buf[:], "http://127.0.0.1:%d/", bound.port) log.infof("requesting %s", url) - body, ok := http.http_get(url) - if !testing.expect(t, ok, "http_get returned ok=false") { + resp, req_err := http.http_get(url) + if !testing.expect(t, req_err == nil, "http_get returned an error") { return } - defer delete(body) + defer delete(resp.body) - testing.expectf(t, body == SERVER_BODY, "expected %q, got %q", SERVER_BODY, body) + testing.expectf(t, resp.status == 200, "expected status 200, got %d", resp.status) + testing.expectf(t, resp.body == SERVER_BODY, "expected %q, got %q", SERVER_BODY, resp.body) } \ No newline at end of file diff --git a/gui/src/data.odin b/gui/src/data.odin index faccd0d..da6b62c 100644 --- a/gui/src/data.odin +++ b/gui/src/data.odin @@ -1,5 +1,9 @@ package main +import "core:encoding/json" +import "core:fmt" +import "core:log" +import "core:strings" import http "lib:http/src" Catalog :: struct { @@ -23,30 +27,195 @@ APIError :: struct { message: string, } -get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) { - data, ok := http.http_get(url) - if !ok { - err := new(APIError) - err.code = 1 - err.message = "request failed" - return nil, err - } - defer delete(data) - - return nil, nil +endpoint :: proc(base_url, path: string) -> string { + return fmt.tprintf("%s/%s", strings.trim_right(base_url, "/"), strings.trim_left(path, "/")) } -get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) { - return nil, nil +api_error :: proc(code: int, message: string) -> ^APIError { + err := new(APIError) + err.code = code + err.message = message + return err +} + +destroy_api_error :: proc(err: ^APIError) { + delete(err.message) + free(err) +} + +destroy_catalogs :: proc(catalogs: [dynamic]Catalog) { + for &c in catalogs { + delete(c.name) + delete(c.release) + if c.description != nil { + delete(c.description^) + free(c.description) + } + if c.object_count != nil { + free(c.object_count) + } + } + delete(catalogs) +} + +destroy_catalog_objects :: proc(objects: [dynamic]CatalogObject) { + for &o in objects { + delete(o.id) + delete(o.catalog) + delete(o.object_type) + } + delete(objects) +} + +request_json_array :: proc( + url: string, +) -> ( + arr: json.Array, + val: json.Value, + err: ^APIError, + ok: bool, +) { + resp, req_err := http.http_get(url) + if req_err != nil { + defer free(req_err) + return nil, json.Null(nil), api_error(req_err.code, strings.clone(req_err.message)), false + } + defer delete(resp.body) + + if resp.status != 200 { + return nil, json.Null(nil), api_error(resp.status, strings.clone(strings.trim_space(resp.body))), false + } + + log.debugf("got %s => %s", url, resp.body) + + v, perr := json.parse_string(resp.body) + if perr != .None { + return nil, json.Null(nil), api_error(int(perr), "invalid JSON in API response"), false + } + + a, is_arr := v.(json.Array) + if !is_arr { + json.destroy_value(v) + return nil, json.Null(nil), api_error(0, "API response was not a JSON array"), false + } + return a, v, nil, true +} + +get_catalogs :: proc(base_url: string) -> ([dynamic]Catalog, ^APIError) { + url := endpoint(base_url, "/api/v1/catalogs") + defer delete(url) + + arr, val, err, ok := request_json_array(url) + if !ok { + return nil, err + } + defer json.destroy_value(val) + + catalogs := make([dynamic]Catalog, 0, len(arr)) + for item in arr { + if c, cat_ok := catalog_from_json(item); cat_ok { + append(&catalogs, c) + } + } + return catalogs, nil } get_catalog_objects :: proc( - url: string, + base_url: string, catalog_name: string, + region: string = "", ) -> ( [dynamic]CatalogObject, ^APIError, ) { - return nil, nil + sb := strings.builder_make() + defer strings.builder_destroy(&sb) + fmt.sbprintf(&sb, "%s/api/v1/objects?catalog=%s", endpoint(base_url, ""), catalog_name) + if region != "" { + fmt.sbprintf(&sb, "®ion=%s", region) + } + fmt.sbprintf(&sb, "&limit=%d", 10_000) + url := strings.to_string(sb) + + arr, val, err, ok := request_json_array(url) + if !ok { + return nil, err + } + defer json.destroy_value(val) + + objects := make([dynamic]CatalogObject, 0, len(arr)) + for item in arr { + if o, obj_ok := catalog_object_from_json(item); obj_ok { + append(&objects, o) + } + } + return objects, nil } +catalog_from_json :: proc(v: json.Value) -> (c: Catalog, ok: bool) { + obj, is_obj := v.(json.Object) + if !is_obj { + return {}, false + } + c = Catalog{ + name = json_string(obj, "name"), + release = json_string(obj, "release"), + } + if d, found := obj["description"]; found { + if s, s_ok := d.(json.String); s_ok { + c.description = new_clone(strings.clone(s)) + } + } + if n, n_ok := json_u64(obj, "object_count"); n_ok { + c.object_count = new_clone(n) + } + return c, true +} + +catalog_object_from_json :: proc(v: json.Value) -> (o: CatalogObject, ok: bool) { + obj, is_obj := v.(json.Object) + if !is_obj { + return {}, false + } + return CatalogObject{ + id = json_string(obj, "id"), + catalog = json_string(obj, "catalog"), + object_type = json_string(obj, "object_type"), + ra = json_f64(obj, "ra"), + dec = json_f64(obj, "dec"), + redshift = json_f64(obj, "redshift"), + }, true +} + +json_string :: proc(obj: json.Object, key: string) -> string { + if v, found := obj[key]; found { + if s, ok := v.(json.String); ok { + return strings.clone(s) + } + } + return "" +} + +json_f64 :: proc(obj: json.Object, key: string) -> f64 { + if v, found := obj[key]; found { + #partial switch n in v { + case json.Integer: + return f64(n) + case json.Float: + return n + } + } + return 0 +} + +json_u64 :: proc(obj: json.Object, key: string) -> (u64, bool) { + if v, found := obj[key]; found { + #partial switch n in v { + case json.Integer: + return u64(n), true + case json.Float: + return u64(n), true + } + } + return 0, false +} \ No newline at end of file diff --git a/gui/src/main.odin b/gui/src/main.odin index b971bac..92d8079 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -1,16 +1,18 @@ package main import "base:runtime" +import "core:fmt" import "core:math" import "core:math/rand" import "core:os" +import "core:strings" import rl "vendor:raylib" WIDTH :: 1280 HEIGHT :: 720 // Number of procedurally generated points standing in for the DESI catalog. -// This is replaced by real survey data once ingestion lands. +// This is replaced by real survey data once a catalog is loaded. POINT_COUNT :: 4_000 WORLD_RADIUS :: f32(500.0) @@ -19,14 +21,33 @@ Galaxy :: struct { color: rl.Color, } +Screen :: enum { + Start, + Universe, +} + +App :: struct { + screen: Screen, + api_url: strings.Builder, + api_url_focused: bool, + region: strings.Builder, + region_focused: bool, + catalogs: [dynamic]Catalog, + selected_catalog: int, + catalog_scroll: int, + status: strings.Builder, + loaded_catalog: string, + loaded_region: string, +} + camera: rl.Camera3D +app: App universe: [dynamic]Galaxy // Deterministic generator so the placeholder sky is stable between runs. rng: rand.Default_Random_State main :: proc() { - c: ^Config err: ^Error @@ -45,21 +66,50 @@ main :: proc() { rl.SetTargetFPS(60) camera = make_camera() - make_universe(&universe, POINT_COUNT) - defer delete(universe) + app = make_app(c) + defer destroy_app(&app) for !rl.WindowShouldClose() { - update() - draw() + switch app.screen { + case .Start: + start_screen_update() + start_screen_draw() + case .Universe: + update() + draw() + } } } +make_app :: proc(c: ^Config) -> App { + a := App{ + screen = .Start, + selected_catalog = -1, + } + a.api_url = strings.builder_make() + a.region = strings.builder_make() + a.status = strings.builder_make() + strings.write_string(&a.api_url, c.api_url) + strings.write_string(&a.status, "Set the API URL, press Refresh, pick a catalog, then Explore.") + return a +} + +destroy_app :: proc(a: ^App) { + strings.builder_destroy(&a.api_url) + strings.builder_destroy(&a.region) + strings.builder_destroy(&a.status) + destroy_catalogs(a.catalogs) + delete(a.loaded_catalog) + delete(a.loaded_region) + delete(universe) +} + make_camera :: proc() -> rl.Camera3D { return { - position = {0, 220, 220}, - target = {0, 0, 0}, - up = {0, 1, 0}, - fovy = 60, + position = {0, 220, 220}, + target = {0, 0, 0}, + up = {0, 1, 0}, + fovy = 60, projection = .PERSPECTIVE, } } @@ -73,6 +123,29 @@ make_universe :: proc(u: ^[dynamic]Galaxy, count: int) { } } +make_universe_from_objects :: proc(u: ^[dynamic]Galaxy, objects: []CatalogObject) { + clear(u) + reserve(u, len(objects)) + for obj in objects { + pos := ra_dec_to_pos(f32(obj.ra), f32(obj.dec), f32(obj.redshift)) + append(u, Galaxy{position = pos, color = color_for_position(pos)}) + } +} + +// Maps an equatorial position (ra/dec in degrees) plus redshift to a point in +// the scene: shells telescope outward with redshift. +ra_dec_to_pos :: proc(ra, dec, redshift: f32) -> rl.Vector3 { + theta := math.to_radians(ra) + phi := math.to_radians(dec) + t := math.clamp(redshift * 0.6, 0.05, 1.0) + r := WORLD_RADIUS * t + return { + r * math.cos(phi) * math.cos(theta), + r * math.sin(phi), + r * math.cos(phi) * math.sin(theta), + } +} + // Uniformly distributed random point inside the scene's bounding sphere. random_sphere_point :: proc(radius: f32) -> rl.Vector3 { for { @@ -97,6 +170,11 @@ color_for_position :: proc(p: rl.Vector3) -> rl.Color { update :: proc() { // Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan. rl.UpdateCamera(&camera, .ORBITAL) + + // Return to the start screen to switch catalog / region without restarting. + if rl.IsKeyPressed(.TAB) { + app.screen = .Start + } } draw :: proc() { @@ -115,5 +193,20 @@ draw :: proc() { } rl.DrawFPS(10, 10) - rl.DrawText("DESI Explorer — drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE) -} + ui_draw_text("DESI Explorer - drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE) + + buf: [128]u8 + if app.loaded_catalog != "" { + ui_draw_text(fmt.bprintf(buf[:], "Catalog: %s", app.loaded_catalog), 10, 60, 18, rl.YELLOW) + } + if app.loaded_region != "" { + ui_draw_text(fmt.bprintf(buf[:], "Region: %s", app.loaded_region), 10, 82, 18, rl.YELLOW) + } + ui_draw_text( + "Press TAB to return to the start screen", + 10, + i32(rl.GetScreenHeight()) - 28, + 16, + {120, 120, 140, 255}, + ) +} \ No newline at end of file diff --git a/gui/src/start_screen.odin b/gui/src/start_screen.odin new file mode 100644 index 0000000..01f518a --- /dev/null +++ b/gui/src/start_screen.odin @@ -0,0 +1,196 @@ +package main + +import "core:fmt" +import "core:strings" +import rl "vendor:raylib" + +CATALOG_ROW_H :: 30 + +start_screen_update :: proc() { + api_rect := rl.Rectangle{60, 112, 460, 34} + region_rect := rl.Rectangle{60, 186, 460, 34} + + text_input_update(&app.api_url, &app.api_url_focused, api_rect) + text_input_update(&app.region, &app.region_focused, region_rect) + + if ui_button_clicked(rl.Rectangle{60, 230, 170, 38}) { + refresh_catalogs() + } + + list_rect := rl.Rectangle{60, 300, 520, 230} + if clicked := catalog_list_update(list_rect); clicked >= 0 { + app.selected_catalog = clicked + set_statusf("Selected catalog: %s", app.catalogs[clicked].name) + } + + if ui_button_clicked(rl.Rectangle{60, 548, 150, 38}) { + if explore() { + app.screen = .Universe + } + } +} + +start_screen_draw :: proc() { + rl.BeginDrawing() + defer rl.EndDrawing() + + rl.ClearBackground({8, 10, 20, 255}) + + ui_draw_text("DESI Explorer", 60, 36, 40, rl.WHITE) + + ui_draw_text("API URL", 60, 92, 16, {150, 160, 190, 255}) + text_input_draw(&app.api_url, app.api_url_focused, rl.Rectangle{60, 112, 460, 34}) + + ui_draw_text("Region (optional)", 60, 166, 16, {150, 160, 190, 255}) + text_input_draw(&app.region, app.region_focused, rl.Rectangle{60, 186, 460, 34}) + + ui_button(rl.Rectangle{60, 230, 170, 38}, "Refresh catalogs") + + ui_draw_text("Catalogs", 60, 280, 16, {150, 160, 190, 255}) + catalog_list_draw(rl.Rectangle{60, 300, 520, 230}) + + ui_button(rl.Rectangle{60, 548, 150, 38}, "Explore") + + ui_draw_text(strings.to_string(app.status), 60, i32(rl.GetScreenHeight()) - 36, 16, {200, 200, 220, 255}) +} + +set_status :: proc(msg: string) { + strings.builder_reset(&app.status) + strings.write_string(&app.status, msg) +} + +set_statusf :: proc(format: string, args: ..any) { + strings.builder_reset(&app.status) + fmt.sbprintf(&app.status, format, ..args) +} + +refresh_catalogs :: proc() { + destroy_catalogs(app.catalogs) + app.catalogs = nil + app.selected_catalog = -1 + + base := strings.trim_space(strings.to_string(app.api_url)) + if base == "" { + set_status("Enter an API URL first.") + return + } + + catalogs, err := get_catalogs(base) + if err != nil { + set_statusf("Failed to load catalogs: %s", err.message) + destroy_api_error(err) + return + } + + app.catalogs = catalogs + set_statusf("Loaded %d catalog(s). Select one and press Explore.", len(catalogs)) +} + +explore :: proc() -> bool { + if app.selected_catalog < 0 || app.selected_catalog >= len(app.catalogs) { + set_status("Select a catalog first.") + return false + } + + base := strings.trim_space(strings.to_string(app.api_url)) + region := strings.trim_space(strings.to_string(app.region)) + if base == "" { + set_status("Enter an API URL first.") + return false + } + + c := app.catalogs[app.selected_catalog] + objects, err := get_catalog_objects(base, c.name, region) + if err != nil { + set_statusf("Failed to load objects: %s", err.message) + destroy_api_error(err) + return false + } + + delete(app.loaded_catalog) + delete(app.loaded_region) + app.loaded_catalog = strings.clone(c.name) + app.loaded_region = strings.clone(region) + + if len(objects) == 0 { + make_universe(&universe, POINT_COUNT) + set_status("No objects returned - showing placeholder sky.") + } else { + make_universe_from_objects(&universe, objects[:]) + set_statusf("Loaded %d object(s).", len(objects)) + } + destroy_catalog_objects(objects) + return true +} + +visible_row_count :: proc(rec: rl.Rectangle) -> int { + return max(1, int(rec.height) / CATALOG_ROW_H) +} + +row_rect :: proc(rec: rl.Rectangle, index, scroll_offset: int) -> rl.Rectangle { + y := rec.y + f32((index - scroll_offset) * CATALOG_ROW_H) + return rl.Rectangle{rec.x, y, rec.width, CATALOG_ROW_H} +} + +catalog_list_update :: proc(rec: rl.Rectangle) -> int { + mouse := rl.GetMousePosition() + if rl.CheckCollisionPointRec(mouse, rec) { + wheel := rl.GetMouseWheelMove() + max_scroll := max(0, len(app.catalogs) - visible_row_count(rec)) + app.catalog_scroll = max(0, min(app.catalog_scroll - int(wheel), max_scroll)) + } + + clicked := -1 + if len(app.catalogs) == 0 { + return clicked + } + visible := visible_row_count(rec) + for i in app.catalog_scroll ..< min(len(app.catalogs), app.catalog_scroll + visible) { + row := row_rect(rec, i, app.catalog_scroll) + if rl.CheckCollisionPointRec(mouse, row) && rl.IsMouseButtonPressed(.LEFT) { + clicked = i + } + } + return clicked +} + +catalog_list_draw :: proc(rec: rl.Rectangle) { + rl.DrawRectangleRec(rec, {12, 14, 22, 255}) + rl.DrawRectangleLinesEx(rec, 1, {56, 60, 82, 255}) + + if len(app.catalogs) == 0 { + ui_draw_text("No catalogs loaded - press Refresh", i32(rec.x) + 12, i32(rec.y) + 12, 16, {120, 120, 140, 255}) + return + } + + mouse := rl.GetMousePosition() + visible := visible_row_count(rec) + for i in app.catalog_scroll ..< min(len(app.catalogs), app.catalog_scroll + visible) { + row := row_rect(rec, i, app.catalog_scroll) + if i == app.selected_catalog { + rl.DrawRectangleRec(row, {44, 56, 92, 255}) + rl.DrawRectangleLinesEx(row, 1, {120, 160, 235, 255}) + } else if rl.CheckCollisionPointRec(mouse, row) { + rl.DrawRectangleRec(row, {26, 34, 56, 255}) + } + + buf: [256]u8 + ui_draw_text( + catalog_display(buf[:], app.catalogs[i]), + i32(rec.x) + 12, + i32(row.y) + 6, + 16, + rl.RAYWHITE, + ) + } +} + +catalog_display :: proc(buf: []u8, c: Catalog) -> string { + if c.description != nil { + return fmt.bprintf(buf, "%s (%s)", c.name, c.description^) + } + if c.release != "" { + return fmt.bprintf(buf, "%s (%s)", c.name, c.release) + } + return fmt.bprintf(buf, "%s", c.name) +} \ No newline at end of file diff --git a/gui/src/ui.odin b/gui/src/ui.odin new file mode 100644 index 0000000..fee9912 --- /dev/null +++ b/gui/src/ui.odin @@ -0,0 +1,90 @@ +package main + +import c "core:c" +import "core:strings" +import "core:unicode/utf8" +import rl "vendor:raylib" + +MAX_INPUT_LEN :: 256 + +to_cstring_buf :: proc(buf: []u8, s: string) -> cstring { + n := min(len(s), len(buf) - 1) + copy(buf[:n], s[:n]) + buf[n] = 0 + return cstring(&buf[0]) +} + +ui_draw_text :: proc(text: string, x, y, size: c.int, color: rl.Color) { + buf: [1024]u8 + rl.DrawText(to_cstring_buf(buf[:], text), x, y, size, color) +} + +text_input_update :: proc(b: ^strings.Builder, focused: ^bool, rec: rl.Rectangle) { + mouse := rl.GetMousePosition() + if rl.CheckCollisionPointRec(mouse, rec) && rl.IsMouseButtonPressed(.LEFT) { + focused^ = true + } else if rl.IsMouseButtonPressed(.LEFT) { + focused^ = false + } + + if !focused^ { + return + } + + for r := rl.GetCharPressed(); r != 0; { + if len(b.buf) < MAX_INPUT_LEN { + strings.write_rune(b, r) + } + r = rl.GetCharPressed() + } + + if rl.IsKeyPressed(.BACKSPACE) { + s := strings.to_string(b^) + _, rune_len := utf8.decode_last_rune(s) + if rune_len > 0 && len(b.buf) >= rune_len { + resize(&b.buf, len(b.buf) - rune_len) + } + } +} + +text_input_draw :: proc(b: ^strings.Builder, focused: bool, rec: rl.Rectangle) { + text := strings.to_string(b^) + + if focused { + rl.DrawRectangleRec(rec, {28, 34, 56, 255}) + rl.DrawRectangleLinesEx(rec, 2, {90, 160, 240, 255}) + } else { + rl.DrawRectangleRec(rec, {16, 18, 30, 255}) + rl.DrawRectangleLinesEx(rec, 1, {70, 76, 96, 255}) + } + + buf: [MAX_INPUT_LEN + 1]u8 + cstr := to_cstring_buf(buf[:], text) + rl.DrawText(cstr, i32(rec.x) + 8, i32(rec.y) + 8, 18, rl.RAYWHITE) + + if focused { + cw := rl.MeasureText(cstr, 18) + rl.DrawRectangle(i32(rec.x) + 8 + cw + 1, i32(rec.y) + 8, 2, 20, rl.SKYBLUE) + } +} + +ui_button_clicked :: proc(rec: rl.Rectangle) -> bool { + mouse := rl.GetMousePosition() + return rl.CheckCollisionPointRec(mouse, rec) && rl.IsMouseButtonPressed(.LEFT) +} + +ui_button :: proc(rec: rl.Rectangle, label: string) { + mouse := rl.GetMousePosition() + hovered := rl.CheckCollisionPointRec(mouse, rec) + if hovered { + rl.DrawRectangleRec(rec, {48, 66, 118, 255}) + } else { + rl.DrawRectangleRec(rec, {30, 38, 66, 255}) + } + rl.DrawRectangleLinesEx(rec, 1, {92, 104, 134, 255}) + + buf: [128]u8 + cstr := to_cstring_buf(buf[:], label) + w := rl.MeasureText(cstr, 18) + rl.DrawText(cstr, i32(rec.x) + (i32(rec.width) - w) / 2, i32(rec.y) + 10, 18, rl.RAYWHITE) +} \ No newline at end of file