commit c8789214bd957d177c17ecd8cf698838c9ca23e4 Author: Daniel Bross Date: Thu Oct 29 23:40:41 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9fb5cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.build/ +bin/ +server/bin/ +web/fonts.bin +web/taskforge.wasm +*.taskforge.tmp +*.db +*.db-wal +*.db-shm diff --git a/README.md b/README.md new file mode 100644 index 0000000..760c3fc --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# TaskForge + +Task management for game projects, written in Jai. The same client code runs as a native Windows app (OpenGL 3.3) and in the browser (WebGL2 via WASM). All UI and rendering is custom immediate-mode code; the only web code is a small JS shim that forwards GL calls, input and HTTP. + +It runs in two modes: + +- **Local:** a single user, with the project in a text file (native) or the browser's storage (web). +- **Server:** a team. `taskforge-server` (also Jai) stores users and the shared project in SQLite and serves the web client. People sign in from the browser or the native app, and changes sync between everyone. + +## Features + +- **Custom fields** (Fields page): Text, Number, Checkbox, Select (one option), Tags (several options) and User (assign people). Options have colours, and a field can apply to tasks, epics or both. Add, rename, reorder and delete fields as you like. +- **Views** for each context: + - A **Board** turns any Select or Checkbox field into kanban columns, with drag & drop. + - A **Checklist** turns each task into a checkbox. "Done" means a field has a chosen value, and items can be grouped by epic. + - Views can filter by epic, by an option (e.g. `Discipline: Art`) or by a user (e.g. `Assignee: Me`), and choose which fields appear on cards. +- **Epic rules** (Epic rules page): set a field on an epic from the state of its tasks, e.g. *Set **Art** to **Done** when **all** of its **Art** tasks have **Status** is **Done***. For each epic and field, the first matching rule from the top wins. If none match, the field keeps its value. +- **Users** (server mode): sign-in with sessions, admins and deactivation. Assign people with User fields. Admins add users and reset passwords on the Users page; everyone can change their own password. +- **Text editing:** selection with the mouse (drag, double-click selects a word, triple-click selects all) and the keyboard (Shift+arrows/Home/End, Ctrl+A). Copy, cut and paste work on the selection, and typing replaces it. + +## Building + +Requires the Jai compiler (tested with beta 0.2.030). + +``` +jai build.jai # native client: bin/TaskForge.exe (+ fonts.bin, libcurl.dll) +jai build.jai - release # native client, optimized +jai build.jai - web # web client: web/taskforge.wasm (add "release" for optimized) +jai build.jai - server # server for this OS: server/bin/taskforge-server(.exe) +jai build.jai - server-linux # Linux x64 server object file, linked on the droplet (see below) +``` + +Each client build bakes `data/Karla-Regular.ttf` (SIL Open Font License) into `fonts.bin` at compile time, so the app needs no font rasterizer at runtime. + +## Running locally + +- **Native, local file:** `bin/TaskForge.exe [path/to/project.taskforge]`. The plain-text project file can live in your game's repository and diffs cleanly. +- **Web, local:** serve `web/` with any static server (`python -m http.server -d web 8000`). The project lives in the browser; Export/Import move it to or from a file. +- **Server on your machine:** + ``` + cd server/bin + taskforge-server add-user daniel "Daniel" --admin # asks for a password + taskforge-server --web ../../web --port 8080 # http://localhost:8080 + ``` + `--demo` on the first start seeds the sample game project; without it you get a clean starter project (Status, Assignee, Discipline, Priority; Board and My tasks views). On Windows the server needs `sqlite3.dll` next to it (the build copies the one in `server/dev/`). +- **Native app against a server:** `bin/TaskForge.exe --server https://tasks.example.com`. The session is remembered in `%APPDATA%\TaskForge\session.txt`. + +## Deploying to a DigitalOcean droplet + +1. Create an Ubuntu 24.04 droplet (the smallest size is plenty) and point a domain's A record at its IP. +2. From Git Bash on your machine: + ``` + deploy/deploy.sh root@ tasks.example.com + ``` + The first run sets up the droplet (`deploy/setup-droplet.sh`): + - installs SQLite, a C toolchain and Caddy, + - creates a `taskforge` system user and a hardened systemd service, + - configures HTTPS for your domain through Caddy (automatic Let's Encrypt), + - enables the firewall, + - adds a daily SQLite backup to `/var/backups/taskforge` (kept for 14 days). + + Every run builds the web client and the Linux server, uploads them, links the server on the droplet and restarts it. For later updates, run `deploy/deploy.sh root@`. +3. Create the first admin on the droplet: + ``` + sudo -u taskforge /opt/taskforge/bin/taskforge-server --db /var/lib/taskforge/taskforge.db add-user daniel "Daniel" --admin + ``` + Then open `https://tasks.example.com`, sign in, and add the rest of the team on the Users page. + +Why link on the droplet: the server is cross-compiled to a Linux object file on Windows, but linking a Linux executable needs Linux's system libraries, which the droplet has. Alternatively, copy your Jai distribution to the droplet and run `jai-linux build.jai - server` there. + +Useful on the droplet: `journalctl -u taskforge -f` (logs), `taskforge-server --db ... list-users`, `taskforge-server --db ... set-password `. + +## How the server works + +- **Database:** SQLite in WAL mode, loaded at runtime (`libsqlite3.so.0` / `sqlite3.dll`). Tables: `records` (the project), `users`, `sessions`, `login_failures`, `meta`. Each worker thread has its own connection; writes are short `BEGIN IMMEDIATE` transactions. +- **Project sync:** a project is a set of records, one text line per entity (`task 42 ...`, `val 42 7 sel 5`, ...), the same format as the local file. Every write bumps a global revision. Clients send the records they changed (found by diffing against what the server last confirmed) and receive everything changed since their last revision. The last writer wins per record, so two people editing different fields of the same task don't overwrite each other. Clients poll every 2 seconds, and immediately after local edits. New ids come in blocks of 1000 per client, so they never collide. +- **Security:** + - Passwords are hashed with PBKDF2-HMAC-SHA256 (310,000 iterations, random salt). + - Session tokens are 256-bit random values, stored only as SHA-256 hashes; they expire after 30 days of inactivity. + - After 10 failed logins in 10 minutes, the username is locked for a while. + - Changing a password signs out that user's other sessions. + - Static file serving only accepts plain file names from the web directory. + - HTTPS is handled by Caddy; the server listens on 127.0.0.1 only. +- **HTTP API** (plain text bodies): `GET /api/info`, `POST /api/login`, `/api/logout`, `/api/sync`, `/api/users`. + +Current limits: one project per server, and changes arrive by polling (up to ~2 s delay) rather than push. + +## Code layout + +| File | Purpose | +|---|---| +| `build.jai` | Build metaprogram for all targets + font atlas baker | +| `src/main.jai` | Client entry; picks the platform layer | +| `src/platform_native.jai` | Win32 window, WGL, input, files, clipboard, libcurl HTTP, `--sim` test harness | +| `src/platform_web.jai`, `web/taskforge.js` | WASM exports + the JS shim (WebGL2, input, fetch, localStorage) | +| `src/web_gl.jai` | GL declarations for WASM (same names as the GL module) | +| `src/render.jai`, `src/font.jai` | Batched renderer and text | +| `src/ui.jai` | Immediate-mode widgets: buttons, text editing with selection, dropdowns, scrolling | +| `src/model.jai` | Fields, tasks, views, users, epic rule evaluation | +| `src/storage.jai` | Record format, local file, starter projects | +| `src/sync.jai` | Sign-in and project sync with the server | +| `src/app.jai`, `src/account.jai` | Screens | +| `server/server.jai` | HTTP server, API, users and sessions | +| `server/sqlite.jai`, `server/crypto.jai` | SQLite binding; SHA-256 / HMAC / PBKDF2 / OS randomness | +| `deploy/` | Droplet setup, deploy script, systemd unit, Caddyfile | + +## Keyboard + +- `Esc`: close a popup or the task panel +- `Ctrl+S`: save / sync now +- In text fields: + - `Shift`+arrows, `Home`, `End`: select + - `Ctrl`+arrows: move by word + - `Ctrl+A`: select all + - `Ctrl+C` / `X` / `V`: copy, cut, paste + - `Ctrl+Backspace`: delete a word + - `Enter` submits a single-line field; `Ctrl+Enter` leaves a multi-line one + +## Testing + +The native client can be scripted, for screenshots and regression checks: + +``` +bin/TaskForge.exe test.taskforge --sim "wait 2;click 710 230;wait 2;type Hello;shot out.bmp;quit" +``` + +Commands: `move X Y`, `down`, `up`, `click X Y`, `type TEXT`, `key CODE [MODS]`, `wheel LINES`, `wait N`, `shot FILE.bmp`, `quit`. diff --git a/build.jai b/build.jai new file mode 100644 index 0000000..4ba0b4d --- /dev/null +++ b/build.jai @@ -0,0 +1,215 @@ +// TaskForge build metaprogram. +// +// jai build.jai -> native Windows build into bin/ +// jai build.jai - release -> optimized native build (LLVM) +// jai build.jai - web -> WASM64 build into web/ +// jai build.jai - server -> server for this OS into server/bin/ (optimized) +// jai build.jai - server-linux -> Linux x64 server object file server/bin/taskforge-server.o, +// linked on the droplet with: cc taskforge-server.o -o taskforge-server -lm -ldl -lpthread +// +// Every build also (re)bakes the font atlas from data/Karla-Regular.ttf using FreeType at compile time, +// so the app itself never needs a font rasterizer at runtime (important for the browser build). + +#run build(); + +build :: () { + set_build_options_dc(.{do_output = false}); + + args := get_build_options().compile_time_command_line; + web := array_find(args, "web"); + release := array_find(args, "release"); + + root := #filepath; + + if array_find(args, "server") || array_find(args, "server-linux") { + build_server(root, array_find(args, "server-linux")); + return; + } + + font_bytes, ok := bake_fonts(tprint("%data/Karla-Regular.ttf", root)); + if !ok { + compiler_report("Font baking failed."); + return; + } + out_dir := ifx web then "web/" else "bin/"; + make_directory_if_it_does_not_exist(tprint("%1%2", root, out_dir)); + write_entire_file(tprint("%1%2fonts.bin", root, out_dir), font_bytes); + + w := compiler_create_workspace(ifx web then "TaskForge Web" else "TaskForge"); + options := get_build_options(w); + copy_commonly_propagated_fields(get_build_options(), *options); + + options.output_path = out_dir; + options.output_executable_name = ifx web then "taskforge" else "TaskForge"; + + import_paths: [..] string; + array_add(*import_paths, tprint("%modules", root)); + for options.import_path array_add(*import_paths, it); + options.import_path = import_paths; + + if web { + // Mirrors jai/examples/wasm. Needs a browser with WASM Memory64 (Chrome 133+, Firefox 134+). + options.output_type = .EXECUTABLE; + options.backend = .LLVM; + options.os_target = .WASM; + options.cpu_target = .CUSTOM; + options.emit_debug_info = .NONE; + options.backtrace_on_crash = .OFF; + options.llvm_options.target_system_features = "+bulk-memory"; + options.llvm_options.enable_split_modules = false; + options.llvm_options.function_sections = true; + STACK_SIZE :: 1024 * 1024; + options.additional_linker_arguments = .["--stack-first", "-z", tprint("stack-size=%", STACK_SIZE), tprint("%data/walloc.o", root)]; + if release set_optimization(*options, .OPTIMIZED); + } else { + if release { + options.backend = .LLVM; + set_optimization(*options, .OPTIMIZED); + } + } + + set_build_options(options, w); + if web remap_import(w, "*", "Default_Allocator", "Walloc"); + + add_build_file(tprint("%src/main.jai", root), w); + + #if OS == .WINDOWS { + // The native client uses libcurl (from the Jai distribution) to talk to the server. + if !web for get_build_options().import_path { + dll := tprint("%/Curl/windows/lib/libcurl.dll", it); + if file_exists(dll) { + copy_file(dll, tprint("%1%2libcurl.dll", root, out_dir)); + break; + } + } + } +} + +build_server :: (root: string, linux: bool) { + out_dir := tprint("%server/bin/", root); + make_directory_if_it_does_not_exist(out_dir); + w := compiler_create_workspace("TaskForge Server"); + options := get_build_options(w); + copy_commonly_propagated_fields(get_build_options(), *options); + options.output_path = out_dir; + options.output_executable_name = "taskforge-server"; + options.backend = .LLVM; + set_optimization(*options, .OPTIMIZED); + if linux { + // There are no Linux system libraries on this machine to link against, so we emit an object file + // (with the runtime entry point) and link it on the server, where libc etc. exist. + options.os_target = .LINUX; + options.cpu_target = .X64; + options.output_type = .OBJECT_FILE; + options.runtime_support_definitions = .ENTRY_POINT_AND_INIT; + } + set_build_options(options, w); + add_build_file(tprint("%server/server.jai", root), w); + #if OS == .WINDOWS { + if !linux copy_file(tprint("%server/dev/sqlite3.dll", root), tprint("%sqlite3.dll", out_dir)); + } +} + +// +// Font atlas baking. +// +// File layout (little endian): +// u32 magic 'TFNT', s32 atlas_w, s32 atlas_h, s32 font_count +// per font: f32 size, f32 scale, f32 ascent, f32 descent, f32 line_height, s32 first_char, s32 glyph_count +// per glyph: s16 x, y, w, h, bearing_x, bearing_y; f32 advance +// u8 pixels[atlas_w * atlas_h] (single-channel coverage) +// +FONT_SIZES :: float.[12, 14, 16, 20, 26]; // Logical sizes used by the UI. Must match Font_Size in src/font.jai. +FONT_SCALES :: float.[1, 2]; // Baked for 1x and 2x displays. +FIRST_CHAR :: 32; +LAST_CHAR :: 255; // ASCII + Latin-1. +ATLAS_W :: 1024; +ATLAS_MAX_H :: 4096; + +bake_fonts :: (ttf_path: string) -> string, bool { + ttf, ok := read_entire_file(ttf_path); + if !ok return "", false; + + lib: FT_Library; + if FT_Init_FreeType(*lib) return "", false; + face: FT_Face; + if FT_New_Memory_Face(lib, ttf.data, cast(FT_Long) ttf.count, 0, *face) return "", false; + + pixels := NewArray(ATLAS_W * ATLAS_MAX_H, u8); + meta: String_Builder; + + pen_x, pen_y, row_h := 1, 1, 0; + font_count := 0; + + for scale: FONT_SCALES { + for size: FONT_SIZES { + px := cast(u32) (size * scale + 0.5); + FT_Set_Pixel_Sizes(face, 0, px); + m := face.size.metrics; + put(*meta, size); + put(*meta, scale); + put(*meta, cast(float) m.ascender / 64.0); + put(*meta, cast(float) m.descender / 64.0); + put(*meta, cast(float) m.height / 64.0); + put(*meta, cast(s32) FIRST_CHAR); + put(*meta, cast(s32) (LAST_CHAR - FIRST_CHAR + 1)); + + for c: FIRST_CHAR..LAST_CHAR { + gx, gy, gw, gh, bx, by: s16; + advance: float; + if FT_Load_Char(face, cast(FT_ULong) c, FT_LOAD_RENDER) == 0 { + g := face.glyph; + bm := g.bitmap; + w := cast(int) bm.width; + h := cast(int) bm.rows; + if pen_x + w + 1 >= ATLAS_W { + pen_x = 1; + pen_y += row_h + 1; + row_h = 0; + } + assert(pen_y + h < ATLAS_MAX_H); + for y: 0..h-1 { + src := bm.buffer + y * bm.pitch; + dst := pixels.data + (pen_y + y) * ATLAS_W + pen_x; + memcpy(dst, src, w); + } + gx, gy, gw, gh = xx pen_x, xx pen_y, xx w, xx h; + bx, by = xx g.bitmap_left, xx g.bitmap_top; + advance = cast(float) g.advance.x / 64.0; + pen_x += w + 1; + row_h = max(row_h, h); + } + put(*meta, gx); put(*meta, gy); put(*meta, gw); put(*meta, gh); + put(*meta, bx); put(*meta, by); put(*meta, advance); + } + font_count += 1; + } + } + + atlas_h := pen_y + row_h + 1; + atlas_h = (atlas_h + 3) & ~3; + + out: String_Builder; + put(*out, cast(u32) 0x544E4654); // 'TFNT' + put(*out, cast(s32) ATLAS_W); + put(*out, cast(s32) atlas_h); + put(*out, cast(s32) font_count); + meta_string := builder_to_string(*meta); + append(*out, meta_string); + append(*out, pixels.data, ATLAS_W * atlas_h); + + FT_Done_Face(face); + FT_Done_FreeType(lib); + return builder_to_string(*out), true; +} + +put :: (b: *String_Builder, value: $T) { + v := value; + append(b, cast(*u8) *v, size_of(T)); +} + +#import "Basic"; +#import "Compiler"; +#import "File"; +#import "File_Utilities"; +#import "freetype"; diff --git a/data/Karla-Regular.ttf b/data/Karla-Regular.ttf new file mode 100644 index 0000000..a08cc9b Binary files /dev/null and b/data/Karla-Regular.ttf differ diff --git a/data/walloc.o b/data/walloc.o new file mode 100644 index 0000000..a3252cc Binary files /dev/null and b/data/walloc.o differ diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..2202591 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,5 @@ +# Caddy terminates HTTPS (certificates from Let's Encrypt, renewed automatically) and forwards to TaskForge. +{DOMAIN} { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..1efc8ba --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Builds TaskForge and deploys it to a droplet. Run from Git Bash (or any shell with jai, ssh and scp): +# +# deploy/deploy.sh root@ tasks.example.com first time (sets up the droplet for that domain) +# deploy/deploy.sh root@ later updates +# +# The server is cross-compiled to a Linux object file here and linked on the droplet (which has libc etc.). +set -euo pipefail + +HOST="${1:?usage: deploy.sh user@host [domain]}" +DOMAIN="${2:-}" +cd "$(dirname "$0")/.." + +jai build.jai - web release +jai build.jai - server-linux + +ssh "$HOST" 'mkdir -p /opt/taskforge/staging' +scp server/bin/taskforge-server.o web/index.html web/taskforge.js web/taskforge.wasm web/fonts.bin \ + deploy/setup-droplet.sh deploy/taskforge.service deploy/Caddyfile \ + "$HOST":/opt/taskforge/staging/ + +ssh "$HOST" "set -e +cd /opt/taskforge/staging +if [ ! -f /etc/systemd/system/taskforge.service ]; then + if [ -z '$DOMAIN' ]; then echo 'First deploy: pass your domain as the second argument.'; exit 1; fi + bash setup-droplet.sh '$DOMAIN' +fi +cc taskforge-server.o -o taskforge-server -no-pie -lm -ldl -lpthread +install -m 755 taskforge-server /opt/taskforge/bin/taskforge-server +install -m 644 index.html taskforge.js taskforge.wasm fonts.bin /opt/taskforge/web/ +systemctl restart taskforge +sleep 1 +systemctl --no-pager --lines=5 status taskforge" diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh new file mode 100644 index 0000000..dec51fe --- /dev/null +++ b/deploy/setup-droplet.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# One-time setup of a fresh Ubuntu droplet (22.04 or 24.04) for TaskForge. +# deploy.sh runs this automatically on the first deploy. By hand, on the droplet as root: +# bash setup-droplet.sh tasks.example.com (with taskforge.service and Caddyfile next to it) +# +# Installs: SQLite, a C toolchain (to link the server), Caddy (HTTPS via Let's Encrypt), a firewall, +# a systemd service and a daily database backup. Point your domain's A record at the droplet first. +set -euo pipefail + +DOMAIN="${1:?usage: setup-droplet.sh }" + +apt-get update +apt-get install -y libsqlite3-0 sqlite3 build-essential curl gnupg ufw \ + debian-keyring debian-archive-keyring apt-transport-https + +# Caddy from its official repository. +if ! command -v caddy >/dev/null; then + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list + apt-get update + apt-get install -y caddy +fi + +# Service account and directories. +id taskforge >/dev/null 2>&1 || useradd --system --home-dir /var/lib/taskforge --shell /usr/sbin/nologin taskforge +mkdir -p /opt/taskforge/bin /opt/taskforge/web /opt/taskforge/staging /var/lib/taskforge /var/backups/taskforge +chown taskforge:taskforge /var/lib/taskforge +chmod 750 /var/lib/taskforge /var/backups/taskforge + +HERE="$(cd "$(dirname "$0")" && pwd)" +install -m 644 "$HERE/taskforge.service" /etc/systemd/system/taskforge.service +sed "s/{DOMAIN}/$DOMAIN/" "$HERE/Caddyfile" > /etc/caddy/Caddyfile + +# Daily consistent backup (safe while the server runs), kept for 14 days. +cat > /etc/cron.daily/taskforge-backup <<'EOF' +#!/bin/sh +sqlite3 /var/lib/taskforge/taskforge.db ".backup '/var/backups/taskforge/taskforge-$(date +%F).db'" +find /var/backups/taskforge -name 'taskforge-*.db' -mtime +14 -delete +EOF +chmod 755 /etc/cron.daily/taskforge-backup + +ufw allow OpenSSH +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable + +systemctl daemon-reload +systemctl enable taskforge +systemctl reload caddy || systemctl restart caddy + +echo +echo "Droplet set up for https://$DOMAIN" +echo "After the server binary is installed, create the first admin on the droplet:" +echo " sudo -u taskforge /opt/taskforge/bin/taskforge-server --db /var/lib/taskforge/taskforge.db add-user \"\" --admin" diff --git a/deploy/taskforge.service b/deploy/taskforge.service new file mode 100644 index 0000000..d18d4e2 --- /dev/null +++ b/deploy/taskforge.service @@ -0,0 +1,21 @@ +[Unit] +Description=TaskForge server +After=network.target + +[Service] +User=taskforge +Group=taskforge +WorkingDirectory=/var/lib/taskforge +ExecStart=/opt/taskforge/bin/taskforge-server --db /var/lib/taskforge/taskforge.db --web /opt/taskforge/web --listen 127.0.0.1 --port 8080 +Restart=always +RestartSec=2 + +# Hardening: the server can only write its database directory. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/taskforge + +[Install] +WantedBy=multi-user.target diff --git a/modules/Walloc.jai b/modules/Walloc.jai new file mode 100644 index 0000000..945ee7c --- /dev/null +++ b/modules/Walloc.jai @@ -0,0 +1,65 @@ +#scope_module + +walloc_malloc :: (size: s64) -> *void #foreign "malloc"; // Will be provided by WASM +walloc_free :: (p: *void) #foreign "free"; // Will be provided by WASM + +#scope_export + +walloc_allocator :: Allocator.{allocator_proc, null}; + +allocator_proc :: (mode: Allocator_Mode, requested_size: s64, old_size: s64, old_memory: *void, allocator_data: *void) -> *void { + if #complete mode == { + case .STARTUP; #through; + case .SHUTDOWN; #through; + case .THREAD_START; #through; + case .THREAD_STOP; + return null; + + case .ALLOCATE; #through; + case .RESIZE; + // @TODO: Can we support proper realloc? + result := walloc_malloc(requested_size); + if mode == .RESIZE { + size_to_copy := min(old_size, requested_size); + if result && size_to_copy memcpy(result, old_memory, size_to_copy); + } + return result; + + case .FREE; + walloc_free(old_memory); + return null; + + case .CREATE_HEAP; #through; + case .DESTROY_HEAP; + context.handling_assertion_failure = true; + context.assertion_failed(#location(), "This allocator does not support multiple heaps.\n"); + context.handling_assertion_failure = false; + return null; + + + case .IS_THIS_YOURS; + context.handling_assertion_failure = true; + context.assertion_failed(#location(), "This allocator does not support IS_THIS_YOURS.\n"); + context.handling_assertion_failure = false; + return null; + + case .CAPS; + if old_memory { (.*)cast(*string)old_memory = CAPS_VERSION_STRING; } + return cast(*void) (Allocator_Caps.HINT_I_AM_A_GENERAL_HEAP_ALLOCATOR|.MULTIPLE_THREADS|.FREE|.ACTUALLY_RESIZE); + + case; + context.handling_assertion_failure = true; + context.assertion_failed(#location(), "Invalid or corrupt mode passed to Walloc.allocator_proc().\n"); + context.handling_assertion_failure = false; + return null; + } +} + +#scope_file + +CAPS_VERSION_STRING :: "Walloc"; + +min :: (a: $T, b: T) -> T { + if a < b return a; + return b; +} diff --git a/project.taskforge b/project.taskforge new file mode 100644 index 0000000..69d52af --- /dev/null +++ b/project.taskforge @@ -0,0 +1,107 @@ +taskforge 1 +project "My Game" +next_id 58 +field 1 3 0 "Status" +option 1 2 8b949e "Backlog" +option 1 3 58a6ff "To do" +option 1 4 d29922 "In progress" +option 1 5 bc8cff "Review" +option 1 6 3fb950 "Done" +field 7 4 1 "Discipline" +option 7 8 58a6ff "Code" +option 7 9 f778ba "Art" +option 7 10 d29922 "Design" +option 7 11 39c5cf "Audio" +option 7 12 a371f7 "Writing" +option 7 13 db6d28 "QA" +field 14 3 0 "Priority" +option 14 15 8b949e "Low" +option 14 16 58a6ff "Medium" +option 14 17 db6d28 "High" +option 14 18 f85149 "Critical" +field 19 1 1 "Estimate (h)" +field 20 0 0 "Assignee" +field 21 3 2 "Art" +option 21 22 8b949e "Not started" +option 21 23 d29922 "In progress" +option 21 24 3fb950 "Done" +field 25 3 2 "Code" +option 25 26 8b949e "Not started" +option 25 27 d29922 "In progress" +option 25 28 3fb950 "Done" +field 29 3 2 "Audio" +option 29 30 8b949e "Not started" +option 29 31 d29922 "In progress" +option 29 32 3fb950 "Done" +task 33 1 0 1 "Level 1: Forest" +val 33 1 sel 4 +val 33 21 sel 23 +val 33 25 sel 27 +val 33 29 sel 30 +task 34 1 0 2 "Combat system" +val 34 1 sel 4 +val 34 21 sel 23 +val 34 25 sel 27 +val 34 29 sel 31 +task 35 0 33 3 "Block out level layout" +val 35 1 sel 6 +val 35 7 tags 10 +val 35 14 sel 16 +val 35 19 num "6" +task 36 0 33 4 "Tree and rock props" +val 36 1 sel 4 +val 36 7 tags 9 +val 36 14 sel 16 +val 36 19 num "12" +task 37 0 33 5 "Forest ground textures" +val 37 1 sel 3 +val 37 7 tags 9 +val 37 19 num "8" +task 38 0 33 6 "Ambient forest soundscape" +val 38 1 sel 2 +val 38 7 tags 11 +val 38 19 num "4" +task 39 0 33 7 "Level streaming triggers" +val 39 1 sel 5 +val 39 7 tags 8 +val 39 14 sel 17 +val 39 19 num "5" +task 40 0 34 8 "Melee hit detection" +val 40 1 sel 4 +val 40 7 tags 8 +val 40 14 sel 17 +val 40 19 num "10" +task 41 0 34 9 "Enemy attack animations" +val 41 1 sel 3 +val 41 7 tags 9 +val 41 14 sel 16 +val 41 19 num "16" +task 42 0 34 10 "Combo input buffering" +val 42 1 sel 2 +val 42 7 tags 8 +val 42 19 num "6" +task 43 0 34 11 "Hit sound effects" +val 43 1 sel 3 +val 43 7 tags 11 +val 43 19 num "3" +view 44 0 0 1 1 6 0 0 0 0 "Board" +viewfield 44 7 +viewfield 44 14 +viewfield 44 19 +view 45 1 0 1 1 6 1 0 7 9 "Art checklist" +viewfield 45 1 +view 46 0 1 1 1 6 0 0 0 0 "Epics" +viewfield 46 21 +viewfield 46 25 +viewfield 46 29 +rule 47 0 1 6 0 0 0 1 0 6 +rule 48 0 1 4 1 0 0 1 1 2 +rule 49 0 21 24 0 7 9 1 0 6 +rule 50 0 21 23 1 7 9 1 1 2 +rule 51 0 21 22 2 7 9 1 1 2 +rule 52 0 25 28 0 7 8 1 0 6 +rule 53 0 25 27 1 7 8 1 1 2 +rule 54 0 25 26 2 7 8 1 1 2 +rule 55 0 29 32 0 7 11 1 0 6 +rule 56 0 29 31 1 7 11 1 1 2 +rule 57 0 29 30 2 7 11 1 1 2 diff --git a/server/crypto.jai b/server/crypto.jai new file mode 100644 index 0000000..7443bcb --- /dev/null +++ b/server/crypto.jai @@ -0,0 +1,208 @@ +// SHA-256, HMAC-SHA256, PBKDF2-HMAC-SHA256 and OS randomness. Used for password hashing and session tokens. + +Sha256 :: struct { + state: [8] u32; + block: [64] u8; + used: int; + length: u64; +} + +SHA256_K :: u32.[ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +sha256_init :: (s: *Sha256) { + s.state = .[0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + s.used = 0; + s.length = 0; +} + +rotr :: inline (x: u32, n: u32) -> u32 { + return (x >> n) | (x << (32 - n)); +} + +sha256_compress :: (s: *Sha256, block: *u8) { + w: [64] u32 = ---; + for i: 0..15 { + p := block + i * 4; + w[i] = ((cast(u32) p[0]) << 24) | ((cast(u32) p[1]) << 16) | ((cast(u32) p[2]) << 8) | (cast(u32) p[3]); + } + for i: 16..63 { + s0 := rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3); + s1 := rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10); + w[i] = w[i-16] + s0 + w[i-7] + s1; + } + a, b, c, d := s.state[0], s.state[1], s.state[2], s.state[3]; + e, f, g, h := s.state[4], s.state[5], s.state[6], s.state[7]; + for i: 0..63 { + S1 := rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + ch := (e & f) ^ (~e & g); + t1 := h + S1 + ch + SHA256_K[i] + w[i]; + S0 := rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + mj := (a & b) ^ (a & c) ^ (b & c); + t2 := S0 + mj; + h = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + s.state[0] += a; s.state[1] += b; s.state[2] += c; s.state[3] += d; + s.state[4] += e; s.state[5] += f; s.state[6] += g; s.state[7] += h; +} + +sha256_update :: (s: *Sha256, data: *u8, count: int) { + s.length += cast(u64) count; + i := 0; + while i < count { + n := min(64 - s.used, count - i); + memcpy(s.block.data + s.used, data + i, n); + s.used += n; + i += n; + if s.used == 64 { + sha256_compress(s, s.block.data); + s.used = 0; + } + } +} + +sha256_update :: inline (s: *Sha256, data: string) { + sha256_update(s, data.data, data.count); +} + +sha256_final :: (s: *Sha256) -> [32] u8 { + bits := s.length * 8; + pad: [72] u8; + pad[0] = 0x80; + pad_len := ifx s.used < 56 then 56 - s.used else 120 - s.used; + for i: 0..7 pad[pad_len + i] = cast(u8) (bits >> cast(u64) ((7 - i) * 8)); + sha256_update(s, pad.data, pad_len + 8); + result: [32] u8; + for i: 0..7 { + result[i*4 + 0] = cast(u8) (s.state[i] >> 24); + result[i*4 + 1] = cast(u8) (s.state[i] >> 16); + result[i*4 + 2] = cast(u8) (s.state[i] >> 8); + result[i*4 + 3] = cast(u8) s.state[i]; + } + return result; +} + +sha256 :: (data: string) -> [32] u8 { + s: Sha256; + sha256_init(*s); + sha256_update(*s, data); + return sha256_final(*s); +} + +// HMAC with the inner/outer pad states precomputed, so PBKDF2 costs two compressions per iteration. +Hmac_Sha256 :: struct { + inner, outer: Sha256; +} + +hmac_init :: (h: *Hmac_Sha256, key: string) { + k: [64] u8; + if key.count > 64 { + digest := sha256(key); + memcpy(k.data, digest.data, 32); + } else { + memcpy(k.data, key.data, key.count); + } + ipad, opad: [64] u8; + for i: 0..63 { + ipad[i] = k[i] ^ 0x36; + opad[i] = k[i] ^ 0x5c; + } + sha256_init(*h.inner); + sha256_update(*h.inner, ipad.data, 64); + sha256_init(*h.outer); + sha256_update(*h.outer, opad.data, 64); +} + +hmac_compute :: (h: *Hmac_Sha256, message: *u8, count: int) -> [32] u8 { + inner := h.inner; + sha256_update(*inner, message, count); + digest := sha256_final(*inner); + outer := h.outer; + sha256_update(*outer, digest.data, 32); + return sha256_final(*outer); +} + +hmac_sha256 :: (key: string, message: string) -> [32] u8 { + h: Hmac_Sha256; + hmac_init(*h, key); + return hmac_compute(*h, message.data, message.count); +} + +// PBKDF2-HMAC-SHA256 with a 32-byte output (one block). +pbkdf2_sha256 :: (password: string, salt: string, iterations: int) -> [32] u8 { + h: Hmac_Sha256; + hmac_init(*h, password); + first := alloc_string(salt.count + 4,, temp); + memcpy(first.data, salt.data, salt.count); + first[salt.count + 0] = 0; + first[salt.count + 1] = 0; + first[salt.count + 2] = 0; + first[salt.count + 3] = 1; + u := hmac_compute(*h, first.data, first.count); + result := u; + for 2..iterations { + u = hmac_compute(*h, u.data, 32); + for i: 0..31 result[i] ^= u[i]; + } + return result; +} + +constant_time_equal :: (a: string, b: string) -> bool { + if a.count != b.count return false; + diff: u8; + for i: 0..a.count-1 diff |= a[i] ^ b[i]; + return diff == 0; +} + +to_hex :: (data: *u8, count: int) -> string { + DIGITS :: "0123456789abcdef"; + result := alloc_string(count * 2); + for i: 0..count-1 { + result[i*2] = DIGITS[data[i] >> 4]; + result[i*2 + 1] = DIGITS[data[i] & 0xF]; + } + return result; +} + +as_string :: (bytes: *[32] u8) -> string { + s: string; + s.data = bytes.data; + s.count = 32; + return s; +} + +// +// Randomness from the OS. +// + +#if OS == .WINDOWS { + tf_bcrypt :: #system_library "bcrypt"; + BCryptGenRandom :: (algorithm: *void, buffer: *u8, count: u32, flags: u32) -> s32 #foreign tf_bcrypt; + + random_bytes :: (buffer: *u8, count: int) -> bool { + BCRYPT_USE_SYSTEM_PREFERRED_RNG :: 2; + return BCryptGenRandom(null, buffer, cast(u32) count, BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0; + } +} else { + tf_libc :: #system_library "libc"; + getrandom :: (buffer: *void, count: u64, flags: u32) -> s64 #foreign tf_libc; + + random_bytes :: (buffer: *u8, count: int) -> bool { + done := 0; + while done < count { + n := getrandom(buffer + done, cast(u64) (count - done), 0); + if n <= 0 return false; + done += n; + } + return true; + } +} diff --git a/server/dev/sqlite3.dll b/server/dev/sqlite3.dll new file mode 100644 index 0000000..7e19353 Binary files /dev/null and b/server/dev/sqlite3.dll differ diff --git a/server/server.jai b/server/server.jai new file mode 100644 index 0000000..b5fcf58 --- /dev/null +++ b/server/server.jai @@ -0,0 +1,786 @@ +// TaskForge server: users, sessions and the shared project, stored in SQLite (WAL mode). +// Also serves the web client. Meant to sit behind Caddy (or nginx) for HTTPS. +// +// taskforge-server [--port 8080] [--listen 127.0.0.1] [--db taskforge.db] [--web ./web] [--threads 8] [--demo] +// taskforge-server [--db taskforge.db] add-user "" [--admin] (password read from stdin) +// taskforge-server [--db taskforge.db] set-password (password read from stdin) +// taskforge-server [--db taskforge.db] list-users +// +// Sync model: the project is a set of records (one text line per entity, see src/storage.jai), each with a key +// like "task 42" or "val 42 7". Every write bumps a global revision; clients send their changed records and +// receive everything changed since the revision they last saw. Last writer wins per record. + +Config :: struct { + port: u16 = 8080; + listen_on := "127.0.0.1"; + db_path := "taskforge.db"; + web_dir := "web"; + threads: s32 = 8; + demo: bool; +} + +config: Config; +listen_socket: Socket; + +PBKDF2_ITERATIONS :: 310000; +SESSION_DAYS :: 30; +ID_BLOCK :: 1000; +MAX_BODY :: 16 * 1024 * 1024; + +main :: () { + args := get_command_line_arguments(); + command := "serve"; + positional: [..] string; + admin_flag := false; + i := 1; + while i < args.count { + a := args[i]; + has_value := i + 1 < args.count; + if a == "--port" && has_value { config.port = cast(u16) string_to_int(args[i + 1]); i += 2; continue; } + if a == "--listen" && has_value { config.listen_on = args[i + 1]; i += 2; continue; } + if a == "--db" && has_value { config.db_path = args[i + 1]; i += 2; continue; } + if a == "--web" && has_value { config.web_dir = args[i + 1]; i += 2; continue; } + if a == "--threads" && has_value { config.threads = cast(s32) string_to_int(args[i + 1]); i += 2; continue; } + if a == "--demo" { config.demo = true; i += 1; continue; } + if a == "--admin" { admin_flag = true; i += 1; continue; } + if a == "--help" || a == "-h" { print_usage(); return; } + if !positional.count && (a == "serve" || a == "add-user" || a == "set-password" || a == "list-users") { + command = a; + array_add(*positional, a); + } else { + array_add(*positional, a); + } + i += 1; + } + + if !load_sqlite() exit(1); + db, ok := db_open(config.db_path); + if !ok exit(1); + if !migrate(db) exit(1); + + if command == { + case "add-user"; + if positional.count < 3 { print_usage(); exit(1); } + password := read_password(); + error := add_user(db, positional[1], positional[2], password, admin_flag); + if error { log_error("%", error); exit(1); } + print("Added user %.\n", positional[1]); + case "set-password"; + if positional.count < 2 { print_usage(); exit(1); } + id := find_user_id(db, positional[1]); + if !id { log_error("No user named %.", positional[1]); exit(1); } + password := read_password(); + error := set_password(db, id, password); + if error { log_error("%", error); exit(1); } + print("Password updated.\n"); + case "list-users"; + s := prepare(db, "SELECT id, username, display_name, is_admin, active FROM users ORDER BY id"); + while step(s) print("% % \"%\"% %\n", column_int(s, 0), column_text(s, 1), column_text(s, 2), + ifx column_int(s, 3) then " admin" else "", ifx column_int(s, 4) then "" else "(deactivated)"); + finalize(s); + case; + serve(); + } +} + +print_usage :: () { + print(#string TXT +Usage: + taskforge-server [--port 8080] [--listen 127.0.0.1] [--db taskforge.db] [--web ./web] [--threads 8] [--demo] + taskforge-server [--db taskforge.db] add-user "" [--admin] (reads password from stdin) + taskforge-server [--db taskforge.db] set-password (reads password from stdin) + taskforge-server [--db taskforge.db] list-users +TXT); +} + +// +// Database +// + +SCHEMA :: #string SQL +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS records ( + key TEXT PRIMARY KEY, + line TEXT NOT NULL, + rev INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + user_id INTEGER +); +CREATE INDEX IF NOT EXISTS records_by_rev ON records(rev); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL, + pw_hash BLOB NOT NULL, + pw_salt BLOB NOT NULL, + pw_iter INTEGER NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + created INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash BLOB PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_by_user ON sessions(user_id); +CREATE TABLE IF NOT EXISTS login_failures ( + username TEXT NOT NULL COLLATE NOCASE, + at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS login_failures_by_name ON login_failures(username, at); +SQL + +migrate :: (db: Db) -> bool { + if !db_exec(db, SCHEMA) return false; + if get_meta(db, "seeded") return true; + + // First start: seed the project. + p := starter_project(config.demo); + lines: [..] string; + project_records(p, *lines); + db_exec(db, "BEGIN IMMEDIATE;"); + s := prepare(db, "INSERT OR REPLACE INTO records(key, line, rev, deleted) VALUES (?, ?, 1, 0)"); + for lines { + bind(s, 1, record_key(it)); + bind(s, 2, it); + run(s); + reset(s); + } + finalize(s); + set_meta(db, "rev", 1); + set_meta(db, "next_id", p.next_id); + set_meta(db, "users_rev", 1); + set_meta(db, "seeded", 1); + db_exec(db, "COMMIT;"); + log("Created a new % project in %.", ifx config.demo then "demo" else "starter", config.db_path); + return true; +} + +get_meta :: (db: Db, key: string) -> s64 { + s := prepare(db, "SELECT value FROM meta WHERE key = ?"); + bind(s, 1, key); + value: s64; + if step(s) value = column_int(s, 0); + finalize(s); + return value; +} + +set_meta :: (db: Db, key: string, value: s64) { + s := prepare(db, "INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"); + bind(s, 1, key); + bind(s, 2, value); + run(s); + finalize(s); +} + +NOW_SQL :: "CAST(strftime('%s', 'now') AS INTEGER)"; + +// +// Users +// + +valid_username :: (name: string) -> bool { + if name.count < 1 || name.count > 32 return false; + for i: 0..name.count-1 { + c := name[i]; + ok := (c >= #char "a" && c <= #char "z") || (c >= #char "A" && c <= #char "Z") || (c >= #char "0" && c <= #char "9") || + c == #char "." || c == #char "_" || c == #char "-"; + if !ok return false; + } + return true; +} + +hash_password :: (password: string, salt: string, iterations: int) -> string { + digest := pbkdf2_sha256(password, salt, iterations); + result := alloc_string(32,, temp); + memcpy(result.data, digest.data, 32); + return result; +} + +new_salt :: () -> string { + salt := alloc_string(16,, temp); + random_bytes(salt.data, 16); + return salt; +} + +add_user :: (db: Db, username: string, display_name: string, password: string, admin: bool) -> error: string { + if !valid_username(username) return "Usernames are 1-32 characters: letters, digits, '.', '_' or '-'."; + if !display_name.count return "Display name can't be empty."; + if password.count < 8 return "Passwords need at least 8 characters."; + if find_user_id(db, username) return "That username is taken."; + salt := new_salt(); + s := prepare(db, tprint("INSERT INTO users(username, display_name, pw_hash, pw_salt, pw_iter, is_admin, active, created) VALUES (?, ?, ?, ?, ?, ?, 1, %)", NOW_SQL)); + bind(s, 1, username); + bind(s, 2, display_name); + bind_blob(s, 3, hash_password(password, salt, PBKDF2_ITERATIONS)); + bind_blob(s, 4, salt); + bind(s, 5, PBKDF2_ITERATIONS); + bind(s, 6, cast(s64) admin); + ok := run(s); + finalize(s); + if !ok return "Could not add the user."; + bump_users_rev(db); + return ""; +} + +set_password :: (db: Db, user_id: s64, password: string, keep_session := "") -> error: string { + if password.count < 8 return "Passwords need at least 8 characters."; + salt := new_salt(); + s := prepare(db, "UPDATE users SET pw_hash = ?, pw_salt = ?, pw_iter = ? WHERE id = ?"); + bind_blob(s, 1, hash_password(password, salt, PBKDF2_ITERATIONS)); + bind_blob(s, 2, salt); + bind(s, 3, PBKDF2_ITERATIONS); + bind(s, 4, user_id); + run(s); + finalize(s); + // Changing a password signs out every other session of that user. + s = prepare(db, "DELETE FROM sessions WHERE user_id = ? AND token_hash != ?"); + bind(s, 1, user_id); + bind_blob(s, 2, keep_session); + run(s); + finalize(s); + return ""; +} + +find_user_id :: (db: Db, username: string) -> s64 { + s := prepare(db, "SELECT id FROM users WHERE username = ?"); + bind(s, 1, username); + id: s64; + if step(s) id = column_int(s, 0); + finalize(s); + return id; +} + +check_password :: (db: Db, user_id: s64, password: string) -> bool { + s := prepare(db, "SELECT pw_hash, pw_salt, pw_iter FROM users WHERE id = ?"); + bind(s, 1, user_id); + ok := false; + if step(s) ok = constant_time_equal(hash_password(password, column_text(s, 1), column_int(s, 2)), column_text(s, 0)); + finalize(s); + return ok; +} + +bump_users_rev :: (db: Db) { + set_meta(db, "users_rev", get_meta(db, "users_rev") + 1); +} + +read_password :: () -> string { + print("Password: "); + line := read_stdin_line(); + // PowerShell prefixes piped input with a UTF-8 byte order mark. + if begins_with(line, "\xEF\xBB\xBF") advance(*line, 3); + while line.count && (line[line.count - 1] == #char "\n" || line[line.count - 1] == #char "\r") line.count -= 1; + return line; +} + +#if OS == .WINDOWS { + read_stdin_line :: () -> string { + tf_k32 :: #system_library "kernel32"; + GetStdHandle_ :: (which: s32) -> *void #foreign tf_k32 "GetStdHandle"; + ReadFile_ :: (file: *void, buffer: *u8, to_read: u32, did_read: *u32, overlapped: *void) -> s32 #foreign tf_k32 "ReadFile"; + handle := GetStdHandle_(-10); + b: String_Builder; + c: u8; + n: u32; + while ReadFile_(handle, *c, 1, *n, null) && n == 1 { + if c == #char "\n" break; + append(*b, c); + } + return builder_to_string(*b); + } +} else { + read_stdin_line :: () -> string { + b: String_Builder; + c: u8; + while read(0, *c, 1) == 1 { + if c == #char "\n" break; + append(*b, c); + } + return builder_to_string(*b); + } +} + +// +// HTTP +// + +serve :: () { + if !socket_init() exit(1); + listen_socket = socket(AF_INET, .STREAM, .TCP); + if listen_socket == INVALID_SOCKET { log_error("socket() failed"); exit(1); } + yes: s32 = 1; + setsockopt(listen_socket, SOL_SOCKET, SO_REUSEADDR, xx *yes, size_of(s32)); + + addr: sockaddr_in; + addr.sin_family = AF_INET; + addr.sin_port = htons(config.port); + ip := inet_addr(temp_c_string(config.listen_on)); + memcpy(*addr.sin_addr, *ip, 4); + if bind(listen_socket, cast(*sockaddr) *addr, size_of(sockaddr_in)) != 0 { + log_error("Could not bind %:% (already in use?)", config.listen_on, config.port); + exit(1); + } + if listen(listen_socket, 128) != 0 { log_error("listen() failed"); exit(1); } + + log("TaskForge server listening on http://%:% (db: %, web: %, % threads, SQLite %)", + config.listen_on, config.port, config.db_path, config.web_dir, config.threads, to_string(sql.sqlite3_libversion())); + + // Every worker blocks in accept() on the shared socket and owns its own SQLite connection. + threads := NewArray(max(config.threads, 1) - 1, Thread); + for * threads { + thread_init(it, (t: *Thread) -> s64 { worker(); return 0; }); + thread_start(it); + } + worker(); +} + +worker :: () { + db, ok := db_open(config.db_path); + if !ok return; + while true { + client := accept(listen_socket, null, null); + if client == INVALID_SOCKET continue; + set_timeouts(client, 15); + handle_connection(db, client); + #if OS == .WINDOWS closesocket(client); + else close(client); + reset_temporary_storage(); + } +} + +set_timeouts :: (s: Socket, seconds: s32) { + #if OS == .WINDOWS { + ms: u32 = cast(u32) seconds * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, xx *ms, size_of(u32)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, xx *ms, size_of(u32)); + } else { + tv: timeval; + tv.tv_sec = seconds; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, *tv, size_of(timeval)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, *tv, size_of(timeval)); + } +} + +Request :: struct { + method: string; + path: string; + authorization: string; + body: string; +} + +handle_connection :: (db: Db, s: Socket) { + request, ok := read_request(s); + if !ok { + respond(s, 400, "text/plain", "Bad request\n"); + return; + } + + if request.method == "GET" { + if request.path == "/api/info" { + respond(s, 200, "text/plain", "taskforge-server 1\n"); + return; + } + serve_static(s, request.path); + return; + } + if request.method != "POST" { + respond(s, 405, "text/plain", "Method not allowed\n"); + return; + } + + if request.path == { + case "/api/login"; handle_login(db, s, request); + case "/api/logout"; handle_logout(db, s, request); + case "/api/sync"; handle_sync(db, s, request); + case "/api/users"; handle_users(db, s, request); + case; respond(s, 404, "text/plain", "Not found\n"); + } +} + +read_request :: (s: Socket) -> Request, bool { + request: Request; + buffer: [..] u8; + buffer.allocator = temp; + header_end := -1; + chunk: [16384] u8; + while header_end < 0 { + n := recv(s, chunk.data, xx chunk.count, 0); + if n <= 0 return request, false; + old := buffer.count; + array_resize(*buffer, old + n, initialize = false); + memcpy(buffer.data + old, chunk.data, n); + // Look for the blank line ending the headers. + start := max(0, old - 3); + for i: start..buffer.count-4 { + if buffer[i] == #char "\r" && buffer[i+1] == #char "\n" && buffer[i+2] == #char "\r" && buffer[i+3] == #char "\n" { + header_end = i + 4; + break; + } + } + if header_end < 0 && buffer.count > 65536 return request, false; + } + + head: string; + head.data = buffer.data; + head.count = header_end; + lines := split(head, "\r\n",, temp); + if !lines.count return request, false; + parts := split(lines[0], " ",, temp); + if parts.count < 2 return request, false; + request.method = parts[0]; + request.path = parts[1]; + query := find_index_from_left(request.path, #char "?"); + if query >= 0 request.path.count = query; + + content_length := 0; + for line: lines { + colon := find_index_from_left(line, #char ":"); + if colon <= 0 continue; + name := to_lower_copy(substring(line, 0, colon),, temp); + value := trim(substring(line, colon + 1, line.count)); + if name == "content-length" content_length = string_to_int(value); + if name == "authorization" request.authorization = value; + } + if content_length < 0 || content_length > MAX_BODY return request, false; + + body := alloc_string(content_length,, temp); + have := min(buffer.count - header_end, content_length); + memcpy(body.data, buffer.data + header_end, have); + while have < content_length { + n := recv(s, body.data + have, xx min(content_length - have, 1 << 20), 0); + if n <= 0 return request, false; + have += n; + } + request.body = body; + return request, true; +} + +respond :: (s: Socket, status: int, content_type: string, body: string, extra_headers := "") { + reason := "OK"; + if status == { + case 400; reason = "Bad Request"; + case 401; reason = "Unauthorized"; + case 403; reason = "Forbidden"; + case 404; reason = "Not Found"; + case 405; reason = "Method Not Allowed"; + case 429; reason = "Too Many Requests"; + case 500; reason = "Internal Server Error"; + } + header := tprint("HTTP/1.1 % %\r\nContent-Type: %\r\nContent-Length: %\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\n%\r\n", + status, reason, content_type, body.count, extra_headers); + send_all(s, header); + send_all(s, body); +} + +send_all :: (s: Socket, data: string) { + sent := 0; + while sent < data.count { + n := send(s, data.data + sent, xx min(data.count - sent, 1 << 20), 0); + if n <= 0 return; + sent += n; + } +} + +serve_static :: (s: Socket, path: string) { + name := ifx path == "/" then "index.html" else substring(path, 1, path.count); + // Only plain file names from the web directory. + for i: 0..name.count-1 { + c := name[i]; + ok := (c >= #char "a" && c <= #char "z") || (c >= #char "A" && c <= #char "Z") || (c >= #char "0" && c <= #char "9") || + c == #char "." || c == #char "_" || c == #char "-"; + if !ok || (c == #char "." && i == 0) { + respond(s, 404, "text/plain", "Not found\n"); + return; + } + } + data, ok := read_entire_file(tprint("%/%", config.web_dir, name), log_errors = false); + if !ok { + respond(s, 404, "text/plain", "Not found\n"); + return; + } + type := "application/octet-stream"; + if ends_with(name, ".html") type = "text/html; charset=utf-8"; + else if ends_with(name, ".js") type = "text/javascript; charset=utf-8"; + else if ends_with(name, ".wasm") type = "application/wasm"; + else if ends_with(name, ".css") type = "text/css; charset=utf-8"; + respond(s, 200, type, data); + free(data); +} + +// +// API +// + +Session :: struct { + user_id: s64; + is_admin: bool; + token_hash: string; +} + +authenticate :: (db: Db, request: Request) -> Session, bool { + session: Session; + prefix :: "Bearer "; + if !begins_with(request.authorization, prefix) return session, false; + token := substring(request.authorization, prefix.count, request.authorization.count); + token_hash := sha256(token); + s := prepare(db, tprint("SELECT u.id, u.is_admin, s.expires - % FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires > % AND u.active = 1", NOW_SQL, NOW_SQL)); + bind_blob(s, 1, as_string(*token_hash)); + ok := step(s); + remaining: s64; + if ok { + session.user_id = column_int(s, 0); + session.is_admin = column_int(s, 1) != 0; + session.token_hash = copy_string(as_string(*token_hash),, temp); + remaining = column_int(s, 2); + } + finalize(s); + // Sliding expiry, refreshed at most about once a day. + if ok && remaining < (SESSION_DAYS - 1) * 86400 { + u := prepare(db, tprint("UPDATE sessions SET expires = % + % WHERE token_hash = ?", NOW_SQL, SESSION_DAYS * 86400)); + bind_blob(u, 1, as_string(*token_hash)); + run(u); + finalize(u); + } + return session, ok; +} + +handle_login :: (db: Db, s: Socket, request: Request) { + r := Line_Reader.{line = request.body}; + username := next_token(*r); + password := next_token(*r); + + // Throttle password guessing: 10 failures within 10 minutes locks the username for a while. + MAX_FAILURES :: 10; + WINDOW_SECONDS :: 600; + f := prepare(db, tprint("SELECT COUNT(*) FROM login_failures WHERE username = ? AND at > % - %", NOW_SQL, WINDOW_SECONDS)); + bind(f, 1, username); + failures: s64; + if step(f) failures = column_int(f, 0); + finalize(f); + if failures >= MAX_FAILURES { + respond(s, 429, "text/plain", "error \"Too many failed attempts. Try again in a few minutes.\"\n"); + return; + } + + q := prepare(db, "SELECT id, pw_hash, pw_salt, pw_iter, active FROM users WHERE username = ?"); + bind(q, 1, username); + found := step(q); + user_id: s64; + ok := false; + if found { + user_id = column_int(q, 0); + hash := hash_password(password, column_text(q, 2), column_int(q, 3)); + ok = constant_time_equal(hash, column_text(q, 1)) && column_int(q, 4) != 0; + } else { + hash_password(password, "timing-equalizer", PBKDF2_ITERATIONS); // Don't reveal which usernames exist. + } + finalize(q); + if !ok { + record := prepare(db, tprint("INSERT INTO login_failures(username, at) VALUES (?, %)", NOW_SQL)); + bind(record, 1, username); + run(record); + finalize(record); + db_exec(db, tprint("DELETE FROM login_failures WHERE at < % - 86400;", NOW_SQL)); + respond(s, 401, "text/plain", "error \"Wrong username or password.\"\n"); + return; + } + clear := prepare(db, "DELETE FROM login_failures WHERE username = ?"); + bind(clear, 1, username); + run(clear); + finalize(clear); + + token_bytes: [32] u8; + random_bytes(token_bytes.data, 32); + token := to_hex(token_bytes.data, 32); + token_hash := sha256(token); + db_exec(db, tprint("DELETE FROM sessions WHERE expires < %;", NOW_SQL)); + ins := prepare(db, tprint("INSERT INTO sessions(token_hash, user_id, expires) VALUES (?, ?, % + %)", NOW_SQL, SESSION_DAYS * 86400)); + bind_blob(ins, 1, as_string(*token_hash)); + bind(ins, 2, user_id); + run(ins); + finalize(ins); + respond(s, 200, "text/plain", tprint("token %\n", token)); + free(token); +} + +handle_logout :: (db: Db, s: Socket, request: Request) { + prefix :: "Bearer "; + if begins_with(request.authorization, prefix) { + token := substring(request.authorization, prefix.count, request.authorization.count); + token_hash := sha256(token); + d := prepare(db, "DELETE FROM sessions WHERE token_hash = ?"); + bind_blob(d, 1, as_string(*token_hash)); + run(d); + finalize(d); + } + respond(s, 200, "text/plain", "ok\n"); +} + +handle_sync :: (db: Db, s: Socket, request: Request) { + session, authed := authenticate(db, request); + if !authed { + respond(s, 401, "text/plain", "error \"Please sign in again.\"\n"); + return; + } + + since: s64; + client_users_rev: s64; + want_ids := false; + puts: [..] string; + puts.allocator = temp; + dels: [..] string; + dels.allocator = temp; + + body := request.body; + while body.count { + end := find_index_from_left(body, #char "\n"); + if end < 0 end = body.count; + line := substring(body, 0, end); + advance(*body, min(end + 1, body.count)); + if begins_with(line, "put ") array_add(*puts, substring(line, 4, line.count)); + else if begins_with(line, "del ") array_add(*dels, substring(line, 4, line.count)); + else if begins_with(line, "since ") since = string_to_int(substring(line, 6, line.count)); + else if begins_with(line, "users_rev ") client_users_rev = string_to_int(substring(line, 10, line.count)); + else if line == "ids" want_ids = true; + } + + out: String_Builder; + out.allocator = temp; + + db_exec(db, "BEGIN IMMEDIATE;"); + rev := get_meta(db, "rev"); + if puts.count || dels.count { + rev += 1; + up := prepare(db, "INSERT INTO records(key, line, rev, deleted, user_id) VALUES (?, ?, ?, 0, ?) ON CONFLICT(key) DO UPDATE SET line = excluded.line, rev = excluded.rev, deleted = 0, user_id = excluded.user_id"); + for puts { + key := record_key(it); + if !key.count continue; + bind(up, 1, key); + bind(up, 2, it); + bind(up, 3, rev); + bind(up, 4, session.user_id); + run(up); + reset(up); + } + finalize(up); + del := prepare(db, "UPDATE records SET deleted = 1, line = '', rev = ?, user_id = ? WHERE key = ?"); + for dels { + bind(del, 1, rev); + bind(del, 2, session.user_id); + bind(del, 3, it); + run(del); + reset(del); + } + finalize(del); + set_meta(db, "rev", rev); + } + if want_ids { + start := get_meta(db, "next_id"); + set_meta(db, "next_id", start + ID_BLOCK); + print_to_builder(*out, "ids % %\n", start, ID_BLOCK); + } + + print_to_builder(*out, "rev %\n", rev); + print_to_builder(*out, "me % %\n", session.user_id, cast(int) session.is_admin); + users_rev := get_meta(db, "users_rev"); + if users_rev != client_users_rev { + print_to_builder(*out, "users_rev %\n", users_rev); + u := prepare(db, "SELECT id, username, display_name, is_admin, active FROM users ORDER BY display_name COLLATE NOCASE"); + while step(u) { + print_to_builder(*out, "user % % % % %\n", column_int(u, 0), quoted(column_text(u, 1)), quoted(column_text(u, 2)), column_int(u, 3), column_int(u, 4)); + } + finalize(u); + } + + q := prepare(db, ifx since == 0 then "SELECT key, line, deleted FROM records WHERE rev > ? AND deleted = 0" else "SELECT key, line, deleted FROM records WHERE rev > ? ORDER BY rev"); + bind(q, 1, since); + while step(q) { + if column_int(q, 2) print_to_builder(*out, "del %\n", column_text(q, 0)); + else print_to_builder(*out, "put %\n", column_text(q, 1)); + } + finalize(q); + db_exec(db, "COMMIT;"); + + respond(s, 200, "text/plain; charset=utf-8", builder_to_string(*out,, temp)); +} + +// Commands (one per request): +// add "username" "Display Name" "password" admin only +// update "Display Name" admin only +// password "new password" "current password" admin, or yourself with your current password +handle_users :: (db: Db, s: Socket, request: Request) { + session, authed := authenticate(db, request); + if !authed { + respond(s, 401, "text/plain", "error \"Please sign in again.\"\n"); + return; + } + error := users_command(db, session, request.body, session.token_hash); + if error respond(s, 400, "text/plain", tprint("error %\n", quoted(error))); + else respond(s, 200, "text/plain", "ok\n"); +} + +users_command :: (db: Db, session: Session, body: string, token_hash: string) -> error: string { + r := Line_Reader.{line = body}; + command := next_token(*r); + if command == { + case "add"; + if !session.is_admin { return "Only admins can add users."; } + username := next_token(*r); + name := next_token(*r); + password := next_token(*r); + admin := next_u32(*r) != 0; + return add_user(db, username, name, password, admin); + case "update"; + if !session.is_admin { return "Only admins can change users."; } + id := cast(s64) next_u32(*r); + name := next_token(*r); + admin := next_u32(*r) != 0; + active := next_u32(*r) != 0; + if id == session.user_id && (!admin || !active) { return "You can't remove your own admin rights or deactivate yourself."; } + if !name.count { return "Display name can't be empty."; } + u := prepare(db, "UPDATE users SET display_name = ?, is_admin = ?, active = ? WHERE id = ?"); + bind(u, 1, name); + bind(u, 2, cast(s64) admin); + bind(u, 3, cast(s64) active); + bind(u, 4, id); + run(u); + finalize(u); + if !active { + d := prepare(db, "DELETE FROM sessions WHERE user_id = ?"); + bind(d, 1, id); + run(d); + finalize(d); + } + bump_users_rev(db); + case "password"; + id := cast(s64) next_u32(*r); + password := next_token(*r); + current := next_token(*r); + if id != session.user_id && !session.is_admin { return "You can only change your own password."; } + if id == session.user_id && !check_password(db, id, current) { return "Your current password is wrong."; } + return set_password(db, id, password, ifx id == session.user_id then token_hash else ""); + case; + return "Unknown command."; + } + return ""; +} + +#load "sqlite.jai"; +#load "crypto.jai"; +#load "../src/common.jai"; +#load "../src/model.jai"; +#load "../src/storage.jai"; + +#import "Basic"; +#import "String"; +#import "Math"; +#import "File"; +#import "Socket"; +#import "Thread"; +#if OS != .WINDOWS { + #import "POSIX"; +} diff --git a/server/sqlite.jai b/server/sqlite.jai new file mode 100644 index 0000000..ab30628 --- /dev/null +++ b/server/sqlite.jai @@ -0,0 +1,151 @@ +// Minimal SQLite binding, loaded at runtime (sqlite3.dll on Windows, libsqlite3.so.0 on Linux). +// Loading dynamically means the server binary has no link-time dependency on SQLite, so it can be +// cross-compiled and then just needs the distro package (libsqlite3-0) on the droplet. + +SQLITE_OK :: 0; +SQLITE_ROW :: 100; +SQLITE_DONE :: 101; +SQLITE_OPEN_READWRITE :: 0x00000002; +SQLITE_OPEN_CREATE :: 0x00000004; +SQLITE_OPEN_NOMUTEX :: 0x00008000; +SQLITE_TRANSIENT :: cast(*void) -1; + +sqlite3 :: void; +sqlite3_stmt :: void; + +Sqlite_Api :: struct { + sqlite3_open_v2: (filename: *u8, db: **sqlite3, flags: s32, vfs: *u8) -> s32 #c_call; + sqlite3_close_v2: (db: *sqlite3) -> s32 #c_call; + sqlite3_exec: (db: *sqlite3, sql: *u8, callback: *void, arg: *void, errmsg: **u8) -> s32 #c_call; + sqlite3_prepare_v2: (db: *sqlite3, sql: *u8, bytes: s32, stmt: **sqlite3_stmt, tail: **u8) -> s32 #c_call; + sqlite3_step: (stmt: *sqlite3_stmt) -> s32 #c_call; + sqlite3_reset: (stmt: *sqlite3_stmt) -> s32 #c_call; + sqlite3_finalize: (stmt: *sqlite3_stmt) -> s32 #c_call; + sqlite3_bind_int64: (stmt: *sqlite3_stmt, index: s32, value: s64) -> s32 #c_call; + sqlite3_bind_text: (stmt: *sqlite3_stmt, index: s32, text: *u8, bytes: s32, destructor: *void) -> s32 #c_call; + sqlite3_bind_blob: (stmt: *sqlite3_stmt, index: s32, data: *void, bytes: s32, destructor: *void) -> s32 #c_call; + sqlite3_column_int64: (stmt: *sqlite3_stmt, column: s32) -> s64 #c_call; + sqlite3_column_text: (stmt: *sqlite3_stmt, column: s32) -> *u8 #c_call; + sqlite3_column_blob: (stmt: *sqlite3_stmt, column: s32) -> *void #c_call; + sqlite3_column_bytes: (stmt: *sqlite3_stmt, column: s32) -> s32 #c_call; + sqlite3_errmsg: (db: *sqlite3) -> *u8 #c_call; + sqlite3_changes: (db: *sqlite3) -> s32 #c_call; + sqlite3_last_insert_rowid: (db: *sqlite3) -> s64 #c_call; + sqlite3_busy_timeout: (db: *sqlite3, ms: s32) -> s32 #c_call; + sqlite3_libversion: () -> *u8 #c_call; +} + +sql: Sqlite_Api; + +load_sqlite :: () -> bool { + #if OS == .WINDOWS { + tf_kernel32 :: #system_library "kernel32"; + LoadLibraryA :: (name: *u8) -> *void #foreign tf_kernel32; + GetProcAddress :: (module: *void, name: *u8) -> *void #foreign tf_kernel32; + lib := LoadLibraryA("sqlite3.dll"); + lookup :: GetProcAddress; + } else { + lib := dlopen("libsqlite3.so.0", RTLD_NOW); + if !lib lib = dlopen("libsqlite3.so", RTLD_NOW); + lookup :: dlsym; + } + if !lib { + log_error("Could not load SQLite. On Ubuntu/Debian: sudo apt install libsqlite3-0. On Windows: put sqlite3.dll next to the server."); + return false; + } + + info := type_info(Sqlite_Api); + for info.members { + address := lookup(lib, temp_c_string(it.name)); + if !address { + log_error("SQLite is missing %; it may be too old.", it.name); + return false; + } + (cast(**void) (cast(*u8) *sql + it.offset_in_bytes)).* = address; + } + return true; +} + +// +// Small conveniences. +// + +Db :: struct { + handle: *sqlite3; +} + +db_open :: (path: string) -> Db, bool { + db: Db; + rc := sql.sqlite3_open_v2(temp_c_string(path), *db.handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX, null); + if rc != SQLITE_OK { + log_error("Could not open database %: %", path, db_error(db)); + return db, false; + } + sql.sqlite3_busy_timeout(db.handle, 5000); + db_exec(db, "PRAGMA journal_mode = WAL;"); + db_exec(db, "PRAGMA synchronous = NORMAL;"); + db_exec(db, "PRAGMA foreign_keys = ON;"); + return db, true; +} + +db_error :: (db: Db) -> string { + return to_string(sql.sqlite3_errmsg(db.handle)); +} + +db_exec :: (db: Db, statement: string) -> bool { + err: *u8; + rc := sql.sqlite3_exec(db.handle, temp_c_string(statement), null, null, *err); + if rc != SQLITE_OK { + log_error("SQL error in \"%\": %", statement, to_string(err)); + return false; + } + return true; +} + +Stmt :: struct { + handle: *sqlite3_stmt; + db: Db; +} + +prepare :: (db: Db, statement: string) -> Stmt { + s: Stmt; + s.db = db; + rc := sql.sqlite3_prepare_v2(db.handle, statement.data, cast(s32) statement.count, *s.handle, null); + if rc != SQLITE_OK log_error("SQL prepare failed for \"%\": %", statement, db_error(db)); + return s; +} + +bind :: (s: Stmt, index: s32, value: s64) { sql.sqlite3_bind_int64(s.handle, index, value); } +bind :: (s: Stmt, index: s32, value: string) { sql.sqlite3_bind_text(s.handle, index, value.data, cast(s32) value.count, SQLITE_TRANSIENT); } +bind_blob :: (s: Stmt, index: s32, value: string) { sql.sqlite3_bind_blob(s.handle, index, value.data, cast(s32) value.count, SQLITE_TRANSIENT); } + +step :: (s: Stmt) -> bool { + rc := sql.sqlite3_step(s.handle); + if rc == SQLITE_ROW return true; + if rc != SQLITE_DONE log_error("SQL step failed: %", db_error(s.db)); + return false; +} + +// Runs a statement that returns no rows; returns success. +run :: (s: Stmt) -> bool { + rc := sql.sqlite3_step(s.handle); + if rc != SQLITE_DONE && rc != SQLITE_ROW { + log_error("SQL failed: %", db_error(s.db)); + return false; + } + return true; +} + +reset :: (s: Stmt) { sql.sqlite3_reset(s.handle); } +finalize :: (s: Stmt) { sql.sqlite3_finalize(s.handle); } + +column_int :: (s: Stmt, column: s32) -> s64 { return sql.sqlite3_column_int64(s.handle, column); } + +// Column text/blob, copied into temporary storage. +column_text :: (s: Stmt, column: s32) -> string { + data := sql.sqlite3_column_blob(s.handle, column); + count := sql.sqlite3_column_bytes(s.handle, column); + result := alloc_string(count,, temp); + if count memcpy(result.data, data, count); + return result; +} diff --git a/src/account.jai b/src/account.jai new file mode 100644 index 0000000..1d7286a --- /dev/null +++ b/src/account.jai @@ -0,0 +1,165 @@ +// Server mode screens: sign-in and user management. + +Account_Forms :: struct { + new_username: string; + new_name: string; + new_password: string; + new_admin: bool; + + reset_user: u32; // User whose password an admin is resetting. + reset_password: string; + + my_current: string; + my_new: string; +} + +forms: Account_Forms; + +draw_login_screen :: (r: Rect) { + draw_rect(r, COL_BG); + card := make_rect(round_f(r.x + (r.w - 380) * 0.5), round_f(r.y + (r.h - 400) * 0.5), 380, 400); + draw_rect(make_rect(card.x, card.y + 6, card.w, card.h), hex(0x000000, 0.3), 14); + draw_rect(card, COL_PANEL, 12); + draw_rect_outline(card, COL_BORDER, 1, 12); + + area := inset(card, 32, 30); + head := cut_top(*area, 34); + draw_rect(make_rect(head.x, head.y + 7, 20, 20), COL_ACCENT, 5); + draw_rect(make_rect(head.x + 5, head.y + 12, 10, 10), COL_PANEL, 2); + draw_text("TaskForge", head.x + 30, head.y + 2, .TITLE, COL_TEXT); + cut_top(*area, 6); + draw_text(fit_text(tprint("Sign in to %", platform_server_label()), area.w, .NORMAL), area.x, area.y, .NORMAL, COL_TEXT_DIM); + cut_top(*area, 36); + + name_id := ui_id("login name"); + pass_id := ui_id("login password"); + if !ui.focus && !sync.login_name.count focus_text(name_id, ""); + + draw_text("Username", area.x, area.y, .NORMAL, COL_TEXT_DIM); + cut_top(*area, 22); + _, submit_name := text_edit(cut_top(*area, 36), *sync.login_name, name_id, "username"); + cut_top(*area, 14); + draw_text("Password", area.x, area.y, .NORMAL, COL_TEXT_DIM); + cut_top(*area, 22); + _, submit_pass := text_edit(cut_top(*area, 36), *sync.login_password, pass_id, "password", password = true); + if submit_name focus_text(pass_id, sync.login_password); + cut_top(*area, 12); + + if sync.login_error.count draw_text(fit_text(sync.login_error, area.w, .NORMAL), area.x, area.y, .NORMAL, hex(0xFF8A84)); + cut_top(*area, 26); + + busy := sync.in_flight[Http_Tag.LOGIN]; + clicked := button(cut_top(*area, 38), ifx busy then "Signing in..." else "Sign in", .PRIMARY, id = ui_id("sign in")); + if (clicked || submit_pass) && !busy && sync.login_name.count sign_in(); +} + +draw_users_page :: (r: Rect) { + p := app.project; + area := inset(r, 28, 18); + area.w = min(area.w, 860.0); + + head := cut_top(*area, 40); + draw_text("Users", head.x, head.y + 2, .TITLE, COL_TEXT); + cut_top(*area, 4); + subtitle := ifx p.me_admin + then "People who can sign in. Assign them to tasks with User fields such as Assignee. As an admin you can add people, reset passwords and deactivate accounts." + else "People who can sign in. Assign them to tasks with User fields such as Assignee."; + h := draw_wrapped_text(subtitle, area.x, area.y, min(area.w, 760.0), .NORMAL, COL_TEXT_DIM); + cut_top(*area, h + 10); + if sync.users_message.count { + draw_text(sync.users_message, area.x, area.y, .NORMAL, ifx sync.users_error then hex(0xFF8A84) else hex(0x3FB950)); + } + cut_top(*area, 26); + + list_r := make_rect(r.x, area.y, r.w, r.y + r.h - area.y); + sid := ui_id("users scroll"); + off := scroll_begin(list_r, sid); + x := area.x; + w := area.w; + y := area.y - off; + + for * u: p.users { + resetting := forms.reset_user == u.id; + card_h := ifx resetting then 100.0 else 60.0; + card := make_rect(x, y, w, card_h); + draw_rect(card, COL_PANEL, 10); + draw_rect_outline(card, COL_BORDER, 1, 10); + avatar(card.x + 30, card.y + 30, 16, u.id, u.name); + + name_r := make_rect(card.x + 56, card.y + 10, 240, 24); + if p.me_admin { + _, submitted := text_edit(make_rect(name_r.x - 8, name_r.y - 2, name_r.w, 26), *u.name, ui_id(u, 1), "Display name", style = .GHOST, size = .MEDIUM); + if submitted users_request(tprint("update % % % %", u.id, quoted(u.name), cast(int) u.is_admin, cast(int) u.active)); + } else { + draw_text(u.name, name_r.x, name_r.y, .MEDIUM, COL_TEXT); + } + tags := tprint("@%", u.username); + if u.id == p.me tags = tprint("% · you", tags); + if u.is_admin tags = tprint("% · admin", tags); + if !u.active tags = tprint("% · deactivated", tags); + draw_text(tags, card.x + 56, card.y + 34, .SMALL, ifx u.active then COL_TEXT_FAINT else hex(0xFF8A84)); + + if p.me_admin && u.id != p.me { + tools := make_rect(card.x + card.w - 12, card.y + 15, 0, 30); + bw :: 130.0; + tools.x -= bw; + if button(make_rect(tools.x, tools.y, bw, 30), ifx u.active then "Deactivate" else "Activate", id = ui_id(u, 2)) + users_request(tprint("update % % % %", u.id, quoted(u.name), cast(int) u.is_admin, cast(int) !u.active)); + tools.x -= bw + 8; + if button(make_rect(tools.x, tools.y, bw, 30), "Reset password", id = ui_id(u, 3)) { + forms.reset_user = ifx resetting then 0 else u.id; + set_string(*forms.reset_password, ""); + if forms.reset_user focus_text(ui_id(u, 5), ""); + } + tools.x -= 92; + if checkbox(make_rect(tools.x, tools.y + 6, 18, 18), u.is_admin, ui_id(u, 4)) + users_request(tprint("update % % % %", u.id, quoted(u.name), cast(int) !u.is_admin, cast(int) u.active)); + draw_text("Admin", tools.x + 26, tools.y + 7, .NORMAL, COL_TEXT_DIM); + } + if resetting { + row := make_rect(card.x + 56, card.y + 58, 260, 32); + _, submitted := text_edit(row, *forms.reset_password, ui_id(u, 5), "New password (8+ characters)", password = true); + if button(make_rect(row.x + row.w + 8, row.y, 120, 32), "Set password", .PRIMARY, id = ui_id(u, 6)) || submitted { + users_request(tprint("password % % \"\"", u.id, quoted(forms.reset_password))); + set_string(*forms.reset_password, ""); + forms.reset_user = 0; + } + } + y += card_h + 10; + } + + y += 14; + if p.me_admin { + draw_text("Add a user", x, y, .LARGE, COL_TEXT); + y += 34; + flow := make_flow(make_rect(x, y, w, 0), 34, 8); + text_edit(flow_next(*flow, 170), *forms.new_username, ui_id("new username"), "username"); + text_edit(flow_next(*flow, 200), *forms.new_name, ui_id("new name"), "Display name"); + text_edit(flow_next(*flow, 200), *forms.new_password, ui_id("new password"), "Password (8+ characters)", password = true); + cb := flow_next(*flow, 18); + if checkbox(make_rect(cb.x, cb.y + 8, 18, 18), forms.new_admin, ui_id("new admin")) forms.new_admin = !forms.new_admin; + flow_label(*flow, "Admin"); + if button(flow_next(*flow, 110), "Add user", .PRIMARY, id = ui_id("add user")) { + users_request(tprint("add % % % %", quoted(forms.new_username), quoted(forms.new_name), quoted(forms.new_password), cast(int) forms.new_admin)); + set_string(*forms.new_username, ""); + set_string(*forms.new_name, ""); + set_string(*forms.new_password, ""); + forms.new_admin = false; + } + y += flow_height(*flow, y) + 30; + } + + draw_text("Change my password", x, y, .LARGE, COL_TEXT); + y += 34; + flow := make_flow(make_rect(x, y, w, 0), 34, 8); + text_edit(flow_next(*flow, 220), *forms.my_current, ui_id("my current password"), "Current password", password = true); + _, submitted := text_edit(flow_next(*flow, 220), *forms.my_new, ui_id("my new password"), "New password (8+ characters)", password = true); + if button(flow_next(*flow, 160), "Change password", id = ui_id("change password")) || submitted { + users_request(tprint("password % % %", p.me, quoted(forms.my_new), quoted(forms.my_current))); + set_string(*forms.my_current, ""); + set_string(*forms.my_new, ""); + } + y += flow_height(*flow, y) + 30; + + scroll_end(list_r, sid, y + off - list_r.y); +} diff --git a/src/app.jai b/src/app.jai new file mode 100644 index 0000000..f7432e2 --- /dev/null +++ b/src/app.jai @@ -0,0 +1,1605 @@ +// The application: sidebar, views (kanban / checklist), task editor, field editor and epic rule editor. + +App_Page :: enum { + VIEW; + FIELDS; + RULES; + USERS; +} + +Drag :: struct { + task: u32; + view: u32; + pending: bool; // Mouse is down on a card but hasn't moved far enough to drag yet. + active: bool; + start_x, start_y: float; + grab_dx, grab_dy: float; + card_w, card_h: float; + + // Drop target, recomputed every frame while dragging. + target_valid: bool; + target_value: u32; + target_none: bool; + target_order: float64; + target_has_order: bool; +} + +App :: struct { + project: *Project; + page: App_Page; + view_id: u32; + selected: u32; // Task shown in the editor panel. + drag: Drag; + + dirty: bool; + dirty_time: float64; + save_failed: bool; + + quick_add_id: u64; // Which "+ Add task" input is open. + quick_text: string; +} + +app: App; + +SIDEBAR_W :: 244.0; +EDITOR_W :: 400.0; +COLUMN_W :: 288.0; +COLUMN_GAP :: 12.0; + +app_init :: (font_data: string, server_mode: bool) -> bool { + if !init_renderer() return false; + if !load_fonts(font_data) return false; + + sync_start(server_mode); + if sync.enabled { + reset_sync_state(); + return true; + } + + text, ok := platform_load_project(); + if ok { + project, parsed := parse_project(text); + if parsed app.project = project; + else log_error("Could not parse the saved project; starting a new one."); + } + if !app.project { + app.project = default_project(); + app.dirty = true; + } + if app.project.views.count app.view_id = app.project.views[0].id; + return true; +} + +app_import :: (text: string) { + project, ok := parse_project(text); + if !ok { + log_error("Import failed: not a TaskForge project file."); + return; + } + app.project = project; // The old project is leaked on purpose; imports are rare. + app.selected = 0; + app.page = .VIEW; + app.view_id = ifx project.views.count then project.views[0].id else 0; + mark_dirty(); +} + +app_shutdown :: () { + if app.dirty && !sync.enabled save_now(); +} + +mark_dirty :: () { + app.dirty = true; + app.dirty_time = ui.time; +} + +save_now :: () { + if sync.enabled { + sync.last_sync = -100; // Sync right away. + return; + } + data := serialize_project(app.project); + ok := platform_save_project(data); + free(data); + app.save_failed = !ok; + if ok app.dirty = false; +} + +// Runs one frame. Returns true if the app wants another frame right away (animation, pending work). +app_frame :: (input: *Frame_Input) -> bool { + p := app.project; + begin_render(input.width, input.height, input.dpr); + ui_begin_frame(input); + app.drag.target_valid = false; + + screen := make_rect(0, 0, input.width, input.height); + if sync.enabled && (!sync.logged_in || !sync.initial_done) { + if !sync.logged_in draw_login_screen(screen); + else draw_text_centered(ifx sync.offline then "Can't reach the server, retrying..." else "Loading project...", screen, .MEDIUM, COL_TEXT_DIM); + ui_end_frame(); + sync_frame(); + end_render(COL_BG); + reset_temporary_storage(); + return ui.wants_frame || sync.in_flight[Http_Tag.SYNC] || sync.in_flight[Http_Tag.LOGIN]; + } + sidebar := cut_left(*screen, SIDEBAR_W); + editor: Rect; + if app.selected && find_task(p, app.selected) editor = cut_right(*screen, min(EDITOR_W, screen.w * 0.5)); + else app.selected = 0; + + draw_sidebar(sidebar); + if app.page == { + case .VIEW; draw_view_page(screen); + case .FIELDS; draw_fields_page(screen); + case .RULES; draw_rules_page(screen); + case .USERS; draw_users_page(screen); + } + if app.selected draw_task_editor(editor); + + update_drag(); + handle_shortcuts(); + ui_end_frame(); + + if apply_epic_rules(p) { + mark_dirty(); + ui.wants_frame = true; + } + if sync.enabled sync_frame(); + else if app.dirty && ui.time - app.dirty_time > 0.6 && !app.save_failed save_now(); + + end_render(COL_BG); + reset_temporary_storage(); + return ui.wants_frame || app.drag.active || app.dirty || sync.in_flight[Http_Tag.SYNC]; +} + +handle_shortcuts :: () { + if ui.focus return; + for ui.input.keys { + ctrl := (it.mods & MOD_CTRL) != 0; + if it.key == KEY_ESCAPE { + if app.drag.active { app.drag.active = false; app.drag.pending = false; } + else if ui.popup.id close_popup(); + else if app.selected app.selected = 0; + } else if ctrl && it.key == #char "S" { + save_now(); + } + } +} + +// +// Sidebar +// + +draw_sidebar :: (r: Rect) { + p := app.project; + draw_rect(r, COL_SIDEBAR); + draw_rect(make_rect(r.x + r.w - 1, r.y, 1, r.h), COL_BORDER); + area := inset(r, 12, 14); + + head := cut_top(*area, 30); + draw_rect(make_rect(head.x + 4, head.y + 7, 16, 16), COL_ACCENT, 4); + draw_rect(make_rect(head.x + 8, head.y + 11, 8, 8), COL_SIDEBAR, 2); + draw_text("TaskForge", head.x + 28, head.y + 3, .LARGE, COL_TEXT); + + name_r := cut_top(*area, 32); + if text_edit(name_r, *p.name, ui_id("project name"), "Project name", style = .GHOST, size = .MEDIUM, text_color = COL_TEXT_DIM) mark_dirty(); + cut_top(*area, 8); + + bottom := cut_bottom(*area, ifx sync.enabled then 78.0 else ifx PLATFORM_WEB then 78.0 else 40.0); + draw_sidebar_footer(bottom); + + sid := ui_id("sidebar"); + offset := scroll_begin(area, sid); + y := area.y - offset; + x := area.x; + w := area.w; + + // Views + if section_header(make_rect(x, y, w, 26), "VIEWS", ui_id("new view")) { + v := create_view(p); + app.view_id = v.id; + app.page = .VIEW; + focus_text(ui_id(v, 1), v.name, select_all = true); + } + y += 28; + for v: p.views { + row := make_rect(x, y, w, 30); + if sidebar_row(row, v.name, app.page == .VIEW && app.view_id == v.id, ui_id(v)) { + app.page = .VIEW; + app.view_id = v.id; + } + draw_view_icon(make_rect(row.x + 10, row.y + 8, 14, 14), v.kind); + y += 32; + } + y += 14; + + // Epics + if section_header(make_rect(x, y, w, 26), "EPICS", ui_id("new epic")) { + epic := create_task(p, "New epic", is_epic = true); + app.selected = epic.id; + focus_text(ui_id(epic, 10), epic.title, select_all = true); + mark_dirty(); + } + y += 28; + epics := sorted_epics(p); + for epic: epics { + row := make_rect(x, y, w, 30); + count := epic_children_count(p, epic.id); + dots_w := 0.0; + for f: p.fields if f.scope == .EPICS && field_is_discrete(f) dots_w += 10; + title_r := make_rect(row.x, row.y, row.w - dots_w - 30, row.h); + if sidebar_row(row, "", app.selected == epic.id, ui_id(epic)) app.selected = epic.id; + draw_rect(make_rect(row.x + 13, row.y + 11, 8, 8), COL_EPIC, 2); + draw_text_in_rect(epic.title, make_rect(row.x + 30, row.y, title_r.w - 30, row.h), .NORMAL, COL_TEXT); + // One dot per epic-only status field, e.g. Art/Code/Audio progress driven by rules. + dx := row.x + row.w - 10; + for < f: p.fields { + if f.scope != .EPICS || !field_is_discrete(f) continue; + _, color := option_label(f, get_sel(epic, f.id)); + if !get_sel(epic, f.id) color = 0x3A404B; + color_dot(dx, row.y + 15, color, 3.5); + dx -= 10; + } + draw_text_in_rect(tprint("%", count), make_rect(dx - 18, row.y, 20, row.h), .SMALL, COL_TEXT_FAINT); + y += 32; + } + if !epics.count { + draw_text("No epics yet", x + 10, y + 4, .SMALL, COL_TEXT_FAINT); + y += 26; + } + y += 14; + + // Project + draw_text("PROJECT", x + 10, y + 6, .SMALL, COL_TEXT_FAINT); + y += 28; + row := make_rect(x, y, w, 30); + if sidebar_row(row, "Fields", app.page == .FIELDS, ui_id("fields page")) app.page = .FIELDS; + draw_fields_icon(make_rect(row.x + 10, row.y + 8, 14, 14)); + y += 32; + row = make_rect(x, y, w, 30); + if sidebar_row(row, "Epic rules", app.page == .RULES, ui_id("rules page")) app.page = .RULES; + draw_rules_icon(make_rect(row.x + 10, row.y + 8, 14, 14)); + y += 32; + if sync.enabled { + row = make_rect(x, y, w, 30); + if sidebar_row(row, "Users", app.page == .USERS, ui_id("users page")) app.page = .USERS; + draw_users_icon(make_rect(row.x + 10, row.y + 8, 14, 14)); + y += 32; + } + + scroll_end(area, sid, y + offset - area.y); +} + +draw_sidebar_footer :: (r: Rect) { + draw_rect(make_rect(r.x - 12, r.y - 6, r.w + 24, 1), COL_BORDER); + if sync.enabled { + p := app.project; + me := find_user(p, p.me); + name := ifx me then me.name else "Signed in"; + avatar(r.x + 16, r.y + 18, 13, p.me, name); + draw_text_in_rect(name, make_rect(r.x + 36, r.y + 2, r.w - 40, 20), .NORMAL, COL_TEXT); + status := "Synced"; + color := COL_TEXT_FAINT; + if sync.offline { status = "Offline, retrying..."; color = COL_DANGER; } + else if app.dirty || sync.in_flight[Http_Tag.SYNC] status = "Syncing..."; + draw_text_in_rect(status, make_rect(r.x + 36, r.y + 20, r.w - 40, 16), .SMALL, color); + if button(make_rect(r.x, r.y + 42, r.w, 30), "Sign out", id = ui_id("sign out")) sign_out(); + return; + } + status: string; + color := COL_TEXT_FAINT; + if app.save_failed { status = "Save failed!"; color = COL_DANGER; } + else if app.dirty status = "Saving..."; + else status = platform_storage_description(); + draw_text_in_rect(status, make_rect(r.x + 4, r.y + 4, r.w - 8, 26), .SMALL, color); + #if PLATFORM_WEB { + half := (r.w - 8) * 0.5; + if button(make_rect(r.x, r.y + 36, half, 30), "Export") { + data := serialize_project(app.project); + platform_export_project(data); + free(data); + } + if button(make_rect(r.x + half + 8, r.y + 36, half, 30), "Import") platform_request_import(); + } +} + +section_header :: (r: Rect, label: string, plus_id: u64) -> bool { + draw_text(label, r.x + 10, r.y + 6, .SMALL, COL_TEXT_FAINT); + return icon_button(make_rect(r.x + r.w - 26, r.y + 1, 24, 24), .PLUS, plus_id); +} + +sidebar_row :: (r: Rect, label: string, selected: bool, id: u64) -> bool { + clicked, hovered := button_behavior(id, r); + if selected draw_rect(r, COL_SELECTED, RADIUS); + else if hovered draw_rect(r, COL_HOVER, RADIUS); + if label.count draw_text_in_rect(label, make_rect(r.x + 32, r.y, r.w - 40, r.h), .NORMAL, ifx selected then COL_TEXT else hex(0xC9D1D9)); + return clicked; +} + +draw_view_icon :: (r: Rect, kind: View_Kind) { + c := COL_TEXT_DIM; + if kind == .KANBAN { + draw_rect(make_rect(r.x, r.y, 4, 14), c, 1); + draw_rect(make_rect(r.x + 5, r.y, 4, 10), c, 1); + draw_rect(make_rect(r.x + 10, r.y, 4, 12), c, 1); + } else { + for 0..2 { + yy := r.y + it * 5; + draw_rect(make_rect(r.x, yy, 4, 4), c, 1); + draw_rect(make_rect(r.x + 6, yy + 1, 8, 2), c, 1); + } + } +} + +draw_fields_icon :: (r: Rect) { + c := COL_TEXT_DIM; + draw_rect_outline(make_rect(r.x, r.y + 1, 14, 5), c, 1.5, 1.5); + draw_rect_outline(make_rect(r.x, r.y + 8, 14, 5), c, 1.5, 1.5); +} + +draw_users_icon :: (r: Rect) { + c := COL_TEXT_DIM; + draw_rect(make_rect(r.x + 4, r.y, 6, 6), c, 3); + draw_rect(make_rect(r.x + 1, r.y + 8, 12, 6), c, 3); +} + +// A round avatar with the user's initials. +avatar :: (cx: float, cy: float, radius: float, user_id: u32, name: string) { + color := user_color(user_id); + draw_rect(make_rect(cx - radius, cy - radius, radius * 2, radius * 2), hex(color, 0.85), radius); + draw_text_centered(user_initials(name), make_rect(cx - radius, cy - radius, radius * 2, radius * 2), ifx radius >= 12 then Font_Size.SMALL else .SMALL, hex(0x111318)); +} + +draw_rules_icon :: (r: Rect) { + c := COL_TEXT_DIM; + draw_rect(make_rect(r.x, r.y + 1, 5, 5), c, 1); + draw_rect(make_rect(r.x, r.y + 9, 5, 5), c, 1); + draw_line(r.x + 5, r.y + 3.5, r.x + 10, r.y + 7, 1.5, c); + draw_line(r.x + 5, r.y + 11.5, r.x + 10, r.y + 7, 1.5, c); + draw_rect(make_rect(r.x + 10, r.y + 5, 4, 4), c, 2); +} + +sorted_epics :: (p: *Project) -> [..] *Task { + result: [..] *Task; + result.allocator = temp; + for p.tasks if it.is_epic array_add(*result, it); + sort_by_order(result); + return result; +} + +create_view :: (p: *Project) -> *View { + v := new_view(p, "New view", .KANBAN); + for p.fields { + if it.kind == .SELECT { + v.group_field = it.id; + v.check_field = it.id; + if it.options.count v.check_value = it.options[it.options.count - 1].id; + break; + } + } + mark_dirty(); + return v; +} + +// +// View page +// + +draw_view_page :: (r: Rect) { + p := app.project; + view := find_view(p, app.view_id); + if !view { + if p.views.count { + app.view_id = p.views[0].id; + view = p.views[0]; + } else { + draw_text_centered("No views yet. Create one with + next to VIEWS.", r, .MEDIUM, COL_TEXT_DIM); + return; + } + } + + area := inset(r, 24, 16); + header := cut_top(*area, 40); + + right := header; + if confirm_button(cut_right(*right, 110), "Delete view", ui_id(view, 2), "Delete it?") { + for p.views if it == view { array_ordered_remove_by_index(*p.views, it_index); break; } + app.view_id = 0; + mark_dirty(); + return; + } + cut_right(*right, 8); + new_label := ifx view.items == .EPICS then "+ New epic" else "+ New task"; + if button(cut_right(*right, 120), new_label, .PRIMARY, id = ui_id(view, 3)) { + t := create_task_in_view(view, ""); + app.selected = t.id; + focus_text(ui_id(t, 10), t.title); + } + name_r := make_rect(header.x - 8, header.y, min(right.w - 16, 460.0), 38); + if text_edit(name_r, *view.name, ui_id(view, 1), "View name", style = .GHOST, size = .TITLE) mark_dirty(); + + cut_top(*area, 10); + toolbar_h := draw_view_toolbar(view, area); + cut_top(*area, toolbar_h + 16); + + body := make_rect(r.x, area.y, r.w, r.y + r.h - area.y); + if view.kind == .KANBAN draw_kanban(view, body); + else draw_checklist(view, body); +} + +draw_view_toolbar :: (view: *View, r: Rect) -> float { + p := app.project; + flow := make_flow(r, 30, 8); + + kinds := string.["Board", "Checklist"]; + kind := segmented(flow_next(*flow, 180), kinds, cast(int) view.kind, ui_id(view, 20)); + if kind != cast(int) view.kind { + view.kind = xx kind; + mark_dirty(); + } + flow.x += 8; + + // Which items + flow_keep(*flow, measure_text("Show", .NORMAL) + 138); flow_label(*flow, "Show"); + items: [..] Popup_Item; + items.allocator = temp; + for VIEW_ITEMS_NAMES array_add(*items, .{label = it, checked = it_index == cast(int) view.items}); + pick := dropdown(flow_next(*flow, 130), VIEW_ITEMS_NAMES[view.items], items, ui_id(view, 21)); + if pick >= 0 { view.items = xx pick; mark_dirty(); } + flow.x += 8; + + if view.kind == .KANBAN { + flow_keep(*flow, measure_text("Columns", .NORMAL) + 158); flow_label(*flow, "Columns"); + fields := discrete_fields(p, .ALL); + pick_field_dropdown(*flow, 150, fields, *view.group_field, ui_id(view, 22)); + } else { + flow_keep(*flow, measure_text("Done when", .NORMAL) + 148); flow_label(*flow, "Done when"); + fields := discrete_fields(p, .ALL); + if pick_field_dropdown(*flow, 140, fields, *view.check_field, ui_id(view, 23)) { + f := find_field(p, view.check_field); + view.check_value = ifx f && f.kind == .SELECT && f.options.count then f.options[f.options.count - 1].id else 1; + } + f := find_field(p, view.check_field); + if f && f.kind == .SELECT { + flow_keep(*flow, measure_text("is", .NORMAL) + 148); flow_label(*flow, "is"); + option_dropdown(*flow, 140, f, *view.check_value, ui_id(view, 24)); + } + flow.x += 4; + flow_keep(*flow, 26 + measure_text("Group by epic", .NORMAL)); + cb := flow_next(*flow, 18); + if checkbox(make_rect(cb.x, cb.y + 6, 18, 18), view.group_by_epic, ui_id(view, 25)) { + view.group_by_epic = !view.group_by_epic; + mark_dirty(); + } + flow_label(*flow, "Group by epic"); + } + flow.x += 8; + + // Epic filter + flow_keep(*flow, measure_text("Epic", .NORMAL) + 178); flow_label(*flow, "Epic"); + epics := sorted_epics(p); + items.count = 0; + array_add(*items, .{label = "All epics", checked = view.filter_epic == 0}); + for epics array_add(*items, .{label = it.title, checked = view.filter_epic == it.id}); + current := find_task(p, view.filter_epic); + pick = dropdown(flow_next(*flow, 170), ifx current then current.title else "All epics", items, ui_id(view, 26)); + if pick >= 0 { + view.filter_epic = ifx pick == 0 then 0 else epics[pick - 1].id; + mark_dirty(); + } + + // Option filter + flow_keep(*flow, measure_text("Filter", .NORMAL) + 198); flow_label(*flow, "Filter"); + filter_label := "Everything"; + filter_color: u32; + keys: [..] [2] u32; + keys.allocator = temp; + items.count = 0; + array_add(*items, .{label = "Everything", checked = view.filter_option == 0}); + array_add(*keys, .[0, 0]); + for f: p.fields { + if f.kind == .USER { + add_user_filter :: (label: string, value: u32, color: u32) #expand { + selected := view.filter_field == f.id && view.filter_option == value; + array_add(*items, .{label = label, color = color, has_color = true, checked = selected}); + array_add(*keys, .[f.id, value]); + if selected { filter_label = label; filter_color = color; } + } + add_user_filter(tprint("%: Me", f.name), ME_OPTION, 0x4F8CFF); + for u: p.users if u.active add_user_filter(tprint("%: %", f.name, u.name), u.id, user_color(u.id)); + continue; + } + if !field_has_options(f) continue; + for o: f.options { + selected := view.filter_field == f.id && view.filter_option == o.id; + label := tprint("%: %", f.name, o.name); + array_add(*items, .{label = label, color = o.color, has_color = true, checked = selected}); + array_add(*keys, .[f.id, o.id]); + if selected { filter_label = label; filter_color = o.color; } + } + } + pick = dropdown(flow_next(*flow, 190), filter_label, items, ui_id(view, 27), swatch = filter_color, has_swatch = filter_color != 0); + if pick >= 0 { + view.filter_field = keys[pick][0]; + view.filter_option = keys[pick][1]; + mark_dirty(); + } + + // Card fields + flow_keep(*flow, measure_text("Fields", .NORMAL) + 128); flow_label(*flow, "Fields"); + items.count = 0; + for f: p.fields array_add(*items, .{label = f.name, checked = array_find(view.card_fields, f.id)}); + pick = dropdown(flow_next(*flow, 120), tprint("% shown", view.card_fields.count), items, ui_id(view, 28), multi = true); + if pick >= 0 { + id := p.fields[pick].id; + found, index := array_find(view.card_fields, id); + if found array_ordered_remove_by_index(*view.card_fields, index); + else array_add(*view.card_fields, id); + mark_dirty(); + } + + return flow_height(*flow, r.y); +} + +discrete_fields :: (p: *Project, exclude_scope: Field_Scope) -> [..] *Field { + result: [..] *Field; + result.allocator = temp; + for p.fields { + if !field_is_discrete(it) continue; + if exclude_scope != .ALL && it.scope == exclude_scope continue; + array_add(*result, it); + } + return result; +} + +pick_field_dropdown :: (flow: *Flow, w: float, fields: [] *Field, value: *u32, id: u64) -> bool { + items: [..] Popup_Item; + items.allocator = temp; + label := "Choose..."; + for fields { + array_add(*items, .{label = it.name, checked = it.id == value.*}); + if it.id == value.* label = it.name; + } + r := ifx flow then flow_next(flow, w) else make_rect(0, 0, w, 30); + pick := dropdown(r, label, items, id); + if pick >= 0 { + value.* = fields[pick].id; + mark_dirty(); + return true; + } + return false; +} + +// Dropdown over a field's options (or No/Yes for checkboxes). +option_dropdown_at :: (r: Rect, f: *Field, value: *u32, id: u64, allow_none := false) -> bool { + items: [..] Popup_Item; + items.allocator = temp; + values: [..] u32; + values.allocator = temp; + if allow_none { + array_add(*items, .{label = "None", checked = value.* == 0}); + array_add(*values, 0); + } + if f.kind == .CHECKBOX { + array_add(*items, .{label = "No", color = 0x8B949E, has_color = true, checked = value.* == 0}); + array_add(*items, .{label = "Yes", color = 0x3FB950, has_color = true, checked = value.* != 0}); + array_add(*values, 0); + array_add(*values, 1); + } else { + for f.options { + array_add(*items, .{label = it.name, color = it.color, has_color = true, checked = it.id == value.*}); + array_add(*values, it.id); + } + } + label := "None"; + swatch: u32; + has_swatch := false; + if f.kind == .CHECKBOX || value.* { + label, swatch = option_label(f, value.*); + has_swatch = true; + } + pick := dropdown(r, label, items, id, swatch = swatch, has_swatch = has_swatch); + if pick >= 0 { + value.* = values[pick]; + mark_dirty(); + return true; + } + return false; +} + +option_dropdown :: (flow: *Flow, w: float, f: *Field, value: *u32, id: u64) -> bool { + return option_dropdown_at(flow_next(flow, w), f, value, id); +} + +create_task_in_view :: (view: *View, title: string, has_column := false, column_value: u32 = 0, epic: u32 = 0) -> *Task { + p := app.project; + t := create_task(p, title, view.items == .EPICS); + if !t.is_epic { + if epic t.epic = epic; + else if view.filter_epic t.epic = view.filter_epic; + } + if view.filter_field && view.filter_option { + f := find_field(p, view.filter_field); + if f && f.kind == .TAGS toggle_tag(t, f.id, view.filter_option); + else if f set_sel(t, f.id, view.filter_option); + } + gf := find_field(p, view.group_field); + if has_column && gf { + set_sel(t, gf.id, column_value); + } else if view.kind == .KANBAN && gf && gf.kind == .SELECT && gf.options.count && !get_sel(t, gf.id) { + set_sel(t, gf.id, gf.options[0].id); + } else if view.kind == .CHECKLIST { + set_checked(p, view, t, false); + } + mark_dirty(); + return t; +} + +// +// Kanban +// + +Column :: struct { + value: u32; + label: string; + color: u32; + none: bool; +} + +task_in_column :: (t: *Task, gf: *Field, col: Column) -> bool { + sel := get_sel(t, gf.id); + if gf.kind == .CHECKBOX return (sel != 0) == (col.value != 0); + if col.none return !sel || !find_option(gf, sel); + return sel == col.value; +} + +draw_kanban :: (view: *View, r: Rect) { + p := app.project; + gf := find_field(p, view.group_field); + if !field_is_discrete(gf) { + draw_text_centered("Pick a Select or Checkbox field for the columns.", r, .MEDIUM, COL_TEXT_DIM); + return; + } + tasks := view_tasks(p, view); + + columns: [..] Column; + columns.allocator = temp; + if gf.kind == .CHECKBOX { + array_add(*columns, .{value = 0, label = tprint("Not %", gf.name), color = 0x8B949E}); + array_add(*columns, .{value = 1, label = gf.name, color = 0x3FB950}); + } else { + none := Column.{label = tprint("No %", gf.name), color = 0x6E7781, none = true}; + any_none := false; + for tasks if task_in_column(it, gf, none) { any_none = true; break; } + if any_none array_add(*columns, none); + for gf.options array_add(*columns, .{value = it.id, label = it.name, color = it.color}); + } + + board_id := ui_id(view, 100); + content_w := 24 + columns.count * (COLUMN_W + COLUMN_GAP) + 12; + board_off := scroll_begin(r, board_id, horizontal = true); + drag := *app.drag; + + for col, ci: columns { + col_r := make_rect(r.x + 24 + ci * (COLUMN_W + COLUMN_GAP) - board_off, r.y, COLUMN_W, r.h - 16); + if col_r.x > r.x + r.w || col_r.x + col_r.w < r.x continue; + col_tasks: [..] *Task; + col_tasks.allocator = temp; + for tasks if task_in_column(it, gf, col) array_add(*col_tasks, it); + + hover_here := drag.active && drag.view == view.id && contains(col_r, ui.mx, ui.my); + draw_rect(col_r, ifx hover_here then hex(0x1c2129) else COL_COLUMN, 10); + if hover_here draw_rect_outline(col_r, hex(col.color, 0.5), 1, 10); + + head := make_rect(col_r.x + 12, col_r.y, col_r.w - 24, 42); + color_dot(head.x + 5, head.y + 21, col.color); + draw_text_in_rect(col.label, make_rect(head.x + 18, head.y, head.w - 60, head.h), .MEDIUM, COL_TEXT); + count_text := tprint("%", col_tasks.count); + draw_text(count_text, head.x + head.w - measure_text(count_text, .NORMAL), head.y + 12, .NORMAL, COL_TEXT_FAINT); + + list_r := make_rect(col_r.x, col_r.y + 42, col_r.w, col_r.h - 42); + list_id := ui_id(view, 1000 + ci); + list_off := scroll_begin(list_r, list_id); + card_x := col_r.x + 8; + card_w := col_r.w - 16; + y := list_r.y - list_off; + + insert_index := -1; + others: [..] *Task; // Column tasks excluding the dragged one, for drop ordering. + others.allocator = temp; + for t: col_tasks { + if drag.active && t.id == drag.task continue; + h := card_height(t, view, card_w); + if hover_here && insert_index < 0 && ui.my < y + h * 0.5 { + insert_index = others.count; + draw_rect(make_rect(card_x, y - 5, card_w, 2), COL_ACCENT, 1); + } + array_add(*others, t); + draw_card(t, make_rect(card_x, y, card_w, h), view); + y += h + 8; + } + if hover_here && insert_index < 0 { + insert_index = others.count; + draw_rect(make_rect(card_x, y - 5, card_w, 2), COL_ACCENT, 1); + } + + // Quick add + qa_id := ui_id(view, 5000 + ci); + y += quick_add(make_rect(card_x, y, card_w, 32), qa_id, "+ Add task", view, true, col.value, 0); + + scroll_end(list_r, list_id, y + list_off - list_r.y + 8); + + if hover_here { + drag.target_valid = true; + drag.target_value = col.value; + drag.target_none = col.none; + drag.target_has_order = others.count > 0; + if others.count { + if insert_index == 0 drag.target_order = others[0].order - 1; + else if insert_index >= others.count drag.target_order = others[others.count - 1].order + 1; + else drag.target_order = (others[insert_index - 1].order + others[insert_index].order) * 0.5; + } + } + } + scroll_end(r, board_id, content_w, horizontal = true); +} + +// An inline "add" input. Returns the height used. +quick_add :: (r: Rect, id: u64, label: string, view: *View, has_column: bool, column_value: u32, epic: u32) -> float { + if app.quick_add_id == id { + _, submitted := text_edit(make_rect(r.x, r.y, r.w, 34), *app.quick_text, id, "Title, then Enter"); + if submitted { + if app.quick_text.count { + if view create_task_in_view(view, app.quick_text, has_column, column_value, epic); + else { + t := create_task(app.project, app.quick_text); + t.epic = epic; + mark_dirty(); + } + set_string(*app.quick_text, ""); + focus_text(id, app.quick_text); // Keep typing the next one. + } + } + if ui.focus != id app.quick_add_id = 0; + return 42; + } + if button(r, label, .GHOST, id = id + 1) { + app.quick_add_id = id; + set_string(*app.quick_text, ""); + focus_text(id, app.quick_text); + } + return r.h + 6; +} + +Card_Chip :: struct { + label: string; + color: u32; +} + +card_chips :: (t: *Task, view: *View) -> [..] Card_Chip { + p := app.project; + chips: [..] Card_Chip; + chips.allocator = temp; + for id: view.card_fields { + f := find_field(p, id); + if !f || !field_applies_to(f, t) continue; + v := get_value(t, f.id); + if !v continue; + if f.kind == { + case .SELECT; + o := find_option(f, v.sel); + if o array_add(*chips, .{o.name, o.color}); + case .TAGS; + for v.tags { + o := find_option(f, it); + if o array_add(*chips, .{o.name, o.color}); + } + case .USER; + for v.tags { + u := find_user(p, it); + if u array_add(*chips, .{u.name, user_color(u.id)}); + } + case .CHECKBOX; + if v.sel array_add(*chips, .{f.name, 0x3FB950}); + case .NUMBER; + if v.text.count array_add(*chips, .{tprint("%: %", f.name, v.text), 0x8B949E}); + case .TEXT; + if v.text.count array_add(*chips, .{fit_text(v.text, 150, .SMALL), 0x8B949E}); + } + } + return chips; +} + +CARD_PAD :: 10.0; + +// Lays out (and optionally draws) a card. Returns its height. +layout_card :: (t: *Task, view: *View, r: Rect, draw: bool) -> float { + p := app.project; + x := r.x + CARD_PAD; + w := r.w - 2 * CARD_PAD; + y := r.y + CARD_PAD; + + if t.is_epic { + if draw chip(x, y, "EPIC", 0xBC8CFF); + y += chip_height() + 6; + } + title := ifx t.title.count then t.title else "Untitled"; + if draw y += draw_wrapped_text(title, x, y, w, .NORMAL, ifx t.title.count then COL_TEXT else COL_TEXT_FAINT); + else y += measure_wrapped_height(title, w, .NORMAL); + + epic := find_task(p, t.epic); + if epic && view.filter_epic == 0 { + y += 4; + if draw { + draw_rect(make_rect(x, y + 5, 6, 6), COL_EPIC, 1.5); + draw_text(fit_text(epic.title, w - 12, .SMALL), x + 11, y, .SMALL, hex(0xB9A3E3)); + } + y += line_height(.SMALL); + } + if t.is_epic { + y += 4; + if draw draw_text(tprint("% tasks", epic_children_count(p, t.id)), x, y, .SMALL, COL_TEXT_FAINT); + y += line_height(.SMALL); + } + + chips := card_chips(t, view); + if chips.count { + y += 8; + flow := make_flow(make_rect(x, y, w, 0), chip_height(), 4); + for chips { + cr := flow_next(*flow, chip_width(it.label)); + if draw chip(cr.x, cr.y, it.label, it.color); + } + y += flow_height(*flow, y); + } + return y + CARD_PAD - r.y; +} + +card_height :: (t: *Task, view: *View, w: float) -> float { + return layout_card(t, view, make_rect(0, 0, w, 0), false); +} + +draw_card :: (t: *Task, r: Rect, view: *View, interactive := true) { + hovered := false; + if interactive { + id := ui_id(t, 3); + clicked, hov := button_behavior(id, r); + hovered = hov; + if hov && ui.pressed && ui.active == id { + d := *app.drag; + d.task = t.id; + d.view = view.id; + d.pending = true; + d.active = false; + d.start_x, d.start_y = ui.mx, ui.my; + d.grab_dx, d.grab_dy = ui.mx - r.x, ui.my - r.y; + d.card_w, d.card_h = r.w, r.h; + } + if clicked && !app.drag.active app.selected = t.id; + } + if !interactive draw_rect(make_rect(r.x + 2, r.y + 6, r.w, r.h), hex(0x000000, 0.4), 8); + draw_rect(r, ifx hovered || !interactive then COL_CARD_HOVER else COL_CARD, 8); + if app.selected == t.id draw_rect_outline(r, COL_ACCENT, 1.5, 8); + else draw_rect_outline(r, hex(0xFFFFFF, 0.04), 1, 8); + layout_card(t, view, r, true); +} + +update_drag :: () { + p := app.project; + d := *app.drag; + if d.pending && !d.active && ui.down { + dx, dy := ui.mx - d.start_x, ui.my - d.start_y; + if dx*dx + dy*dy > 25 { + d.active = true; + ui.active = 0; // So releasing doesn't count as a click on the card. + } + } + if d.active { + ui.cursor_kind = .GRAB; + t := find_task(p, d.task); + view := find_view(p, d.view); + if t && view { + set_layer(LAYER_DRAG); + draw_card(t, make_rect(ui.mx - d.grab_dx, ui.my - d.grab_dy, d.card_w, d.card_h), view, interactive = false); + set_layer(LAYER_BASE); + } + } + if ui.released || !ui.down { + if d.active && d.target_valid { + t := find_task(p, d.task); + view := find_view(p, d.view); + gf := ifx view then find_field(p, view.group_field) else null; + if t && gf { + if d.target_none clear_value(t, gf.id); + else set_sel(t, gf.id, d.target_value); + if d.target_has_order t.order = d.target_order; + mark_dirty(); + } + } + d.pending = false; + d.active = false; + } +} + +// +// Checklist +// + +Checklist_Group :: struct { + epic: *Task; + tasks: [..] *Task; +} + +draw_checklist :: (view: *View, r: Rect) { + p := app.project; + cf := find_field(p, view.check_field); + if !field_is_discrete(cf) { + draw_text_centered("Pick a Select or Checkbox field under \"Done when\".", r, .MEDIUM, COL_TEXT_DIM); + return; + } + tasks := view_tasks(p, view); + + groups: [..] Checklist_Group; + groups.allocator = temp; + if view.group_by_epic { + for epic: sorted_epics(p) { + if view.filter_epic && epic.id != view.filter_epic continue; + g: Checklist_Group; + g.epic = epic; + g.tasks.allocator = temp; + for tasks if !it.is_epic && it.epic == epic.id array_add(*g.tasks, it); + array_add(*groups, g); + } + loose: Checklist_Group; + loose.tasks.allocator = temp; + for tasks if (it.is_epic || !find_task(p, it.epic)) array_add(*loose.tasks, it); + if loose.tasks.count || !groups.count array_add(*groups, loose); + } else { + g: Checklist_Group; + g.tasks = tasks; + array_add(*groups, g); + } + + total, done := 0, 0; + for g: groups for g.tasks { + total += 1; + if is_checked(p, view, it) done += 1; + } + + width := min(r.w - 48, 860.0); + area := make_rect(r.x + 24, r.y, width, r.h); + + // Overall progress. + head := cut_top(*area, 30); + draw_text(tprint("% of % done", done, total), head.x, head.y + 2, .NORMAL, COL_TEXT_DIM); + bar := make_rect(head.x + 140, head.y + 8, min(head.w - 140, 300.0), 6); + draw_rect(bar, hex(0xFFFFFF, 0.08), 3); + if total draw_rect(make_rect(bar.x, bar.y, bar.w * done / cast(float) total, bar.h), hex(0x3FB950), 3); + cut_top(*area, 4); + + list_r := make_rect(r.x, area.y, r.w, area.h); + sid := ui_id(view, 200); + off := scroll_begin(list_r, sid); + y := area.y - off; + + for g, gi: groups { + if view.group_by_epic { + y += 10; + title := ifx g.epic then g.epic.title else "No epic"; + gdone := 0; + for g.tasks if is_checked(p, view, it) gdone += 1; + hr := make_rect(area.x, y, area.w, 30); + if g.epic { + clicked, hovered := button_behavior(ui_id(g.epic, 201), hr); + if hovered draw_rect(hr, COL_HOVER, RADIUS); + if clicked app.selected = g.epic.id; + draw_rect(make_rect(hr.x + 8, hr.y + 11, 8, 8), COL_EPIC, 2); + } + draw_text(title, hr.x + 24, hr.y + 5, .MEDIUM, COL_TEXT); + draw_text(tprint("%/%", gdone, g.tasks.count), hr.x + 34 + measure_text(title, .MEDIUM), hr.y + 7, .NORMAL, COL_TEXT_FAINT); + y += 34; + } + + for t: g.tasks { + y += checklist_row(view, t, make_rect(area.x, y, area.w, 0)); + } + + epic_id := ifx g.epic then g.epic.id else 0; + y += quick_add(make_rect(area.x, y, 200, 30), ui_id(view, 6000 + gi), "+ Add item", view, false, 0, epic_id); + y += 6; + } + scroll_end(list_r, sid, y + off - list_r.y + 16); +} + +checklist_row :: (view: *View, t: *Task, r: Rect) -> float { + p := app.project; + checked := is_checked(p, view, t); + + chips := card_chips(t, view); + chips_w := 0.0; + for chips chips_w += chip_width(it.label) + 4; + chips_w = min(chips_w, r.w * 0.4); + + title_x := r.x + 40; + title_w := r.w - 40 - chips_w - 16; + title := ifx t.title.count then t.title else "Untitled"; + lines: [..] Text_Line; + lines.allocator = temp; + wrap_text(title, title_w, .NORMAL, *lines); + lh := line_height(.NORMAL); + h := max(36.0, lines.count * lh + 16); + row := make_rect(r.x, r.y, r.w, h); + + if checkbox(make_rect(r.x + 12, r.y + 9, 18, 18), checked, ui_id(t, 30)) { + set_checked(p, view, t, !checked); + mark_dirty(); + } + clicked, hovered := button_behavior(ui_id(t, 31), make_rect(title_x - 6, r.y, r.w - 34, h)); + if clicked app.selected = t.id; + if hovered || app.selected == t.id draw_rect(make_rect(r.x, r.y + 1, r.w, h - 2), ifx app.selected == t.id then COL_SELECTED else COL_HOVER, RADIUS); + + color := ifx checked then COL_TEXT_FAINT else (ifx t.title.count then COL_TEXT else COL_TEXT_FAINT); + for lines { + line := substring(title, it.start, it.end); + ly := r.y + 8 + it_index * lh; + draw_text(line, title_x, ly, .NORMAL, color); + if checked draw_rect(make_rect(title_x, ly + lh * 0.55, measure_text(line, .NORMAL), 1.2), COL_TEXT_FAINT); + } + + cx := r.x + r.w - chips_w - 8; + push_clip(make_rect(cx, r.y, chips_w + 8, h)); + for chips { + cx += chip(cx, r.y + 9, it.label, it.color) + 4; + } + pop_clip(); + + draw_rect(make_rect(r.x + 8, r.y + h - 1, r.w - 16, 1), hex(0xFFFFFF, 0.04)); + return h; +} + +// +// Task editor panel +// + +LABEL_W :: 116.0; +ROW_H :: 32.0; + +draw_task_editor :: (r: Rect) { + p := app.project; + t := find_task(p, app.selected); + if !t return; + + draw_rect(r, COL_PANEL); + draw_rect(make_rect(r.x, r.y, 1, r.h), COL_BORDER); + area := inset(r, 18, 14); + + header := cut_top(*area, 30); + draw_text(ifx t.is_epic then "EPIC" else "TASK", header.x, header.y + 8, .SMALL, ifx t.is_epic then COL_EPIC else COL_TEXT_FAINT); + if icon_button(cut_right(*header, 30), .CLOSE, ui_id("close editor")) { + app.selected = 0; + return; + } + cut_right(*header, 6); + if confirm_button(cut_right(*header, 84), "Delete", ui_id(t, 4), "Delete?") { + delete_task(p, t.id); + app.selected = 0; + mark_dirty(); + return; + } + cut_top(*area, 6); + + sid := ui_id(t, 5); + off := scroll_begin(area, sid); + x := area.x; + w := area.w - 8; + y := area.y - off; + + th := text_edit_height(t.title, w + 8, .LARGE); + if text_edit(make_rect(x - 8, y, w + 8, th), *t.title, ui_id(t, 10), "Title", multiline = true, size = .LARGE, style = .GHOST) mark_dirty(); + y += th + 12; + + editor_label :: (text: string, y: float, auto := false) #expand { + draw_text_in_rect(text, make_rect(`x, y, LABEL_W - 8, ROW_H), .NORMAL, COL_TEXT_DIM); + if auto chip(`x + min(measure_text(text, .NORMAL), LABEL_W - 44) + 6, y + 8, "auto", 0x4F8CFF); + } + + // Type + editor_label("Type", y); + kinds := string.["Task", "Epic"]; + kind := segmented(make_rect(x + LABEL_W, y, 160, ROW_H), kinds, xx t.is_epic, ui_id(t, 11)); + if kind != cast(int) t.is_epic { + t.is_epic = kind == 1; + if t.is_epic t.epic = 0; + else for p.tasks if it.epic == t.id it.epic = 0; + mark_dirty(); + } + y += ROW_H + 8; + + if !t.is_epic { + editor_label("Epic", y); + epics := sorted_epics(p); + items: [..] Popup_Item; + items.allocator = temp; + array_add(*items, .{label = "None", checked = t.epic == 0}); + for epics array_add(*items, .{label = it.title, color = 0xBC8CFF, has_color = true, checked = it.id == t.epic}); + epic := find_task(p, t.epic); + pick := dropdown(make_rect(x + LABEL_W, y, w - LABEL_W, ROW_H), ifx epic then epic.title else "None", items, ui_id(t, 12), swatch = 0xBC8CFF, has_swatch = epic != null); + if pick >= 0 { + t.epic = ifx pick == 0 then 0 else epics[pick - 1].id; + mark_dirty(); + } + y += ROW_H + 8; + } + + for f: p.fields { + if !field_applies_to(f, t) continue; + auto := t.is_epic && field_is_rule_driven(p, t, f.id); + editor_label(f.name, y, auto); + er := make_rect(x + LABEL_W, y, w - LABEL_W, ROW_H); + fid := ui_id(t, 1000 + cast(int) f.id); + v := value_for_edit(t, f.id); + if f.kind == { + case .TEXT; + if text_edit(er, *v.text, fid, "Empty") mark_dirty(); + case .NUMBER; + if text_edit(er, *v.text, fid, "0") { + v.num = string_to_float(v.text); + mark_dirty(); + } + case .CHECKBOX; + if checkbox(make_rect(er.x, er.y + 7, 18, 18), v.sel != 0, fid) { + v.sel = xx (v.sel == 0); + mark_dirty(); + } + case .SELECT; + option_dropdown_at(er, f, *v.sel, fid, allow_none = true); + case .USER; + users: [..] *User_Info; + users.allocator = temp; + items: [..] Popup_Item; + items.allocator = temp; + names: [..] string; + names.allocator = temp; + for * u: p.users { + on := has_tag(t, f.id, u.id); + if !u.active && !on continue; + array_add(*users, u); + array_add(*items, .{label = u.name, color = user_color(u.id), has_color = true, checked = on}); + if on array_add(*names, u.name); + } + label := ifx names.count then join_temp(names) else (ifx p.users.count then "Nobody" else "No users (local project)"); + pick := dropdown(er, label, items, fid, multi = true); + if pick >= 0 { + toggle_tag(t, f.id, users[pick].id); + mark_dirty(); + } + case .TAGS; + items: [..] Popup_Item; + items.allocator = temp; + names: [..] string; + names.allocator = temp; + for o: f.options { + on := has_tag(t, f.id, o.id); + array_add(*items, .{label = o.name, color = o.color, has_color = true, checked = on}); + if on array_add(*names, o.name); + } + label := ifx names.count then join_temp(names) else "None"; + pick := dropdown(er, label, items, fid, multi = true); + if pick >= 0 { + toggle_tag(t, f.id, f.options[pick].id); + mark_dirty(); + } + } + y += ROW_H + 8; + } + + y += 6; + draw_text("Notes", x, y, .NORMAL, COL_TEXT_DIM); + y += 24; + nh := max(text_edit_height(t.notes, w + 8, .NORMAL), 96.0); + if text_edit(make_rect(x - 4, y, w + 4, nh), *t.notes, ui_id(t, 13), "Add notes...", multiline = true) mark_dirty(); + y += nh + 18; + + if t.is_epic y = draw_epic_details(t, x, y, w); + + scroll_end(area, sid, y + off - area.y); +} + +draw_epic_details :: (epic: *Task, x: float, y: float, w: float) -> float { + p := app.project; + children: [..] *Task; + children.allocator = temp; + for p.tasks if !it.is_epic && it.epic == epic.id array_add(*children, it); + sort_by_order(children); + + // Status-like field to show next to each child: the current view's column field, else the first Select field. + status: *Field; + view := find_view(p, app.view_id); + if view status = find_field(p, view.group_field); + if !status || status.scope == .EPICS { + status = null; + for p.fields if it.kind == .SELECT && it.scope != .EPICS { status = it; break; } + } + + draw_text(tprint("Tasks (%)", children.count), x, y, .MEDIUM, COL_TEXT); + y += 30; + for t: children { + row := make_rect(x - 4, y, w + 4, 30); + clicked, hovered := button_behavior(ui_id(t, 40), row); + if hovered draw_rect(row, COL_HOVER, RADIUS); + if clicked app.selected = t.id; + chip_w := 0.0; + if status { + label, color := option_label(status, get_sel(t, status.id)); + chip_w = chip_width(label); + chip(row.x + row.w - chip_w - 6, row.y + 6, label, color); + } + draw_text_in_rect(t.title, make_rect(row.x + 8, row.y, row.w - chip_w - 24, row.h), .NORMAL, COL_TEXT); + y += 32; + } + y += quick_add(make_rect(x - 4, y, 200, 30), ui_id(epic, 41), "+ Add task", null, false, 0, epic.id); + y += 18; + + draw_text("Rules", x, y, .MEDIUM, COL_TEXT); + y += 28; + any := false; + for rule: p.rules { + if !rule_applies_to(rule, epic) continue; + any = true; + result := evaluate_rule(p, rule, epic); + color_dot(x + 4, y + 9, ifx result.matched then cast(u32) 0x3FB950 else 0x3A404B); + y += draw_wrapped_text(rule_sentence(rule), x + 16, y, w - 16, .SMALL, ifx result.matched then COL_TEXT else COL_TEXT_DIM); + y += 8; + } + if !any { + draw_text("No rules apply to this epic.", x, y, .SMALL, COL_TEXT_FAINT); + y += 22; + } + if button(make_rect(x - 4, y + 4, 120, 30), "Edit rules", id = ui_id(epic, 42)) app.page = .RULES; + return y + 50; +} + +rule_sentence :: (rule: *Epic_Rule) -> string { + p := app.project; + target := find_field(p, rule.target_field); + cond := find_field(p, rule.cond_field); + filter := find_field(p, rule.filter_field); + target_name := ifx target then target.name else "?"; + cond_name := ifx cond then cond.name else "?"; + value, _ := option_label(target, rule.target_value); + cond_value, _ := option_label(cond, rule.cond_value); + tasks := "tasks"; + if filter && rule.filter_option { + o, _ := option_label(filter, rule.filter_option); + tasks = tprint("%: % tasks", filter.name, o); + } + return tprint("Set % to % when % % have % % %.", target_name, value, QUANTIFIER_NAMES[rule.quant], tasks, + cond_name, COMPARE_NAMES[rule.cond_op], cond_value); +} + +// +// Fields page +// + +page_header :: (r: *Rect, title: string, subtitle: string, button_label: string, button_id: u64) -> bool { + head := cut_top(r, 40); + clicked := button(cut_right(*head, 130), button_label, .PRIMARY, id = button_id); + draw_text(title, head.x, head.y + 2, .TITLE, COL_TEXT); + cut_top(r, 4); + h := draw_wrapped_text(subtitle, r.x, r.y, min(r.w, 760.0), .NORMAL, COL_TEXT_DIM); + cut_top(r, h + 18); + return clicked; +} + +draw_fields_page :: (r: Rect) { + p := app.project; + area := inset(r, 28, 18); + area.w = min(area.w, 920.0); + if page_header(*area, "Fields", "Custom fields that tasks and epics can have. Select and Tags fields have options; Select and Checkbox fields can drive board columns, checklists and epic rules.", "+ Add field", ui_id("add field")) { + f := add_field(p, "New field", .SELECT); + add_option(p, f, "Option A"); + add_option(p, f, "Option B"); + focus_text(ui_id(f, 1), f.name, select_all = true); + mark_dirty(); + } + + list_r := make_rect(r.x, area.y, r.w, r.y + r.h - area.y); + sid := ui_id("fields scroll"); + off := scroll_begin(list_r, sid); + y := area.y - off; + x := area.x; + w := area.w; + + move_from, move_to := -1, -1; + delete_id: u32; + for f, fi: p.fields { + has_options := field_has_options(f); + card_h := 56.0; + if has_options card_h += f.options.count * 36 + 40; + card := make_rect(x, y, w, card_h); + draw_rect(card, COL_PANEL, 10); + draw_rect_outline(card, COL_BORDER, 1, 10); + + row := inset(make_rect(card.x, card.y, card.w, 56), 12, 12); + name_r := cut_left(*row, 240); + if text_edit(name_r, *f.name, ui_id(f, 1), "Field name", size = .MEDIUM) mark_dirty(); + cut_left(*row, 10); + + items: [..] Popup_Item; + items.allocator = temp; + for FIELD_KIND_NAMES array_add(*items, .{label = it, checked = it_index == cast(int) f.kind}); + pick := dropdown(cut_left(*row, 120), FIELD_KIND_NAMES[f.kind], items, ui_id(f, 2)); + if pick >= 0 { + change_field_kind(p, f, xx pick); + if field_has_options(f) && !f.options.count add_option(p, f, "Option A"); + mark_dirty(); + } + cut_left(*row, 8); + items.count = 0; + for FIELD_SCOPE_NAMES array_add(*items, .{label = it, checked = it_index == cast(int) f.scope}); + pick = dropdown(cut_left(*row, 150), FIELD_SCOPE_NAMES[f.scope], items, ui_id(f, 3)); + if pick >= 0 { f.scope = xx pick; mark_dirty(); } + + if confirm_button(cut_right(*row, 80), "Delete", ui_id(f, 4), "Sure?") delete_id = f.id; + cut_right(*row, 6); + if icon_button(cut_right(*row, 30), .DOWN, ui_id(f, 5)) && fi < p.fields.count - 1 { move_from, move_to = fi, fi + 1; } + if icon_button(cut_right(*row, 30), .UP, ui_id(f, 6)) && fi > 0 { move_from, move_to = fi, fi - 1; } + + if has_options { + oy := card.y + 56; + option_move_from, option_move_to := -1, -1; + delete_option_id: u32; + for * o, oi: f.options { + orow := make_rect(card.x + 24, oy, card.w - 48, 30); + swatch := cut_left(*orow, 30); + clicked, hovered := button_behavior(ui_id(f, 10000 + cast(int) o.id), swatch); + if hovered draw_rect(swatch, COL_HOVER, RADIUS); + color_dot(swatch.x + 15, swatch.y + 15, o.color, 7); + if clicked { + index := 0; + for OPTION_PALETTE if it == o.color index = it_index; + o.color = OPTION_PALETTE[(index + 1) % OPTION_PALETTE.count]; + mark_dirty(); + } + cut_left(*orow, 6); + if text_edit(cut_left(*orow, 240), *o.name, ui_id(f, 20000 + cast(int) o.id), "Option name") mark_dirty(); + cut_left(*orow, 6); + if icon_button(cut_left(*orow, 28), .UP, ui_id(f, 30000 + cast(int) o.id)) && oi > 0 { option_move_from, option_move_to = oi, oi - 1; } + if icon_button(cut_left(*orow, 28), .DOWN, ui_id(f, 40000 + cast(int) o.id)) && oi < f.options.count - 1 { option_move_from, option_move_to = oi, oi + 1; } + if icon_button(cut_left(*orow, 28), .TRASH, ui_id(f, 50000 + cast(int) o.id)) delete_option_id = o.id; + oy += 36; + } + if button(make_rect(card.x + 24, oy + 2, 130, 30), "+ Add option", .GHOST, id = ui_id(f, 7)) { + id := add_option(p, f, "New option"); + focus_text(ui_id(f, 20000 + cast(int) id), "New option", select_all = true); + mark_dirty(); + } + if option_move_from >= 0 { + tmp := f.options[option_move_from]; + f.options[option_move_from] = f.options[option_move_to]; + f.options[option_move_to] = tmp; + mark_dirty(); + } + if delete_option_id { + delete_option(p, f, delete_option_id); + mark_dirty(); + } + } + y += card_h + 12; + } + if move_from >= 0 { + swap_order(p.fields[move_from], p.fields[move_to]); + sort_project(p); + mark_dirty(); + } + if delete_id { + delete_field(p, delete_id); + mark_dirty(); + } + scroll_end(list_r, sid, y + off - list_r.y + 20); +} + +// +// Epic rules page +// + +draw_rules_page :: (r: Rect) { + p := app.project; + area := inset(r, 28, 18); + area.w = min(area.w, 980.0); + subtitle :: "Rules set fields on epics from the state of their tasks, e.g. \"set Art to Done when all Art tasks have Status is Done\". For each epic and field, the first matching rule from the top wins; if none match, the field keeps its value and can be set by hand."; + if page_header(*area, "Epic rules", subtitle, "+ Add rule", ui_id("add rule")) { + rule := add_rule(p); + targets := discrete_fields(p, .TASKS); + conds := discrete_fields(p, .EPICS); + if targets.count { + rule.target_field = targets[0].id; + if targets[0].kind == .SELECT && targets[0].options.count rule.target_value = targets[0].options[targets[0].options.count - 1].id; + } + if conds.count { + rule.cond_field = conds[0].id; + if conds[0].kind == .SELECT && conds[0].options.count rule.cond_value = conds[0].options[conds[0].options.count - 1].id; + } + mark_dirty(); + } + + list_r := make_rect(r.x, area.y, r.w, r.y + r.h - area.y); + sid := ui_id("rules scroll"); + off := scroll_begin(list_r, sid); + y := area.y - off; + + if !p.rules.count { + draw_text("No rules yet.", area.x, y, .NORMAL, COL_TEXT_FAINT); + y += 30; + } + + move_from, move_to := -1, -1; + delete_index := -1; + for rule, ri: p.rules { + content := make_rect(area.x + 16, y + 14, area.w - 32 - 110, 0); + h := rule_editor(rule, content, measure_only = true); + card := make_rect(area.x, y, area.w, h + 28 + 26); + draw_rect(card, COL_PANEL, 10); + draw_rect_outline(card, COL_BORDER, 1, 10); + draw_text(tprint("%", ri + 1), card.x - 22, card.y + 18, .NORMAL, COL_TEXT_FAINT); + + rule_editor(rule, content, measure_only = false); + + tools := make_rect(card.x + card.w - 108, card.y + 12, 96, 30); + if icon_button(cut_left(*tools, 30), .UP, ui_id(rule, 1)) && ri > 0 { move_from, move_to = ri, ri - 1; } + if icon_button(cut_left(*tools, 30), .DOWN, ui_id(rule, 2)) && ri < p.rules.count - 1 { move_from, move_to = ri, ri + 1; } + if icon_button(cut_left(*tools, 30), .TRASH, ui_id(rule, 3)) delete_index = ri; + + // Live preview of which epics this rule currently matches. + matched: [..] string; + matched.allocator = temp; + for epic: sorted_epics(p) { + if rule_applies_to(rule, epic) && evaluate_rule(p, rule, epic).matched array_add(*matched, epic.title); + } + preview := ifx matched.count then tprint("Currently true for: %", join_temp(matched)) else "Currently true for no epics."; + draw_text(fit_text(preview, card.w - 32, .SMALL), card.x + 16, card.y + card.h - 26, .SMALL, ifx matched.count then hex(0x3FB950) else COL_TEXT_FAINT); + + y += card.h + 12; + } + if move_from >= 0 { + swap_order(p.rules[move_from], p.rules[move_to]); + sort_project(p); + mark_dirty(); + } + if delete_index >= 0 { + free(p.rules[delete_index]); + array_ordered_remove_by_index(*p.rules, delete_index); + mark_dirty(); + } + scroll_end(list_r, sid, y + off - list_r.y + 20); +} + +// The rule as a sentence of dropdowns. With measure_only, nothing is drawn and the height is returned. +rule_editor :: (rule: *Epic_Rule, r: Rect, measure_only: bool) -> float { + p := app.project; + flow := make_flow(r, 30, 8); + + label :: (text: string) #expand { + if `measure_only flow_next(*`flow, measure_text(text, .NORMAL)); + else flow_label(*`flow, text); + } + slot :: (w: float) -> Rect #expand { + return flow_next(*`flow, w); + } + newline :: () #expand { + `flow.x = `flow.x0; + `flow.y += `flow.row_h + `flow.gap; + } + + // Line 1: For set to + label("For"); + sr := slot(180); + target := find_field(p, rule.target_field); + sr2 := Rect.{}; + if !measure_only { + epics := sorted_epics(p); + items: [..] Popup_Item; + items.allocator = temp; + array_add(*items, .{label = "every epic", checked = rule.epic == 0}); + for epics array_add(*items, .{label = it.title, checked = rule.epic == it.id}); + epic := find_task(p, rule.epic); + pick := dropdown(sr, ifx epic then epic.title else "every epic", items, ui_id(rule, 10)); + if pick >= 0 { rule.epic = ifx pick == 0 then 0 else epics[pick - 1].id; mark_dirty(); } + } + label("set"); + sr = slot(140); + if !measure_only { + fields := discrete_fields(p, .TASKS); + if pick_field_dropdown_at(sr, fields, *rule.target_field, ui_id(rule, 11)) rule.target_value = 0; + target = find_field(p, rule.target_field); + } + label("to"); + sr = slot(140); + if !measure_only && target option_dropdown_at(sr, target, *rule.target_value, ui_id(rule, 12)); + + newline(); + + // Line 2: when of its tasks have + label("when"); + sr = slot(80); + if !measure_only { + items: [..] Popup_Item; + items.allocator = temp; + for QUANTIFIER_NAMES array_add(*items, .{label = it, checked = it_index == cast(int) rule.quant}); + pick := dropdown(sr, QUANTIFIER_NAMES[rule.quant], items, ui_id(rule, 13)); + if pick >= 0 { rule.quant = xx pick; mark_dirty(); } + } + label("of its"); + sr = slot(190); + if !measure_only { + items: [..] Popup_Item; + items.allocator = temp; + keys: [..] [2] u32; + keys.allocator = temp; + current := "tasks"; + current_color: u32; + array_add(*items, .{label = "tasks", checked = rule.filter_option == 0}); + array_add(*keys, .[0, 0]); + for f: p.fields { + if !field_has_options(f) || f.scope == .EPICS continue; + for o: f.options { + l := tprint("% tasks", o.name); + selected := rule.filter_field == f.id && rule.filter_option == o.id; + array_add(*items, .{label = tprint("%: %", f.name, o.name), color = o.color, has_color = true, checked = selected}); + array_add(*keys, .[f.id, o.id]); + if selected { current = l; current_color = o.color; } + } + } + pick := dropdown(sr, current, items, ui_id(rule, 14), swatch = current_color, has_swatch = current_color != 0); + if pick >= 0 { + rule.filter_field, rule.filter_option = keys[pick][0], keys[pick][1]; + mark_dirty(); + } + } + label("have"); + sr = slot(130); + cond := find_field(p, rule.cond_field); + if !measure_only { + fields := discrete_fields(p, .EPICS); + if pick_field_dropdown_at(sr, fields, *rule.cond_field, ui_id(rule, 15)) rule.cond_value = 0; + cond = find_field(p, rule.cond_field); + } + sr = slot(80); + if !measure_only { + items: [..] Popup_Item; + items.allocator = temp; + for COMPARE_NAMES array_add(*items, .{label = it, checked = it_index == cast(int) rule.cond_op}); + pick := dropdown(sr, COMPARE_NAMES[rule.cond_op], items, ui_id(rule, 16)); + if pick >= 0 { rule.cond_op = xx pick; mark_dirty(); } + } + sr = slot(140); + if !measure_only && cond option_dropdown_at(sr, cond, *rule.cond_value, ui_id(rule, 17)); + + return flow_height(*flow, r.y); +} + +pick_field_dropdown_at :: (r: Rect, fields: [] *Field, value: *u32, id: u64) -> bool { + items: [..] Popup_Item; + items.allocator = temp; + label := "Choose..."; + for fields { + array_add(*items, .{label = it.name, checked = it.id == value.*}); + if it.id == value.* label = it.name; + } + pick := dropdown(r, label, items, id); + if pick >= 0 { + value.* = fields[pick].id; + mark_dirty(); + return true; + } + return false; +} + +join_temp :: (parts: [] string, separator := ", ") -> string { + b: String_Builder; + b.allocator = temp; + for parts { + if it_index append(*b, separator); + append(*b, it); + } + return builder_to_string(*b,, allocator = temp); +} diff --git a/src/common.jai b/src/common.jai new file mode 100644 index 0000000..af4e5a6 --- /dev/null +++ b/src/common.jai @@ -0,0 +1,261 @@ +// Types shared by every part of the app: platform-agnostic input, rects, colors, small string helpers. + +// +// Input. Both platform layers fill one of these per frame; the app never talks to the OS directly. +// + +KEY_BACKSPACE :: 8; +KEY_TAB :: 9; +KEY_ENTER :: 13; +KEY_ESCAPE :: 27; +KEY_DELETE :: 127; +KEY_UP :: 128; +KEY_DOWN :: 129; +KEY_LEFT :: 130; +KEY_RIGHT :: 131; +KEY_PAGE_UP :: 132; +KEY_PAGE_DOWN :: 133; +KEY_HOME :: 134; +KEY_END :: 135; +// Letters use their uppercase ASCII code ('A' = 65), matching the Input module's Key_Code. + +MOD_SHIFT :: 1; +MOD_CTRL :: 2; +MOD_ALT :: 4; + +Key_Press :: struct { + key: u32; + mods: u32; +} + +MOUSE_LEFT :: 0; +MOUSE_RIGHT :: 1; +MOUSE_MIDDLE :: 2; + +Frame_Input :: struct { + width, height: float; // Logical (DPI independent) size of the drawable area. + dpr: float = 1; // Physical pixels per logical pixel. + time: float64; // Seconds. + + mouse_x, mouse_y: float; + mouse_down: [3] bool; + mouse_pressed: [3] bool; + mouse_released: [3] bool; + wheel_x, wheel_y: float; // In "lines". Positive y scrolls content down (towards the end). + + keys: [..] Key_Press; + chars: [..] u32; // Text input, as UTF-32 code points. + mods: u32; +} + +reset_per_frame_input :: (input: *Frame_Input) { + for 0..2 { + input.mouse_pressed[it] = false; + input.mouse_released[it] = false; + } + input.wheel_x = 0; + input.wheel_y = 0; + input.keys.count = 0; + input.chars.count = 0; +} + +Cursor_Kind :: enum s32 { + ARROW :: 0; + HAND :: 1; + TEXT :: 2; + GRAB :: 3; +} + +// +// Rects (logical pixels, origin top-left, y down). +// + +Rect :: struct { + x, y, w, h: float; +} + +make_rect :: inline (x: float, y: float, w: float, h: float) -> Rect { + r: Rect = ---; + r.x, r.y, r.w, r.h = x, y, w, h; + return r; +} + +contains :: inline (r: Rect, x: float, y: float) -> bool { + return x >= r.x && y >= r.y && x < r.x + r.w && y < r.y + r.h; +} + +intersect :: (a: Rect, b: Rect) -> Rect { + x0 := max(a.x, b.x); + y0 := max(a.y, b.y); + x1 := min(a.x + a.w, b.x + b.w); + y1 := min(a.y + a.h, b.y + b.h); + return make_rect(x0, y0, max(0.0, x1 - x0), max(0.0, y1 - y0)); +} + +inset :: (r: Rect, amount: float) -> Rect { + return make_rect(r.x + amount, r.y + amount, max(0.0, r.w - 2*amount), max(0.0, r.h - 2*amount)); +} + +inset :: (r: Rect, ax: float, ay: float) -> Rect { + return make_rect(r.x + ax, r.y + ay, max(0.0, r.w - 2*ax), max(0.0, r.h - 2*ay)); +} + +cut_left :: (r: *Rect, w: float) -> Rect { + w = min(w, r.w); + result := make_rect(r.x, r.y, w, r.h); + r.x += w; + r.w -= w; + return result; +} + +cut_right :: (r: *Rect, w: float) -> Rect { + w = min(w, r.w); + result := make_rect(r.x + r.w - w, r.y, w, r.h); + r.w -= w; + return result; +} + +cut_top :: (r: *Rect, h: float) -> Rect { + h = min(h, r.h); + result := make_rect(r.x, r.y, r.w, h); + r.y += h; + r.h -= h; + return result; +} + +cut_bottom :: (r: *Rect, h: float) -> Rect { + h = min(h, r.h); + result := make_rect(r.x, r.y + r.h - h, r.w, h); + r.h -= h; + return result; +} + +// +// Colors are packed as 0xAABBGGRR so the bytes in memory are R, G, B, A (what the vertex format expects). +// + +hex :: (value: u32, alpha: float = 1) -> u32 { + r := (value >> 16) & 0xFF; + g := (value >> 8) & 0xFF; + b := value & 0xFF; + a := cast(u32) (clamp(alpha, 0, 1) * 255 + 0.5); + return r | (g << 8) | (b << 16) | (a << 24); +} + +with_alpha :: (color: u32, alpha: float) -> u32 { + a := cast(u32) (clamp(alpha, 0, 1) * cast(float) (color >> 24) + 0.5); + return (color & 0x00FFFFFF) | (a << 24); +} + +// Mixes two packed colors; t = 0 gives a, t = 1 gives b. +mix_color :: (a: u32, b: u32, t: float) -> u32 { + result: u32; + for 0..3 { + shift := cast(u32) (it * 8); + ca := cast(float) ((a >> shift) & 0xFF); + cb := cast(float) ((b >> shift) & 0xFF); + c := cast(u32) (ca + (cb - ca) * t + 0.5); + result |= (c & 0xFF) << shift; + } + return result; +} + +// +// Strings. All strings stored in the project are heap allocated and owned by their container. +// + +set_string :: (dest: *string, value: string) { + if dest.data == value.data && dest.count == value.count return; + old := dest.*; + dest.* = copy_string(value); + if old.count free(old); +} + +string_insert :: (s: *string, at: int, text: string) { + if !text.count return; + result := alloc_string(s.count + text.count); + memcpy(result.data, s.data, at); + memcpy(result.data + at, text.data, text.count); + memcpy(result.data + at + text.count, s.data + at, s.count - at); + if s.count free(s.*); + s.* = result; +} + +string_remove :: (s: *string, at: int, count: int) { + if count <= 0 return; + result := alloc_string(s.count - count); + memcpy(result.data, s.data, at); + memcpy(result.data + at, s.data + at + count, s.count - at - count); + free(s.*); + s.* = result; +} + +substring :: inline (s: string, start: int, end: int) -> string { + result: string; + result.data = s.data + start; + result.count = end - start; + return result; +} + +// Decodes one UTF-8 code point at byte offset i. Returns the code point and its length in bytes. +decode_utf8 :: (s: string, i: int) -> u32, int { + c := cast(u32) s[i]; + if c < 0x80 return c, 1; + n := 0; + if (c & 0xE0) == 0xC0 { n = 2; c &= 0x1F; } + else if (c & 0xF0) == 0xE0 { n = 3; c &= 0x0F; } + else if (c & 0xF8) == 0xF0 { n = 4; c &= 0x07; } + else return 0xFFFD, 1; + if i + n > s.count return 0xFFFD, 1; + for 1..n-1 c = (c << 6) | (cast(u32) s[i + it] & 0x3F); + return c, n; +} + +encode_utf8 :: (c: u32, buffer: *[4] u8) -> string { + result: string; + result.data = buffer.data; + if c < 0x80 { + buffer.*[0] = xx c; + result.count = 1; + } else if c < 0x800 { + buffer.*[0] = xx (0xC0 | (c >> 6)); + buffer.*[1] = xx (0x80 | (c & 0x3F)); + result.count = 2; + } else if c < 0x10000 { + buffer.*[0] = xx (0xE0 | (c >> 12)); + buffer.*[1] = xx (0x80 | ((c >> 6) & 0x3F)); + buffer.*[2] = xx (0x80 | (c & 0x3F)); + result.count = 3; + } else { + buffer.*[0] = xx (0xF0 | (c >> 18)); + buffer.*[1] = xx (0x80 | ((c >> 12) & 0x3F)); + buffer.*[2] = xx (0x80 | ((c >> 6) & 0x3F)); + buffer.*[3] = xx (0x80 | (c & 0x3F)); + result.count = 4; + } + return result; +} + +prev_char_start :: (s: string, i: int) -> int { + if i <= 0 return 0; + i -= 1; + while i > 0 && (s[i] & 0xC0) == 0x80 i -= 1; + return i; +} + +next_char_start :: (s: string, i: int) -> int { + if i >= s.count return s.count; + i += 1; + while i < s.count && (s[i] & 0xC0) == 0x80 i += 1; + return i; +} + +floor_f :: inline (x: float) -> float { + i := cast(float) cast(s64) x; + if i > x i -= 1; + return i; +} + +round_f :: inline (x: float) -> float { + return floor_f(x + 0.5); +} diff --git a/src/font.jai b/src/font.jai new file mode 100644 index 0000000..31b0d5b --- /dev/null +++ b/src/font.jai @@ -0,0 +1,226 @@ +// Loads the font atlas baked by build.jai and draws/measures text with it. + +Font_Size :: enum s32 { + SMALL :: 0; // 12 + NORMAL :: 1; // 14 + MEDIUM :: 2; // 16 + LARGE :: 3; // 20 + TITLE :: 4; // 26 +} +FONT_SIZE_COUNT :: 5; // Must match FONT_SIZES in build.jai. + +Glyph :: struct { + x, y, w, h: s16; + bearing_x, bearing_y: s16; + advance: float; +} + +Baked_Font :: struct { + size, scale: float; + ascent, descent, line_height: float; // Atlas pixels. + first_char: s32; + glyphs: [] Glyph; +} + +fonts: [..] Baked_Font; + +load_fonts :: (data: string) -> bool { + cursor := 0; + read :: ($T: Type) -> T #expand { + result: T; + if `cursor + size_of(T) <= `data.count memcpy(*result, `data.data + `cursor, size_of(T)); + `cursor += size_of(T); + return result; + } + + magic := read(u32); + if magic != 0x544E4654 { + log_error("fonts.bin: bad magic"); + return false; + } + atlas_w := read(s32); + atlas_h := read(s32); + count := read(s32); + for 0..count-1 { + f: Baked_Font; + f.size = read(float); + f.scale = read(float); + f.ascent = read(float); + f.descent = read(float); + f.line_height = read(float); + f.first_char = read(s32); + glyph_count := read(s32); + f.glyphs = NewArray(glyph_count, Glyph); + for * g: f.glyphs { + g.x = read(s16); g.y = read(s16); g.w = read(s16); g.h = read(s16); + g.bearing_x = read(s16); g.bearing_y = read(s16); + g.advance = read(float); + } + array_add(*fonts, f); + } + if cursor + atlas_w * atlas_h > data.count { + log_error("fonts.bin: truncated"); + return false; + } + upload_atlas(data.data + cursor, atlas_w, atlas_h); + return fonts.count >= FONT_SIZE_COUNT; +} + +get_font :: (size: Font_Size) -> *Baked_Font { + set := ifx renderer.dpr > 1.25 && fonts.count >= FONT_SIZE_COUNT * 2 then 1 else 0; + return *fonts[set * FONT_SIZE_COUNT + cast(s32) size]; +} + +get_glyph :: (f: *Baked_Font, c: u32) -> *Glyph { + index := cast(s64) c - f.first_char; + if index < 0 || index >= f.glyphs.count index = #char "?" - f.first_char; + return *f.glyphs[index]; +} + +line_height :: (size: Font_Size) -> float { + f := get_font(size); + return ceil(f.line_height / f.scale); +} + +// Draws a single line of text with its top at y. Returns the x position after the last character. +draw_text :: (text: string, x: float, y: float, size: Font_Size, color: u32) -> float { + f := get_font(size); + dpr := renderer.dpr; + s := dpr / f.scale; // Atlas pixels -> physical pixels. + inv_w := 1.0 / renderer.atlas_w; + inv_h := 1.0 / renderer.atlas_h; + baseline := round_f((y + f.ascent / f.scale) * dpr); + pen := x; + i := 0; + while i < text.count { + c, n := decode_utf8(text, i); + i += n; + g := get_glyph(f, c); + if g.w > 0 { + px := round_f(pen * dpr) + g.bearing_x * s; + py := baseline - g.bearing_y * s; + x0 := px / dpr; + y0 := py / dpr; + x1 := (px + g.w * s) / dpr; + y1 := (py + g.h * s) / dpr; + draw_glyph_quad(x0, y0, x1, y1, + g.x * inv_w, g.y * inv_h, (g.x + g.w) * inv_w, (g.y + g.h) * inv_h, color); + } + pen += g.advance / f.scale; + } + return pen; +} + +measure_text :: (text: string, size: Font_Size) -> float { + f := get_font(size); + width := 0.0; + i := 0; + while i < text.count { + c, n := decode_utf8(text, i); + i += n; + width += get_glyph(f, c).advance; + } + return width / f.scale; +} + +// Byte index in text closest to the given x offset from the start of the text. +hit_test_text :: (text: string, x: float, size: Font_Size) -> int { + f := get_font(size); + pen := 0.0; + i := 0; + while i < text.count { + c, n := decode_utf8(text, i); + advance := get_glyph(f, c).advance / f.scale; + if x < pen + advance * 0.5 return i; + pen += advance; + i += n; + } + return text.count; +} + +draw_text_centered :: (text: string, r: Rect, size: Font_Size, color: u32) { + w := measure_text(text, size); + h := line_height(size); + draw_text(text, round_f(r.x + (r.w - w) * 0.5), round_f(r.y + (r.h - h) * 0.5), size, color); +} + +// Draws text vertically centered in r, left aligned, clipped with an ellipsis if it doesn't fit. +draw_text_in_rect :: (text: string, r: Rect, size: Font_Size, color: u32) { + h := line_height(size); + y := round_f(r.y + (r.h - h) * 0.5); + draw_text(fit_text(text, r.w, size), r.x, y, size, color); +} + +fit_text :: (text: string, width: float, size: Font_Size) -> string { + if measure_text(text, size) <= width return text; + ellipsis :: "..."; + available := width - measure_text(ellipsis, size); + if available <= 0 return ""; + end := hit_test_text(text, available, size); + while end > 0 && measure_text(substring(text, 0, end), size) > available end = prev_char_start(text, end); + return tprint("%1%2", substring(text, 0, end), ellipsis); +} + +// +// Word wrapping. Lines are byte ranges into the original text (end excludes any '\n'). +// + +Text_Line :: struct { + start, end: int; +} + +wrap_text :: (text: string, width: float, size: Font_Size, lines: *[..] Text_Line) { + lines.count = 0; + f := get_font(size); + max_w := width * f.scale; + + line_start := 0; + i := 0; + pen := 0.0; + last_break := -1; // Byte index just after the last space on this line. + while i < text.count { + c, n := decode_utf8(text, i); + if c == #char "\n" { + array_add(lines, .{line_start, i}); + i += n; + line_start = i; + pen = 0; + last_break = -1; + continue; + } + advance := get_glyph(f, c).advance; + if pen + advance > max_w && i > line_start && c != #char " " { + if last_break > line_start { + array_add(lines, .{line_start, last_break}); + line_start = last_break; + pen = measure_text(substring(text, line_start, i), size) * f.scale; + } else { + array_add(lines, .{line_start, i}); + line_start = i; + pen = 0; + } + last_break = -1; + } + pen += advance; + i += n; + if c == #char " " last_break = i; + } + array_add(lines, .{line_start, text.count}); +} + +// Draws wrapped text and returns its height. +draw_wrapped_text :: (text: string, x: float, y: float, width: float, size: Font_Size, color: u32) -> float { + lines: [..] Text_Line; + lines.allocator = temp; + wrap_text(text, width, size, *lines); + lh := line_height(size); + for lines draw_text(substring(text, it.start, it.end), x, y + it_index * lh, size, color); + return lines.count * lh; +} + +measure_wrapped_height :: (text: string, width: float, size: Font_Size) -> float { + lines: [..] Text_Line; + lines.allocator = temp; + wrap_text(text, width, size, *lines); + return lines.count * line_height(size); +} diff --git a/src/main.jai b/src/main.jai new file mode 100644 index 0000000..798ed7a --- /dev/null +++ b/src/main.jai @@ -0,0 +1,25 @@ +// TaskForge: task management for game projects. One codebase, native (OpenGL) and web (WebGL2 via WASM). + +#if OS == .WASM { + PLATFORM_WEB :: true; + #load "web_gl.jai"; + #load "platform_web.jai"; +} else { + PLATFORM_WEB :: false; + #load "platform_native.jai"; +} + +#load "common.jai"; +#load "render.jai"; +#load "font.jai"; +#load "ui.jai"; +#load "model.jai"; +#load "storage.jai"; +#load "app.jai"; +#load "sync.jai"; +#load "account.jai"; + +#import "Basic"; +#import "Math"; +#import "String"; +#import "Hash_Table"; diff --git a/src/model.jai b/src/model.jai new file mode 100644 index 0000000..d00f724 --- /dev/null +++ b/src/model.jai @@ -0,0 +1,618 @@ +// Project data model: custom fields, tasks/epics, views and epic rules. + +Field_Kind :: enum u8 { + TEXT :: 0; + NUMBER :: 1; + CHECKBOX :: 2; + SELECT :: 3; // One option. + TAGS :: 4; // Any number of options. + USER :: 5; // Any number of users (from the server). +} + +FIELD_KIND_NAMES :: string.["Text", "Number", "Checkbox", "Select", "Tags", "User"]; + +Field_Scope :: enum u8 { + ALL :: 0; + TASKS :: 1; + EPICS :: 2; +} + +FIELD_SCOPE_NAMES :: string.["Tasks & epics", "Tasks only", "Epics only"]; + +Option :: struct { + id: u32; + name: string; + color: u32; // 0xRRGGBB +} + +Field :: struct { + id: u32; + name: string; + kind: Field_Kind; + scope: Field_Scope; + order: float64; + options: [..] Option; +} + +Field_Value :: struct { + field: u32; + text: string; // TEXT, and the typed text of NUMBER. + num: float64; // NUMBER + sel: u32; // SELECT option id; CHECKBOX 0/1. + tags: [..] u32; // TAGS option ids, or USER user ids. +} + +Task :: struct { + id: u32; + title: string; + notes: string; + is_epic: bool; + epic: u32; // Parent epic id, 0 if none. + order: float64; // Manual sort order. + values: [..] Field_Value; +} + +View_Kind :: enum u8 { + KANBAN :: 0; + CHECKLIST :: 1; +} + +View_Items :: enum u8 { + TASKS :: 0; + EPICS :: 1; + ALL :: 2; +} + +VIEW_ITEMS_NAMES :: string.["Tasks", "Epics", "Tasks & epics"]; + +View :: struct { + id: u32; + name: string; + kind: View_Kind; + items: View_Items; + order: float64; + + group_field: u32; // Kanban: columns come from this SELECT/CHECKBOX field. + check_field: u32; // Checklist: the checkbox reflects this field... + check_value: u32; // ...being this option (SELECT) or checked (CHECKBOX). + group_by_epic: bool; // Checklist: section per epic. + + filter_epic: u32; // Only tasks in this epic (0 = any). + filter_field: u32; // Only tasks where this field... + filter_option: u32; // ...has this option (0 = no filter; ME_OPTION = the signed-in user). + + card_fields: [..] u32; // Fields shown on cards / rows. +} + +Quantifier :: enum u8 { + ALL :: 0; + ANY :: 1; + NONE :: 2; +} + +QUANTIFIER_NAMES :: string.["all", "any", "none"]; + +Compare :: enum u8 { + IS :: 0; + IS_NOT :: 1; +} + +COMPARE_NAMES :: string.["is", "is not"]; + +// "For , set to when of its tasks [where has ] +// have ." +Epic_Rule :: struct { + id: u32; + order: float64; // Rules are evaluated in this order. + epic: u32; // 0 = every epic. + target_field: u32; + target_value: u32; + quant: Quantifier; + filter_field: u32; // 0 = all tasks of the epic. + filter_option: u32; + cond_field: u32; + cond_op: Compare; + cond_value: u32; +} + +Project :: struct { + name: string; + next_id: u32 = 1; + id_limit: u32; // When synced, ids come in blocks from the server: [next_id, id_limit). 0 = no limit. + fields: [..] *Field; + tasks: [..] *Task; + views: [..] *View; + rules: [..] *Epic_Rule; + + // Server mode only. + users: [..] User_Info; + me: u32; + me_admin: bool; +} + +User_Info :: struct { + id: u32; + username: string; + name: string; + is_admin: bool; + active: bool; +} + +ME_OPTION :: 0xFFFF_FFFF; // Filter value meaning "the signed-in user". + +OPTION_PALETTE :: u32.[0x8B949E, 0x58A6FF, 0xD29922, 0x3FB950, 0xF778BA, 0xBC8CFF, 0xF85149, 0x39C5CF, 0xDB6D28, 0xA371F7]; + +new_id :: (p: *Project) -> u32 { + id := p.next_id; + p.next_id += 1; + return id; +} + +find_field :: (p: *Project, id: u32) -> *Field { + if !id return null; + for p.fields if it.id == id return it; + return null; +} + +find_task :: (p: *Project, id: u32) -> *Task { + if !id return null; + for p.tasks if it.id == id return it; + return null; +} + +find_view :: (p: *Project, id: u32) -> *View { + if !id return null; + for p.views if it.id == id return it; + return null; +} + +find_user :: (p: *Project, id: u32) -> *User_Info { + for * p.users if it.id == id return it; + return null; +} + +user_color :: (id: u32) -> u32 { + return OPTION_PALETTE[1 + (id * 7) % (OPTION_PALETTE.count - 1)]; +} + +user_initials :: (name: string) -> string { + b: String_Builder; + b.allocator = temp; + take_next := true; + count := 0; + for i: 0..name.count-1 { + c := name[i]; + if c == #char " " { take_next = true; continue; } + if take_next && count < 2 && c < 0x80 { + append(*b, ifx c >= #char "a" && c <= #char "z" then cast(u8) (c - 32) else c); + count += 1; + } + take_next = false; + } + return builder_to_string(*b,, allocator = temp); +} + +find_option :: (f: *Field, id: u32) -> *Option { + if !f return null; + for * f.options if it.id == id return it; + return null; +} + +field_has_options :: (f: *Field) -> bool { + return f && (f.kind == .SELECT || f.kind == .TAGS); +} + +next_order :: (orders: [] $T) -> float64 { + result: float64 = 0; + for orders result = max(result, it.order); + return result + 1; +} + +sort_by_order_field :: (items: [] $T) { + for i: 1..items.count-1 { + t := items[i]; + j := i - 1; + while j >= 0 && items[j].order > t.order { + items[j + 1] = items[j]; + j -= 1; + } + items[j + 1] = t; + } +} + +// Keeps fields, views and rules in their shared order (after loading or receiving remote changes). +sort_project :: (p: *Project) { + sort_by_order_field(p.fields); + sort_by_order_field(p.views); + sort_by_order_field(p.rules); +} + +swap_order :: (a: *$T, b: *T) { + tmp := a.order; + a.order = b.order; + b.order = tmp; + if a.order == b.order b.order += 0.5; +} + +// Fields that hold a single discrete value: usable for kanban columns, checklist state and rule targets. +field_is_discrete :: (f: *Field) -> bool { + return f && (f.kind == .SELECT || f.kind == .CHECKBOX); +} + +field_applies_to :: (f: *Field, task: *Task) -> bool { + if f.scope == .ALL return true; + if f.scope == .EPICS return task.is_epic; + return !task.is_epic; +} + +add_field :: (p: *Project, name: string, kind: Field_Kind, scope := Field_Scope.ALL) -> *Field { + f := New(Field); + f.id = new_id(p); + f.name = copy_string(name); + f.kind = kind; + f.scope = scope; + f.order = next_order(p.fields); + array_add(*p.fields, f); + return f; +} + +add_option :: (p: *Project, f: *Field, name: string, color: u32 = 0) -> u32 { + o: Option; + o.id = new_id(p); + o.name = copy_string(name); + o.color = ifx color then color else OPTION_PALETTE[f.options.count % OPTION_PALETTE.count]; + array_add(*f.options, o); + return o.id; +} + +option_by_name :: (f: *Field, name: string) -> u32 { + for f.options if it.name == name return it.id; + return 0; +} + +// +// Values +// + +get_value :: (task: *Task, field: u32) -> *Field_Value { + for * task.values if it.field == field return it; + return null; +} + +value_for_edit :: (task: *Task, field: u32) -> *Field_Value { + v := get_value(task, field); + if v return v; + nv: Field_Value; + nv.field = field; + array_add(*task.values, nv); + return *task.values[task.values.count - 1]; +} + +free_value :: (v: *Field_Value) { + if v.text.count free(v.text); + array_free(v.tags); +} + +clear_value :: (task: *Task, field: u32) { + for * task.values { + if it.field == field { + free_value(it); + remove it; + } + } +} + +get_sel :: (task: *Task, field: u32) -> u32 { + v := get_value(task, field); + return ifx v then v.sel else 0; +} + +set_sel :: (task: *Task, field: u32, value: u32) { + value_for_edit(task, field).sel = value; +} + +has_tag :: (task: *Task, field: u32, option: u32) -> bool { + v := get_value(task, field); + if !v return false; + for v.tags if it == option return true; + return false; +} + +toggle_tag :: (task: *Task, field: u32, option: u32) { + v := value_for_edit(task, field); + for v.tags { + if it == option { + array_ordered_remove_by_index(*v.tags, it_index); + return; + } + } + array_add(*v.tags, option); +} + +// True if the task's value for f "contains" option: SELECT equals, TAGS includes, CHECKBOX equals 0/1. +task_has_option :: (task: *Task, f: *Field, option: u32) -> bool { + if !f return false; + if f.kind == { + case .SELECT; return get_sel(task, f.id) == option; + case .CHECKBOX; return (get_sel(task, f.id) != 0) == (option != 0); + case .TAGS; return has_tag(task, f.id, option); + case .USER; return has_tag(task, f.id, option); + } + return false; +} + +// Name + color of an option value, for display. CHECKBOX fields have the pseudo options 0 = No, 1 = Yes. +option_label :: (f: *Field, option: u32) -> string, u32 { + if !f return "?", 0x8B949E; + if f.kind == .CHECKBOX return ifx option then "Yes" else "No", ifx option then cast(u32) 0x3FB950 else 0x8B949E; + o := find_option(f, option); + if o return o.name, o.color; + return "(none)", 0x6B7480; +} + +// +// Tasks +// + +create_task :: (p: *Project, title: string, is_epic := false) -> *Task { + t := New(Task); + t.id = new_id(p); + t.title = copy_string(title); + t.is_epic = is_epic; + max_order: float64 = 0; + for p.tasks max_order = max(max_order, it.order); + t.order = max_order + 1; + array_add(*p.tasks, t); + return t; +} + +delete_task :: (p: *Project, id: u32) { + for p.tasks { + if it.epic == id it.epic = 0; + } + remove_rules(p, (r: *Epic_Rule, id: u32) -> bool { return r.epic == id; }, id); + for p.views if it.filter_epic == id it.filter_epic = 0; + for p.tasks { + if it.id == id { + for * v: it.values free_value(v); + array_free(it.values); + free(it.title); + free(it.notes); + free(it); + array_ordered_remove_by_index(*p.tasks, it_index); + break; + } + } +} + +epic_children_count :: (p: *Project, epic: u32) -> int { + n := 0; + for p.tasks if it.epic == epic && !it.is_epic n += 1; + return n; +} + +// +// Fields +// + +delete_field :: (p: *Project, id: u32) { + for t: p.tasks clear_value(t, id); + for v: p.views { + if v.group_field == id v.group_field = 0; + if v.check_field == id v.check_field = 0; + if v.filter_field == id { v.filter_field = 0; v.filter_option = 0; } + found, index := array_find(v.card_fields, id); + if found array_ordered_remove_by_index(*v.card_fields, index); + } + remove_rules(p, (r: *Epic_Rule, id: u32) -> bool { return r.target_field == id || r.cond_field == id; }, id); + for p.rules if it.filter_field == id { it.filter_field = 0; it.filter_option = 0; } + for p.fields { + if it.id == id { + for o: it.options free(o.name); + array_free(it.options); + free(it.name); + free(it); + array_ordered_remove_by_index(*p.fields, it_index); + break; + } + } +} + +delete_option :: (p: *Project, f: *Field, option: u32) { + for t: p.tasks { + v := get_value(t, f.id); + if !v continue; + if v.sel == option v.sel = 0; + for v.tags if it == option remove it; + } + for v: p.views { + if v.filter_field == f.id && v.filter_option == option { v.filter_field = 0; v.filter_option = 0; } + if v.check_field == f.id && v.check_value == option v.check_value = 0; + } + for p.rules { + if it.target_field == f.id && it.target_value == option it.target_value = 0; + if it.cond_field == f.id && it.cond_value == option it.cond_value = 0; + if it.filter_field == f.id && it.filter_option == option { it.filter_field = 0; it.filter_option = 0; } + } + for f.options { + if it.id == option { + free(it.name); + array_ordered_remove_by_index(*f.options, it_index); + break; + } + } +} + +// Changing kind keeps what can be kept: SELECT <-> TAGS convert, anything else drops the old values. +change_field_kind :: (p: *Project, f: *Field, kind: Field_Kind) { + if f.kind == kind return; + for t: p.tasks { + v := get_value(t, f.id); + if !v continue; + if f.kind == .SELECT && kind == .TAGS { + v.tags.count = 0; + if v.sel array_add(*v.tags, v.sel); + v.sel = 0; + } else if f.kind == .TAGS && kind == .SELECT { + v.sel = ifx v.tags.count then v.tags[0] else 0; + v.tags.count = 0; + } else if f.kind == .TEXT && kind == .NUMBER { + v.num = string_to_float(v.text); + } else if !(f.kind == .NUMBER && kind == .TEXT) { + clear_value(t, f.id); + } + } + f.kind = kind; + for v: p.views { + if v.group_field == f.id && !field_is_discrete(f) v.group_field = 0; + if v.check_field == f.id && !field_is_discrete(f) v.check_field = 0; + if v.filter_field == f.id && !(field_has_options(f) || f.kind == .CHECKBOX) { v.filter_field = 0; v.filter_option = 0; } + } + if !field_is_discrete(f) { + remove_rules(p, (r: *Epic_Rule, id: u32) -> bool { return r.target_field == id || r.cond_field == id; }, f.id); + } +} + +// +// Views +// + +task_in_view :: (p: *Project, view: *View, task: *Task) -> bool { + if view.items == .TASKS && task.is_epic return false; + if view.items == .EPICS && !task.is_epic return false; + if view.filter_epic && task.epic != view.filter_epic && task.id != view.filter_epic return false; + if view.filter_field && view.filter_option { + f := find_field(p, view.filter_field); + option := ifx view.filter_option == ME_OPTION then p.me else view.filter_option; + if f && !task_has_option(task, f, option) return false; + } + return true; +} + +// Tasks shown by a view, sorted by manual order. Temp allocated. +view_tasks :: (p: *Project, view: *View) -> [..] *Task { + result: [..] *Task; + result.allocator = temp; + for p.tasks if task_in_view(p, view, it) array_add(*result, it); + sort_by_order(result); + return result; +} + +sort_by_order :: (tasks: [] *Task) { + for i: 1..tasks.count-1 { + t := tasks[i]; + j := i - 1; + while j >= 0 && tasks[j].order > t.order { + tasks[j + 1] = tasks[j]; + j -= 1; + } + tasks[j + 1] = t; + } +} + +is_checked :: (p: *Project, view: *View, task: *Task) -> bool { + f := find_field(p, view.check_field); + if !f return false; + if f.kind == .CHECKBOX return get_sel(task, f.id) != 0; + return view.check_value && get_sel(task, f.id) == view.check_value; +} + +set_checked :: (p: *Project, view: *View, task: *Task, checked: bool) { + f := find_field(p, view.check_field); + if !f return; + if f.kind == .CHECKBOX { + set_sel(task, f.id, xx checked); + } else if f.kind == .SELECT { + if checked { + set_sel(task, f.id, view.check_value); + } else { + // Unchecking goes back to the first option that isn't the "done" one. + back: u32; + for f.options if it.id != view.check_value { back = it.id; break; } + set_sel(task, f.id, back); + } + } +} + +// +// Epic rules +// + +Rule_Result :: struct { + matched: bool; + count, total: int; +} + +evaluate_rule :: (p: *Project, rule: *Epic_Rule, epic: *Task) -> Rule_Result { + result: Rule_Result; + cond := find_field(p, rule.cond_field); + filter := find_field(p, rule.filter_field); + if !cond return result; + + for t: p.tasks { + if t.is_epic || t.epic != epic.id continue; + if filter && rule.filter_option && !task_has_option(t, filter, rule.filter_option) continue; + result.total += 1; + has := task_has_option(t, cond, rule.cond_value); + if rule.cond_op == .IS_NOT has = !has; + if has result.count += 1; + } + + if rule.quant == { + case .ALL; result.matched = result.total > 0 && result.count == result.total; // An epic with no matching tasks is not "all done". + case .ANY; result.matched = result.count > 0; + case .NONE; result.matched = result.count == 0; + } + return result; +} + +rule_applies_to :: (rule: *Epic_Rule, epic: *Task) -> bool { + return epic.is_epic && (rule.epic == 0 || rule.epic == epic.id); +} + +// For every epic and every field targeted by rules, the first matching rule (in list order) sets the value. +// If no rule matches, the field keeps whatever value it has (so it can still be set by hand). +// Returns true if anything changed. +apply_epic_rules :: (p: *Project) -> bool { + changed := false; + decided: [..] u32; + decided.allocator = temp; + for epic: p.tasks { + if !epic.is_epic continue; + decided.count = 0; + for rule: p.rules { + if !rule_applies_to(rule, epic) continue; + target := find_field(p, rule.target_field); + if !field_is_discrete(target) || !field_applies_to(target, epic) continue; + if array_find(decided, target.id) continue; + if !evaluate_rule(p, rule, epic).matched continue; + array_add(*decided, target.id); + value := rule.target_value; + if target.kind == .CHECKBOX value = xx (value != 0); + if get_sel(epic, target.id) != value { + set_sel(epic, target.id, value); + changed = true; + } + } + } + return changed; +} + +// Removes rules matching the predicate while keeping the order of the rest (order is rule priority). +remove_rules :: (p: *Project, predicate: (*Epic_Rule, u32) -> bool, arg: u32) { + i := 0; + while i < p.rules.count { + r := p.rules[i]; + if predicate(r, arg) { + free(r); + array_ordered_remove_by_index(*p.rules, i); + } else { + i += 1; + } + } +} + +field_is_rule_driven :: (p: *Project, epic: *Task, field: u32) -> bool { + for p.rules if it.target_field == field && rule_applies_to(it, epic) return true; + return false; +} diff --git a/src/platform_native.jai b/src/platform_native.jai new file mode 100644 index 0000000..fe3e6f5 --- /dev/null +++ b/src/platform_native.jai @@ -0,0 +1,417 @@ +// Native platform layer: Win32 window + OpenGL 3.3 core context, via Jai's Window_Creation / Input / GL modules. +// The project is a plain text file (default: project.taskforge in the working directory, or the path given +// on the command line), so it can live in your game's repository. + +#import "Window_Creation"; +#import "Input"; +#import "GL"; +#import "File"; +#import "File_Utilities"; +#import "Clipboard"; +#import "System"; +#import "Windows"; +#import "Thread"; +#import "Curl"; + +tf_user32 :: #system_library "user32"; +SetProcessDpiAwarenessContext_ :: (value: *void) -> s32 #foreign tf_user32 "SetProcessDpiAwarenessContext"; +GetDpiForWindow_ :: (hwnd: HWND) -> u32 #foreign tf_user32 "GetDpiForWindow"; +MsgWaitForMultipleObjects_ :: (count: u32, handles: *HANDLE, wait_all: s32, milliseconds: u32, wake_mask: u32) -> u32 #foreign tf_user32 "MsgWaitForMultipleObjects"; +QS_ALLINPUT_ :: 0x04FF; + +project_path: string; +server_url: string; // Set with --server; empty = local project file. +native_window: HWND; + +main :: () { + args := get_command_line_arguments(); + project_path = "project.taskforge"; + i := 1; + while i < args.count { + if args[i] == "--sim" && i + 1 < args.count { + sim_script = args[i + 1]; + i += 2; + continue; + } + if args[i] == "--server" && i + 1 < args.count { + server_url = copy_string(args[i + 1]); + while server_url.count && server_url[server_url.count - 1] == #char "/" server_url.count -= 1; + i += 2; + continue; + } + project_path = copy_string(args[i]); + i += 1; + } + + SetProcessDpiAwarenessContext_(cast(*void) -4); // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 + native_window = create_window(1440, 900, "TaskForge", background_color_rgb = .[0.067, 0.075, 0.094]); + _, hdc := wgl_create_context(native_window, null, 3, 3); + gl_load(*gl); + #if OS == .WINDOWS { + if wglSwapIntervalEXT wglSwapIntervalEXT(1); + } + + exe_dir := path_strip_filename(get_path_of_running_executable()); + font_data, ok := read_entire_file(tprint("%fonts.bin", exe_dir)); + if !ok { + log_error("Missing fonts.bin next to the executable. Build with: jai build.jai"); + return; + } + if server_url.count curl_global_init(CURL_GLOBAL_DEFAULT); + if !app_init(font_data, server_url.count > 0) { + log_error("Failed to initialize."); + return; + } + + input: Frame_Input; + idle_frames := 0; + last_mouse_x, last_mouse_y := -1.0, -1.0; + running := true; + while running { + update_window_events(); + reset_per_frame_input(*input); + + for event: events_this_frame { + if event.type == { + case .QUIT; + running = false; + case .KEYBOARD; + button := -1; + if event.key_code == { + case .MOUSE_BUTTON_LEFT; button = MOUSE_LEFT; + case .MOUSE_BUTTON_RIGHT; button = MOUSE_RIGHT; + case .MOUSE_BUTTON_MIDDLE; button = MOUSE_MIDDLE; + } + if button >= 0 { + if event.key_pressed { + input.mouse_down[button] = true; + input.mouse_pressed[button] = true; + } else { + input.mouse_down[button] = false; + input.mouse_released[button] = true; + } + } else if event.key_pressed { + key: Key_Press; + key.key = cast(u32) event.key_code; + if event.shift_pressed key.mods |= MOD_SHIFT; + if event.ctrl_pressed key.mods |= MOD_CTRL; + if event.alt_pressed key.mods |= MOD_ALT; + array_add(*input.keys, key); + } + case .TEXT_INPUT; + if event.utf32 >= 32 && event.utf32 != 127 array_add(*input.chars, event.utf32); + case .MOUSE_WHEEL; + if event.typical_wheel_delta { + lines := -cast(float) event.wheel_delta / cast(float) event.typical_wheel_delta; + input.wheel_y += lines; + } + } + } + + input.mods = 0; + if input_button_states[Key_Code.SHIFT] & .DOWN input.mods |= MOD_SHIFT; + if input_button_states[Key_Code.CTRL] & .DOWN input.mods |= MOD_CTRL; + if input_button_states[Key_Code.ALT] & .DOWN input.mods |= MOD_ALT; + + rect: RECT; + GetClientRect(native_window, *rect); + pw := rect.right - rect.left; + ph := rect.bottom - rect.top; + dpi := GetDpiForWindow_(native_window); + input.dpr = ifx dpi then dpi / 96.0 else 1.0; + + if pw <= 0 || ph <= 0 { + // Minimized. + MsgWaitForMultipleObjects_(0, null, 0, 250, QS_ALLINPUT_); + continue; + } + input.width = pw / input.dpr; + input.height = ph / input.dpr; + input.time = seconds_since_init(); + + if sim_script.count { + if !simulate_frame(*input, pw, ph) running = false; + } else { + mx, my := get_mouse_pointer_position(native_window, false); + input.mouse_x = mx / input.dpr; + input.mouse_y = my / input.dpr; + // If a button was released outside the window we never got the event. + if input.mouse_down[MOUSE_LEFT] && !(input_button_states[Key_Code.MOUSE_BUTTON_LEFT] & .DOWN) { + input.mouse_down[MOUSE_LEFT] = false; + input.mouse_released[MOUSE_LEFT] = true; + } + } + + poll_http_jobs(); + frame_start := seconds_since_init(); + wants_more := app_frame(*input); + if sim_screenshot.count { + save_screenshot(sim_screenshot, pw, ph); + sim_screenshot = ""; + } + SwapBuffers(hdc); + + // Vsync isn't guaranteed (drivers may ignore the swap interval), so cap the frame rate ourselves. + MIN_FRAME_TIME :: 1.0 / 120.0; + elapsed := seconds_since_init() - frame_start; + if elapsed < MIN_FRAME_TIME sleep_milliseconds(cast(s32) ((MIN_FRAME_TIME - elapsed) * 1000) + 1); + if sim_script.count continue; + + moved := input.mouse_x != last_mouse_x || input.mouse_y != last_mouse_y; + last_mouse_x, last_mouse_y = input.mouse_x, input.mouse_y; + if events_this_frame.count || moved || wants_more idle_frames = 0; + else idle_frames += 1; + + // A task app should not burn a CPU core: sleep until input arrives (or the caret needs to blink). + if idle_frames > 2 MsgWaitForMultipleObjects_(0, null, 0, xx ifx http_jobs.count then 15 else 500, QS_ALLINPUT_); + } + + app_shutdown(); +} + +platform_load_project :: () -> string, bool { + if !file_exists(project_path) return "", false; + text, ok := read_entire_file(project_path); + return text, ok; +} + +platform_save_project :: (data: string) -> bool { + // Write to a temp file first so a crash mid-write can't destroy the project. + temp_path := tprint("%.tmp", project_path); + if !write_entire_file(temp_path, data) return false; + return file_move(temp_path, project_path); +} + +platform_storage_description :: () -> string { + return tprint("Saved to %", path_filename(project_path)); +} + +platform_server_label :: () -> string { + return server_url; +} + +// +// HTTP (server mode): each request runs libcurl on its own thread; results are picked up by the main loop. +// + +Http_Job :: struct { + tag: Http_Tag; + url: string; + body: string; + token: string; + status: int; + response: String_Builder; + thread: Thread; +} + +http_jobs: [..] *Http_Job; + +platform_http_post :: (tag: Http_Tag, path: string, body: string, token: string) { + job := New(Http_Job); + job.tag = tag; + job.url = sprint("%1%2", server_url, path); + job.body = copy_string(body); + job.token = copy_string(token); + thread_init(*job.thread, http_thread_proc); + job.thread.data = job; + thread_start(*job.thread); + array_add(*http_jobs, job); +} + +http_thread_proc :: (thread: *Thread) -> s64 { + job := cast(*Http_Job) thread.data; + curl := curl_easy_init(); + if !curl return 0; + defer curl_easy_cleanup(curl); + + write :: (data: *u8, size: u64, count: u64, builder: *String_Builder) -> u64 #c_call { + n := size * count; + push_context { append(builder, data, cast(s64) n); } + return n; + } + + headers: *curl_slist; + headers = curl_slist_append(headers, "Content-Type: text/plain; charset=utf-8"); + if job.token.count headers = curl_slist_append(headers, temp_c_string(tprint("Authorization: Bearer %", job.token))); + defer curl_slist_free_all(headers); + + curl_easy_setopt(curl, .URL, temp_c_string(job.url)); + curl_easy_setopt(curl, .POST, cast(s64) 1); + curl_easy_setopt(curl, .POSTFIELDS, job.body.data); + curl_easy_setopt(curl, .POSTFIELDSIZE, cast(s64) job.body.count); + curl_easy_setopt(curl, .HTTPHEADER, headers); + curl_easy_setopt(curl, .WRITEFUNCTION, write); + curl_easy_setopt(curl, .WRITEDATA, *job.response); + curl_easy_setopt(curl, .TIMEOUT, cast(s64) 20); + curl_easy_setopt(curl, .CONNECTTIMEOUT, cast(s64) 10); + + if curl_easy_perform(curl) == .OK { + code: s64; + curl_easy_getinfo(curl, .RESPONSE_CODE, *code); + job.status = code; + } + return 0; +} + +poll_http_jobs :: () { + i := 0; + while i < http_jobs.count { + job := http_jobs[i]; + if !thread_is_done(*job.thread) { + i += 1; + continue; + } + thread_deinit(*job.thread); + array_ordered_remove_by_index(*http_jobs, i); + response := builder_to_string(*job.response); + http_on_response(job.tag, job.status, response); + free(response); + free(job.url); + free(job.body); + free(job.token); + free(job); + } +} + +// The session token is kept per user profile, tied to the server it belongs to. +session_path :: () -> string { + home := get_home_directory(); + dir := tprint("%/AppData/Roaming/TaskForge", home); + make_directory_if_it_does_not_exist(dir); + return tprint("%/session.txt", dir); +} + +platform_load_session :: () -> string { + text, ok := read_entire_file(session_path(), log_errors = false); + if !ok return ""; + lines := split(text, "\n",, temp); + if lines.count < 2 || lines[0] != server_url return ""; + return copy_string(trim(lines[1])); +} + +platform_save_session :: (token: string) { + write_entire_file(session_path(), tprint("%\n%\n", server_url, token)); +} + +platform_clipboard_get :: () -> string { + text := os_clipboard_get_text(); + result := copy_temporary_string(text); + free(text); + return result; +} + +platform_clipboard_set :: (text: string) { + os_clipboard_set_text(text); +} + +platform_set_cursor :: (kind: Cursor_Kind) { + name: *s16; + if kind == { + case .HAND; name = cast(*s16) IDC_HAND; + case .TEXT; name = cast(*s16) IDC_IBEAM; + case .GRAB; name = cast(*s16) IDC_SIZEALL; + case; name = cast(*s16) IDC_ARROW; + } + SetCursor(LoadCursorW(null, name)); +} + +platform_export_project :: (data: string) {} +platform_request_import :: () {} + +// +// Test harness: --sim "" drives the app with scripted input, one command per frame, separated by ';'. +// move X Y | down | up | click X Y | type TEXT | key CODE [MODS] | wheel LINES | wait N | shot FILE.bmp | quit +// Coordinates are logical pixels. +// + +sim_script: string; +sim_screenshot: string; +sim_wait: int; +sim_pending_up: bool; + +simulate_frame :: (input: *Frame_Input, pw: s32, ph: s32) -> bool { + if sim_pending_up { + sim_pending_up = false; + input.mouse_down[MOUSE_LEFT] = false; + input.mouse_released[MOUSE_LEFT] = true; + return true; + } + if sim_wait > 0 { + sim_wait -= 1; + return true; + } + while sim_script.count && (sim_script[0] == #char ";" || sim_script[0] == #char " ") advance(*sim_script); + if !sim_script.count return false; + + end := find_index_from_left(sim_script, #char ";"); + if end < 0 end = sim_script.count; + command := substring(sim_script, 0, end); + advance(*sim_script, end); + + parts := split(command, " ",, allocator = temp); + if !parts.count return true; + if parts[0] == { + case "move"; + input.mouse_x = string_to_float(parts[1]); + input.mouse_y = string_to_float(parts[2]); + case "down"; + input.mouse_down[MOUSE_LEFT] = true; + input.mouse_pressed[MOUSE_LEFT] = true; + case "up"; + input.mouse_down[MOUSE_LEFT] = false; + input.mouse_released[MOUSE_LEFT] = true; + case "click"; + input.mouse_x = string_to_float(parts[1]); + input.mouse_y = string_to_float(parts[2]); + input.mouse_down[MOUSE_LEFT] = true; + input.mouse_pressed[MOUSE_LEFT] = true; + sim_pending_up = true; + case "type"; + text := substring(command, 5, command.count); + for 0..text.count-1 array_add(*input.chars, cast(u32) text[it]); + case "key"; + k: Key_Press; + k.key = cast(u32) string_to_int(parts[1]); + if parts.count > 2 k.mods = cast(u32) string_to_int(parts[2]); + array_add(*input.keys, k); + case "wheel"; + input.wheel_y += string_to_float(parts[1]); + case "wait"; + sim_wait = string_to_int(parts[1]); + case "shot"; + sim_screenshot = copy_string(parts[1]); + case "quit"; + return false; + } + return true; +} + +save_screenshot :: (path: string, w: s32, h: s32) { + pixels := NewArray(w * h * 4, u8,, allocator = temp); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, xx w, xx h, GL_BGRA, GL_UNSIGNED_BYTE, pixels.data); + + // 32-bit BMP, bottom-up rows (which is what glReadPixels gives us). + b: String_Builder; + put :: (b: *String_Builder, value: $T) { + v := value; + append(b, cast(*u8) *v, size_of(T)); + } + append(*b, "BM"); + put(*b, cast(u32) (54 + pixels.count)); + put(*b, cast(u32) 0); + put(*b, cast(u32) 54); + put(*b, cast(u32) 40); + put(*b, cast(s32) w); + put(*b, cast(s32) h); + put(*b, cast(u16) 1); + put(*b, cast(u16) 32); + put(*b, cast(u32) 0); + put(*b, cast(u32) pixels.count); + put(*b, cast(s32) 2835); + put(*b, cast(s32) 2835); + put(*b, cast(u32) 0); + put(*b, cast(u32) 0); + append(*b, pixels.data, pixels.count); + write_entire_file(path, *b); +} diff --git a/src/platform_web.jai b/src/platform_web.jai new file mode 100644 index 0000000..721cb3e --- /dev/null +++ b/src/platform_web.jai @@ -0,0 +1,185 @@ +// Web platform layer. The page (web/taskforge.js) owns the canvas and the event listeners and calls the +// exported web_* procedures below; everything else, including all rendering, happens in Jai. + +// Imports implemented in web/taskforge.js. +js_storage_save :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign; +js_storage_load_size :: (key: *u8, key_count: s64) -> s64 #foreign; +js_storage_load :: (dest: *u8) #foreign; +js_http_post :: (tag: s32, path: *u8, path_count: s64, body: *u8, body_count: s64, token: *u8, token_count: s64) #foreign; +js_clipboard_write :: (data: *u8, count: s64) #foreign; +js_set_cursor :: (kind: s32) #foreign; +js_download :: (data: *u8, count: s64) #foreign; +js_request_import :: () #foreign; + +saved_context: #Context; +web_input: Frame_Input; + +main :: () { + // The JS side calls main() once to run the Jai runtime's initialization, then drives frames itself. + saved_context = context; + saved_context.stack_trace = null; +} + +#program_export +web_alloc :: (size: s64) -> *u8 #c_call { + result: *u8; + push_context saved_context { result = alloc(size); } + return result; +} + +#program_export +web_init :: (font_data: *u8, font_size: s64, server_mode: s32) -> s32 #c_call { + ok: bool; + push_context saved_context { + data: string; + data.data = font_data; + data.count = font_size; + ok = app_init(data, server_mode != 0); + } + return xx ok; +} + +#program_export +web_frame :: (width: float, height: float, dpr: float, time: float64) -> s32 #c_call { + wants_more: bool; + push_context saved_context { + web_input.width = width; + web_input.height = height; + web_input.dpr = dpr; + web_input.time = time; + wants_more = app_frame(*web_input); + reset_per_frame_input(*web_input); + } + return xx wants_more; +} + +#program_export +web_flush :: () #c_call { + push_context saved_context { if app.dirty save_now(); } +} + +#program_export +web_mouse_move :: (x: float, y: float) #c_call { + web_input.mouse_x = x; + web_input.mouse_y = y; +} + +#program_export +web_mouse_button :: (button: s32, down: s32) #c_call { + if button < 0 || button > 2 return; + if down { + web_input.mouse_down[button] = true; + web_input.mouse_pressed[button] = true; + } else { + web_input.mouse_down[button] = false; + web_input.mouse_released[button] = true; + } +} + +#program_export +web_wheel :: (dx: float, dy: float) #c_call { + web_input.wheel_x += dx; + web_input.wheel_y += dy; +} + +#program_export +web_modifiers :: (mods: u32) #c_call { + web_input.mods = mods; +} + +#program_export +web_key :: (key: u32, mods: u32) #c_call { + push_context saved_context { + web_input.mods = mods; + array_add(*web_input.keys, .{key = key, mods = mods}); + } +} + +#program_export +web_char :: (codepoint: u32) #c_call { + push_context saved_context { array_add(*web_input.chars, codepoint); } +} + +#program_export +web_http_response :: (tag: s32, status: s32, data: *u8, size: s64) #c_call { + push_context saved_context { + body: string; + body.data = data; + body.count = size; + http_on_response(xx tag, status, body); + if data free(data); + } +} + +#program_export +web_import :: (data: *u8, size: s64) #c_call { + push_context saved_context { + text: string; + text.data = data; + text.count = size; + app_import(text); + free(data); + } +} + +PROJECT_KEY :: "taskforge.project"; +SESSION_KEY :: "taskforge.session"; + +storage_load :: (key: string) -> string, bool { + size := js_storage_load_size(key.data, key.count); + if size <= 0 return "", false; + text := alloc_string(size); + js_storage_load(text.data); + return text, true; +} + +platform_load_project :: () -> string, bool { + text, ok := storage_load(PROJECT_KEY); + return text, ok; +} + +platform_save_project :: (data: string) -> bool { + js_storage_save(PROJECT_KEY.data, PROJECT_KEY.count, data.data, data.count); + return true; +} + +platform_load_session :: () -> string { + text := storage_load(SESSION_KEY); + return text; +} + +platform_save_session :: (token: string) { + js_storage_save(SESSION_KEY.data, SESSION_KEY.count, token.data, token.count); +} + +platform_http_post :: (tag: Http_Tag, path: string, body: string, token: string) { + js_http_post(xx tag, path.data, path.count, body.data, body.count, token.data, token.count); +} + +platform_server_label :: () -> string { + return "this server"; +} + +platform_storage_description :: () -> string { + return "Saved in this browser"; +} + +platform_clipboard_get :: () -> string { + return ""; // Pasting arrives through the browser's paste event as text input instead. +} + +platform_clipboard_set :: (text: string) { + js_clipboard_write(text.data, text.count); +} + +platform_set_cursor :: (kind: Cursor_Kind) { + js_set_cursor(xx kind); +} + +platform_export_project :: (data: string) { + js_download(data.data, data.count); +} + +platform_request_import :: () { + js_request_import(); +} diff --git a/src/render.jai b/src/render.jai new file mode 100644 index 0000000..bf893b6 --- /dev/null +++ b/src/render.jai @@ -0,0 +1,390 @@ +// Batched 2D renderer on plain OpenGL 3.3 core / OpenGL ES 3.0 (WebGL2). +// +// Everything is drawn with one shader and one vertex buffer per frame: +// mode 0: glyph -> color * atlas coverage +// mode 1: rounded rect, filled (analytic SDF, anti-aliased) +// mode 2: rounded rect, outline (thickness in u) +// mode 3: solid triangle +// All coordinates are logical pixels; the shader maps them to the framebuffer using the DPI scale. + +Vertex :: struct { + x, y: float; + u, v: float; + lx, ly: float; // Position relative to the rect center. + hx, hy: float; // Rect half size. + radius: float; + mode: float; + color: u32; +} + +Draw_Cmd :: struct { + first: s32; + count: s32; + clip: Rect; +} + +LAYER_BASE :: 0; +LAYER_POPUP :: 1; +LAYER_DRAG :: 2; +NUM_LAYERS :: 3; + +Draw_Layer :: struct { + verts: [..] Vertex; + cmds: [..] Draw_Cmd; + clip_stack: [..] Rect; +} + +Renderer :: struct { + program: GLuint; + vao: GLuint; + vbo: GLuint; + u_screen: GLint; + u_scale: GLint; + u_tex: GLint; + atlas: GLuint; + atlas_w, atlas_h: float; + + width, height: float; // Logical. + dpr: float = 1; + + layers: [NUM_LAYERS] Draw_Layer; + layer: s32; + upload: [..] Vertex; +} + +renderer: Renderer; + +#if OS == .WASM { + SHADER_HEADER :: "#version 300 es\nprecision highp float;\n"; +} else { + SHADER_HEADER :: "#version 330 core\n"; +} + +VERTEX_SHADER :: #string GLSL +in vec2 a_pos; +in vec2 a_uv; +in vec2 a_local; +in vec2 a_half; +in vec2 a_rm; +in vec4 a_color; +uniform vec2 u_screen; +out vec2 v_uv; +out vec2 v_local; +out vec2 v_half; +out vec2 v_rm; +out vec4 v_color; +void main() { + v_uv = a_uv; v_local = a_local; v_half = a_half; v_rm = a_rm; v_color = a_color; + gl_Position = vec4(a_pos.x / u_screen.x * 2.0 - 1.0, 1.0 - a_pos.y / u_screen.y * 2.0, 0.0, 1.0); +} +GLSL + +FRAGMENT_SHADER :: #string GLSL +in vec2 v_uv; +in vec2 v_local; +in vec2 v_half; +in vec2 v_rm; +in vec4 v_color; +uniform sampler2D u_tex; +uniform float u_scale; +out vec4 o_color; +float sd_round_box(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + vec2(r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} +void main() { + float mode = v_rm.y; + if (mode < 0.5) { + float a = texture(u_tex, v_uv).r; + o_color = vec4(v_color.rgb, v_color.a * a); + } else if (mode < 2.5) { + float d = sd_round_box(v_local, v_half, v_rm.x); + float a = clamp(0.5 - d * u_scale, 0.0, 1.0); + if (mode > 1.5) a *= clamp(0.5 + (d + v_uv.x) * u_scale, 0.0, 1.0); + o_color = vec4(v_color.rgb, v_color.a * a); + } else { + o_color = v_color; + } +} +GLSL + +init_renderer :: () -> bool { + using renderer; + vs := compile_shader(GL_VERTEX_SHADER, VERTEX_SHADER); + fs := compile_shader(GL_FRAGMENT_SHADER, FRAGMENT_SHADER); + if !vs || !fs return false; + + program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, fs); + glBindAttribLocation(program, 0, "a_pos".data); + glBindAttribLocation(program, 1, "a_uv".data); + glBindAttribLocation(program, 2, "a_local".data); + glBindAttribLocation(program, 3, "a_half".data); + glBindAttribLocation(program, 4, "a_rm".data); + glBindAttribLocation(program, 5, "a_color".data); + glLinkProgram(program); + ok: GLint; + glGetProgramiv(program, GL_LINK_STATUS, *ok); + if !ok { + buffer: [1024] u8; + length: GLsizei; + glGetProgramInfoLog(program, buffer.count, *length, buffer.data); + log_error("Shader link failed: %", to_string(buffer.data, length)); + return false; + } + u_screen = glGetUniformLocation(program, "u_screen".data); + u_scale = glGetUniformLocation(program, "u_scale".data); + u_tex = glGetUniformLocation(program, "u_tex".data); + + glGenVertexArrays(1, *vao); + glBindVertexArray(vao); + glGenBuffers(1, *vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + + stride :: size_of(Vertex); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, stride, cast(*void) 0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, cast(*void) 8); + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, cast(*void) 16); + glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, cast(*void) 24); + glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, stride, cast(*void) 32); + glVertexAttribPointer(5, 4, GL_UNSIGNED_BYTE, GL_TRUE, stride, cast(*void) 40); + for 0..5 glEnableVertexAttribArray(xx it); + return true; +} + +compile_shader :: (type: GLenum, body: string) -> GLuint { + shader := glCreateShader(type); + sources: [2] *u8; + sources[0] = SHADER_HEADER.data; + sources[1] = body.data; + lengths: [2] GLint; + lengths[0] = SHADER_HEADER.count; + lengths[1] = xx body.count; + glShaderSource(shader, 2, sources.data, lengths.data); + glCompileShader(shader); + ok: GLint; + glGetShaderiv(shader, GL_COMPILE_STATUS, *ok); + if !ok { + buffer: [1024] u8; + length: GLsizei; + glGetShaderInfoLog(shader, buffer.count, *length, buffer.data); + log_error("Shader compile failed: %", to_string(buffer.data, length)); + return 0; + } + return shader; +} + +upload_atlas :: (pixels: *u8, w: s32, h: s32) { + using renderer; + glGenTextures(1, *atlas); + glBindTexture(GL_TEXTURE_2D, atlas); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, xx w, xx h, 0, GL_RED, GL_UNSIGNED_BYTE, pixels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + atlas_w = xx w; + atlas_h = xx h; +} + +begin_render :: (width: float, height: float, dpr: float) { + renderer.width = width; + renderer.height = height; + renderer.dpr = dpr; + for * renderer.layers { + it.verts.count = 0; + it.cmds.count = 0; + it.clip_stack.count = 0; + array_add(*it.clip_stack, make_rect(0, 0, width, height)); + } + renderer.layer = LAYER_BASE; +} + +set_layer :: (layer: s32) { + renderer.layer = layer; +} + +current_clip :: () -> Rect { + stack := *renderer.layers[renderer.layer].clip_stack; + return stack.*[stack.count - 1]; +} + +push_clip :: (r: Rect) { + stack := *renderer.layers[renderer.layer].clip_stack; + array_add(stack, intersect(stack.*[stack.count - 1], r)); +} + +pop_clip :: () { + stack := *renderer.layers[renderer.layer].clip_stack; + if stack.count > 1 stack.count -= 1; +} + +// Returns a pointer to n new vertices in the current layer, starting a new draw command if the clip changed. +reserve_vertices :: (n: int) -> *Vertex { + layer := *renderer.layers[renderer.layer]; + clip := layer.clip_stack[layer.clip_stack.count - 1]; + need_new := layer.cmds.count == 0; + if !need_new { + last := layer.cmds[layer.cmds.count - 1].clip; + need_new = last.x != clip.x || last.y != clip.y || last.w != clip.w || last.h != clip.h; + } + if need_new { + cmd: Draw_Cmd; + cmd.first = xx layer.verts.count; + cmd.clip = clip; + array_add(*layer.cmds, cmd); + } + layer.cmds[layer.cmds.count - 1].count += xx n; + old := layer.verts.count; + array_resize(*layer.verts, old + n, initialize = false); + return layer.verts.data + old; +} + +draw_rect :: (r: Rect, color: u32, radius: float = 0) { + shape(r, color, radius, 1, 0); +} + +draw_rect_outline :: (r: Rect, color: u32, thickness: float = 1, radius: float = 0) { + shape(r, color, radius, 2, thickness); +} + +shape :: (r: Rect, color: u32, radius: float, mode: float, thickness: float) { + if r.w <= 0 || r.h <= 0 || (color >> 24) == 0 return; + hx := r.w * 0.5; + hy := r.h * 0.5; + cx := r.x + hx; + cy := r.y + hy; + radius = min(radius, min(hx, hy)); + pad :: 1.0; + x0, y0 := r.x - pad, r.y - pad; + x1, y1 := r.x + r.w + pad, r.y + r.h + pad; + + xs := float.[x0, x1, x1, x0, x1, x0]; + ys := float.[y0, y0, y1, y0, y1, y1]; + v := reserve_vertices(6); + for i: 0..5 { + p := v + i; + p.x, p.y = xs[i], ys[i]; + p.u, p.v = thickness, 0; + p.lx, p.ly = xs[i] - cx, ys[i] - cy; + p.hx, p.hy = hx, hy; + p.radius = radius; + p.mode = mode; + p.color = color; + } +} + +draw_glyph_quad :: (x0: float, y0: float, x1: float, y1: float, u0: float, v0: float, u1: float, v1: float, color: u32) { + xs := float.[x0, x1, x1, x0, x1, x0]; + ys := float.[y0, y0, y1, y0, y1, y1]; + us := float.[u0, u1, u1, u0, u1, u0]; + vs := float.[v0, v0, v1, v0, v1, v1]; + v := reserve_vertices(6); + for i: 0..5 { + p := v + i; + p.* = .{}; + p.x, p.y = xs[i], ys[i]; + p.u, p.v = us[i], vs[i]; + p.color = color; + } +} + +draw_triangle :: (ax: float, ay: float, bx: float, by: float, cx: float, cy: float, color: u32) { + v := reserve_vertices(3); + for 0..2 { + v[it] = .{}; + v[it].mode = 3; + v[it].color = color; + } + v[0].x, v[0].y = ax, ay; + v[1].x, v[1].y = bx, by; + v[2].x, v[2].y = cx, cy; +} + +draw_line :: (ax: float, ay: float, bx: float, by: float, thickness: float, color: u32) { + dx, dy := bx - ax, by - ay; + len := sqrt(dx*dx + dy*dy); + if len <= 0 return; + nx := -dy / len * thickness * 0.5; + ny := dx / len * thickness * 0.5; + draw_triangle(ax + nx, ay + ny, bx + nx, by + ny, bx - nx, by - ny, color); + draw_triangle(ax + nx, ay + ny, bx - nx, by - ny, ax - nx, ay - ny, color); +} + +// Small "v" chevron used by dropdowns. +draw_chevron_down :: (cx: float, cy: float, size: float, color: u32) { + draw_line(cx - size, cy - size*0.5, cx, cy + size*0.5, 1.5, color); + draw_line(cx, cy + size*0.5, cx + size, cy - size*0.5, 1.5, color); +} + +draw_check_mark :: (r: Rect, color: u32) { + x, y, s := r.x, r.y, min(r.w, r.h); + draw_line(x + s*0.22, y + s*0.52, x + s*0.42, y + s*0.72, 2, color); + draw_line(x + s*0.42, y + s*0.72, x + s*0.78, y + s*0.30, 2, color); +} + +draw_cross :: (cx: float, cy: float, size: float, color: u32) { + draw_line(cx - size, cy - size, cx + size, cy + size, 1.5, color); + draw_line(cx - size, cy + size, cx + size, cy - size, 1.5, color); +} + +draw_plus :: (cx: float, cy: float, size: float, color: u32) { + draw_rect(make_rect(cx - size, cy - 0.75, size*2, 1.5), color); + draw_rect(make_rect(cx - 0.75, cy - size, 1.5, size*2), color); +} + +end_render :: (clear_color: u32) { + using renderer; + fb_w := cast(s32) round_f(width * dpr); + fb_h := cast(s32) round_f(height * dpr); + + glViewport(0, 0, xx fb_w, xx fb_h); + glDisable(GL_SCISSOR_TEST); + cr := cast(float) ( clear_color & 0xFF) / 255.0; + cg := cast(float) ((clear_color >> 8) & 0xFF) / 255.0; + cb := cast(float) ((clear_color >> 16) & 0xFF) / 255.0; + glClearColor(cr, cg, cb, 1); + glClear(GL_COLOR_BUFFER_BIT); + + upload.count = 0; + for layers array_add_array(*upload, it.verts); + if !upload.count return; + + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glEnable(GL_SCISSOR_TEST); + + glUseProgram(program); + glUniform2f(u_screen, width, height); + glUniform1f(u_scale, dpr); + glUniform1i(u_tex, 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, atlas); + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, upload.count * size_of(Vertex), upload.data, GL_STREAM_DRAW); + + base: s32 = 0; + for layer: layers { + for layer.cmds { + if it.clip.w <= 0 || it.clip.h <= 0 continue; + sx := cast(s32) round_f(it.clip.x * dpr); + sy := cast(s32) round_f(it.clip.y * dpr); + sw := cast(s32) round_f((it.clip.x + it.clip.w) * dpr) - sx; + sh := cast(s32) round_f((it.clip.y + it.clip.h) * dpr) - sy; + glScissor(sx, fb_h - sy - sh, xx sw, xx sh); + glDrawArrays(GL_TRIANGLES, base + it.first, xx it.count); + } + base += xx layer.verts.count; + } +} + +array_add_array :: (dest: *[..] $T, src: [] T) { + old := dest.count; + array_resize(dest, old + src.count, initialize = false); + memcpy(dest.data + old, src.data, src.count * size_of(T)); +} diff --git a/src/storage.jai b/src/storage.jai new file mode 100644 index 0000000..0b4b61d --- /dev/null +++ b/src/storage.jai @@ -0,0 +1,632 @@ +// Project storage. A project is a set of records, one text line per entity, each with a unique key +// (see record_key). The same lines are used for the local project file and for syncing with the server. +// +// project "My Game" +// field "name" {