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