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
This commit is contained in:
@@ -16,9 +16,17 @@ RESOURCE_DIR := $(CURDIR)/resources/dev
|
|||||||
GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example
|
GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example
|
||||||
API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example
|
API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example
|
||||||
API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json
|
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 \
|
.PHONY: help setup run run-web build build-debug build-web test clean fmt \
|
||||||
renovate-validate
|
api-wait renovate-validate
|
||||||
|
|
||||||
help: ## List available targets
|
help: ## List available targets
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
@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) -----------------------------------------------------
|
## ---- Renderer (Odin) -----------------------------------------------------
|
||||||
|
|
||||||
run: ## Run the native app (gui/)
|
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)" $(MAKE) -C $(API) run & api_pid=$$!; \
|
@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; \
|
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; \
|
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \
|
||||||
kill $$api_pid 2>/dev/null
|
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)
|
build-web: ## WebAssembly build -> build/web (gui/, needs emscripten)
|
||||||
@$(MAKE) -C $(GUI) build-web
|
@$(MAKE) -C $(GUI) build-web
|
||||||
|
|
||||||
run-web: ## Start WASM build + API server for web dev
|
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)" $(MAKE) -C $(API) run & api_pid=$$!; \
|
@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; \
|
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; \
|
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \
|
||||||
kill $$api_pid 2>/dev/null
|
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 ----------------------------------------------------------
|
## ---- Aggregates ----------------------------------------------------------
|
||||||
|
|
||||||
test: ## Test all projects (odin + cargo + go)
|
test: ## Test all projects (odin + cargo + go)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ A fun personal project with three interlocking goals:
|
|||||||
Early scaffolding. The application currently:
|
Early scaffolding. The application currently:
|
||||||
|
|
||||||
- Opens a resizable 3D raylib window with an orbital camera (zoom + rotate + pan).
|
- 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).
|
- 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.
|
- 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
|
`resources/dev/desi_subset.json` (a small JSON catalog subset served by the
|
||||||
API's `/api/v1/catalogs` and `/api/v1/objects` endpoints).
|
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
|
Project-specific targets live in their own `Makefile` and are reached with
|
||||||
`make -C <dir> <target>`:
|
`make -C <dir> <target>`:
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import "core:strings"
|
|||||||
import "core:time"
|
import "core:time"
|
||||||
|
|
||||||
DEFAULT_HTTP_PORT :: 80
|
DEFAULT_HTTP_PORT :: 80
|
||||||
MAX_REDIRECTS :: 5
|
MAX_REDIRECTS :: 5
|
||||||
HTTP_TIMEOUT :: 5 * time.Second
|
HTTP_TIMEOUT :: 5 * time.Second
|
||||||
|
|
||||||
Response :: struct {
|
Response :: struct {
|
||||||
status: int,
|
status: int,
|
||||||
@@ -18,42 +18,79 @@ Response :: struct {
|
|||||||
chunked: bool,
|
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
|
current := url
|
||||||
owns := false
|
owns := false
|
||||||
|
|
||||||
for _ in 0 ..= MAX_REDIRECTS {
|
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 owns do delete(current)
|
||||||
|
|
||||||
if !request_ok {
|
if request_err != nil {
|
||||||
if next_url != "" do delete(next_url)
|
return {}, request_err
|
||||||
if body != "" do delete(body)
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if next_url != "" {
|
if next_url != "" {
|
||||||
if body != "" do delete(body)
|
|
||||||
current = next_url
|
current = next_url
|
||||||
owns = true
|
owns = true
|
||||||
continue
|
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)
|
host, port, path, parse_ok := parse_http_url(url)
|
||||||
if !parse_ok {
|
if !parse_ok {
|
||||||
return "", "", 0, false
|
return "", {}, error_make(.Invalid_Url)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port)
|
conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port)
|
||||||
if dial_err != nil {
|
if dial_err != nil {
|
||||||
return "", "", 0, false
|
return "", {}, error_make(.Dial_Failed)
|
||||||
}
|
}
|
||||||
defer net.close(conn)
|
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 {
|
if _, send_err := net.send_tcp(conn, transmute([]u8)request); send_err != nil {
|
||||||
return "", "", 0, false
|
return "", {}, error_make(.Send_Failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf: [dynamic]u8
|
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
|
if recv_err != nil || n == 0 do break
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, parsed := parse_response(buf[:])
|
parsed, response_ok := parse_response(buf[:])
|
||||||
if !parsed {
|
if !response_ok {
|
||||||
return "", "", 0, false
|
return "", {}, error_make(.Invalid_Response)
|
||||||
}
|
}
|
||||||
|
resp = parsed
|
||||||
|
|
||||||
if resp.status >= 300 && resp.status < 400 && resp.location != "" {
|
if resp.status >= 300 && resp.status < 400 && resp.location != "" {
|
||||||
resolved := resolve_redirect(url, resp.location)
|
resolved := resolve_redirect(url, resp.location)
|
||||||
if resolved == "" {
|
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 {
|
if resp.chunked {
|
||||||
decoded, decode_ok := decode_chunked(resp.body)
|
decoded, decode_ok := decode_chunked(resp.body)
|
||||||
if !decode_ok {
|
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) {
|
if resp.content_length >= 0 && resp.content_length < len(resp.body) {
|
||||||
resp.body = resp.body[:resp.content_length]
|
resp.body = resp.body[:resp.content_length]
|
||||||
}
|
}
|
||||||
if resp.body == "" {
|
resp.body = strings.clone(resp.body)
|
||||||
return "", "", resp.status, true
|
return "", resp, nil
|
||||||
}
|
|
||||||
return "", strings.clone(resp.body), resp.status, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_response :: proc(raw: []byte) -> (resp: Response, ok: bool) {
|
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 {
|
if len(decoded) == 0 {
|
||||||
return "", true
|
return "", true
|
||||||
}
|
}
|
||||||
return string(decoded[:]), true
|
return strings.clone(string(decoded[:])), true
|
||||||
}
|
}
|
||||||
|
|
||||||
fold_eq :: proc(a, b: string) -> bool {
|
fold_eq :: proc(a, b: string) -> bool {
|
||||||
|
|||||||
@@ -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)
|
url := fmt.bprintf(url_buf[:], "http://127.0.0.1:%d/", bound.port)
|
||||||
|
|
||||||
log.infof("requesting %s", url)
|
log.infof("requesting %s", url)
|
||||||
body, ok := http.http_get(url)
|
resp, req_err := http.http_get(url)
|
||||||
if !testing.expect(t, ok, "http_get returned ok=false") {
|
if !testing.expect(t, req_err == nil, "http_get returned an error") {
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
+184
-15
@@ -1,5 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "core:encoding/json"
|
||||||
|
import "core:fmt"
|
||||||
|
import "core:log"
|
||||||
|
import "core:strings"
|
||||||
import http "lib:http/src"
|
import http "lib:http/src"
|
||||||
|
|
||||||
Catalog :: struct {
|
Catalog :: struct {
|
||||||
@@ -23,30 +27,195 @@ APIError :: struct {
|
|||||||
message: string,
|
message: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) {
|
endpoint :: proc(base_url, path: string) -> string {
|
||||||
data, ok := http.http_get(url)
|
return fmt.tprintf("%s/%s", strings.trim_right(base_url, "/"), strings.trim_left(path, "/"))
|
||||||
if !ok {
|
|
||||||
err := new(APIError)
|
|
||||||
err.code = 1
|
|
||||||
err.message = "request failed"
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer delete(data)
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) {
|
api_error :: proc(code: int, message: string) -> ^APIError {
|
||||||
return nil, nil
|
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(
|
get_catalog_objects :: proc(
|
||||||
url: string,
|
base_url: string,
|
||||||
catalog_name: string,
|
catalog_name: string,
|
||||||
|
region: string = "",
|
||||||
) -> (
|
) -> (
|
||||||
[dynamic]CatalogObject,
|
[dynamic]CatalogObject,
|
||||||
^APIError,
|
^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
|
||||||
|
}
|
||||||
+104
-11
@@ -1,16 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "base:runtime"
|
import "base:runtime"
|
||||||
|
import "core:fmt"
|
||||||
import "core:math"
|
import "core:math"
|
||||||
import "core:math/rand"
|
import "core:math/rand"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
|
import "core:strings"
|
||||||
import rl "vendor:raylib"
|
import rl "vendor:raylib"
|
||||||
|
|
||||||
WIDTH :: 1280
|
WIDTH :: 1280
|
||||||
HEIGHT :: 720
|
HEIGHT :: 720
|
||||||
|
|
||||||
// Number of procedurally generated points standing in for the DESI catalog.
|
// 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
|
POINT_COUNT :: 4_000
|
||||||
WORLD_RADIUS :: f32(500.0)
|
WORLD_RADIUS :: f32(500.0)
|
||||||
|
|
||||||
@@ -19,14 +21,33 @@ Galaxy :: struct {
|
|||||||
color: rl.Color,
|
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
|
camera: rl.Camera3D
|
||||||
|
app: App
|
||||||
universe: [dynamic]Galaxy
|
universe: [dynamic]Galaxy
|
||||||
|
|
||||||
// Deterministic generator so the placeholder sky is stable between runs.
|
// Deterministic generator so the placeholder sky is stable between runs.
|
||||||
rng: rand.Default_Random_State
|
rng: rand.Default_Random_State
|
||||||
|
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
|
|
||||||
c: ^Config
|
c: ^Config
|
||||||
err: ^Error
|
err: ^Error
|
||||||
|
|
||||||
@@ -45,21 +66,50 @@ main :: proc() {
|
|||||||
rl.SetTargetFPS(60)
|
rl.SetTargetFPS(60)
|
||||||
|
|
||||||
camera = make_camera()
|
camera = make_camera()
|
||||||
make_universe(&universe, POINT_COUNT)
|
app = make_app(c)
|
||||||
defer delete(universe)
|
defer destroy_app(&app)
|
||||||
|
|
||||||
for !rl.WindowShouldClose() {
|
for !rl.WindowShouldClose() {
|
||||||
update()
|
switch app.screen {
|
||||||
draw()
|
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 {
|
make_camera :: proc() -> rl.Camera3D {
|
||||||
return {
|
return {
|
||||||
position = {0, 220, 220},
|
position = {0, 220, 220},
|
||||||
target = {0, 0, 0},
|
target = {0, 0, 0},
|
||||||
up = {0, 1, 0},
|
up = {0, 1, 0},
|
||||||
fovy = 60,
|
fovy = 60,
|
||||||
projection = .PERSPECTIVE,
|
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.
|
// Uniformly distributed random point inside the scene's bounding sphere.
|
||||||
random_sphere_point :: proc(radius: f32) -> rl.Vector3 {
|
random_sphere_point :: proc(radius: f32) -> rl.Vector3 {
|
||||||
for {
|
for {
|
||||||
@@ -97,6 +170,11 @@ color_for_position :: proc(p: rl.Vector3) -> rl.Color {
|
|||||||
update :: proc() {
|
update :: proc() {
|
||||||
// Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan.
|
// Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan.
|
||||||
rl.UpdateCamera(&camera, .ORBITAL)
|
rl.UpdateCamera(&camera, .ORBITAL)
|
||||||
|
|
||||||
|
// Return to the start screen to switch catalog / region without restarting.
|
||||||
|
if rl.IsKeyPressed(.TAB) {
|
||||||
|
app.screen = .Start
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw :: proc() {
|
draw :: proc() {
|
||||||
@@ -115,5 +193,20 @@ draw :: proc() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rl.DrawFPS(10, 10)
|
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},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user