Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.build/
|
||||
bin/
|
||||
server/bin/
|
||||
web/fonts.bin
|
||||
web/taskforge.wasm
|
||||
*.taskforge.tmp
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -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@<droplet-ip> 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@<droplet-ip>`.
|
||||
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 <user>`.
|
||||
|
||||
## 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`.
|
||||
@@ -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";
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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@<droplet-ip> tasks.example.com first time (sets up the droplet for that domain)
|
||||
# deploy/deploy.sh root@<droplet-ip> 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"
|
||||
@@ -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 <domain>}"
|
||||
|
||||
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 <username> \"<Your Name>\" --admin"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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 <username> "<Display Name>" [--admin] (password read from stdin)
|
||||
// taskforge-server [--db taskforge.db] set-password <username> (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 <username> "<Display Name>" [--admin] (reads password from stdin)
|
||||
taskforge-server [--db taskforge.db] set-password <username> (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> admin only
|
||||
// update <id> "Display Name" <admin> <active> admin only
|
||||
// password <id> "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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+165
@@ -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);
|
||||
}
|
||||
+1605
File diff suppressed because it is too large
Load Diff
+261
@@ -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);
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
+618
@@ -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 <epic>, set <target_field> to <target_value> when <quant> of its tasks [where <filter_field> has <filter_option>]
|
||||
// have <cond_field> <cond_op> <cond_value>."
|
||||
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;
|
||||
}
|
||||
@@ -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 "<commands>" 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
+390
@@ -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));
|
||||
}
|
||||
+632
@@ -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 <id> <order> <kind> <scope> "name" {<option id> <rrggbb> "option name"}*
|
||||
// task <id> <is_epic> <epic> <order> "title" "notes"
|
||||
// val <task> <field> text "..." | num "3.5" | sel <id> | tags <id>...
|
||||
// view <id> <order> <kind> <items> <group_field> <check_field> <check_value> <group_by_epic> <filter_epic> <filter_field> <filter_option> "name" {<card field>}*
|
||||
// rule <id> <order> <epic> <target_field> <target_value> <quant> <filter_field> <filter_option> <cond_field> <cond_op> <cond_value>
|
||||
//
|
||||
// A local project file starts with "taskforge 2" and "next_id N". Version 1 files (the first format) still load.
|
||||
|
||||
FORMAT_VERSION :: 2;
|
||||
|
||||
// All records of a project, as temp-allocated lines.
|
||||
project_records :: (p: *Project, lines: *[..] string) {
|
||||
push_allocator(temp);
|
||||
array_add(lines, tprint("project %", quoted(p.name)));
|
||||
for f: p.fields {
|
||||
b: String_Builder;
|
||||
print_to_builder(*b, "field % % % % %", f.id, f.order, cast(u8) f.kind, cast(u8) f.scope, quoted(f.name));
|
||||
for f.options print_to_builder(*b, " % % %", it.id, formatInt(it.color, base = 16, minimum_digits = 6), quoted(it.name));
|
||||
array_add(lines, builder_to_string(*b));
|
||||
}
|
||||
for t: p.tasks {
|
||||
array_add(lines, tprint("task % % % % % %", t.id, cast(u8) t.is_epic, t.epic, t.order, quoted(t.title), quoted(t.notes)));
|
||||
for * v: t.values {
|
||||
f := find_field(p, v.field);
|
||||
if !f continue;
|
||||
line := value_record(t, f, v);
|
||||
if line.count array_add(lines, line);
|
||||
}
|
||||
}
|
||||
for v: p.views {
|
||||
b: String_Builder;
|
||||
print_to_builder(*b, "view % % % % % % % % % % % %", v.id, v.order, cast(u8) v.kind, cast(u8) v.items, v.group_field,
|
||||
v.check_field, v.check_value, cast(u8) v.group_by_epic, v.filter_epic, v.filter_field, v.filter_option, quoted(v.name));
|
||||
for v.card_fields print_to_builder(*b, " %", it);
|
||||
array_add(lines, builder_to_string(*b));
|
||||
}
|
||||
for r: p.rules {
|
||||
array_add(lines, tprint("rule % % % % % % % % % % %", r.id, r.order, r.epic, r.target_field, r.target_value, cast(u8) r.quant,
|
||||
r.filter_field, r.filter_option, r.cond_field, cast(u8) r.cond_op, r.cond_value));
|
||||
}
|
||||
}
|
||||
|
||||
value_record :: (t: *Task, f: *Field, v: *Field_Value) -> string {
|
||||
if f.kind == {
|
||||
case .TEXT;
|
||||
if v.text.count return tprint("val % % text %", t.id, f.id, quoted(v.text));
|
||||
case .NUMBER;
|
||||
if v.text.count return tprint("val % % num %", t.id, f.id, quoted(v.text));
|
||||
case .CHECKBOX; #through;
|
||||
case .SELECT;
|
||||
if v.sel return tprint("val % % sel %", t.id, f.id, v.sel);
|
||||
case .TAGS; #through;
|
||||
case .USER;
|
||||
if v.tags.count {
|
||||
b: String_Builder;
|
||||
b.allocator = temp;
|
||||
print_to_builder(*b, "val % % tags", t.id, f.id);
|
||||
for v.tags print_to_builder(*b, " %", it);
|
||||
return builder_to_string(*b,, allocator = temp);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// The unique key of a record line: its kind plus its id(s), e.g. "task 42" or "val 42 7".
|
||||
record_key :: (line: string) -> string {
|
||||
r := Line_Reader.{line = line};
|
||||
kind := next_token(*r);
|
||||
ids := 0;
|
||||
if kind == {
|
||||
case "project"; ids = 0;
|
||||
case "field"; #through;
|
||||
case "task"; #through;
|
||||
case "view"; #through;
|
||||
case "rule"; ids = 1;
|
||||
case "val"; ids = 2;
|
||||
case; return "";
|
||||
}
|
||||
if ids == 0 return copy_string(kind,, temp);
|
||||
a := next_token(*r);
|
||||
if ids == 1 return tprint("% %", kind, a);
|
||||
b := next_token(*r);
|
||||
return tprint("% % %", kind, a, b);
|
||||
}
|
||||
|
||||
serialize_project :: (p: *Project) -> string {
|
||||
lines: [..] string;
|
||||
lines.allocator = temp;
|
||||
project_records(p, *lines);
|
||||
b: String_Builder;
|
||||
print_to_builder(*b, "taskforge %\n", FORMAT_VERSION);
|
||||
print_to_builder(*b, "next_id %\n", p.next_id);
|
||||
for lines {
|
||||
append(*b, it);
|
||||
append(*b, "\n");
|
||||
}
|
||||
return builder_to_string(*b);
|
||||
}
|
||||
|
||||
quoted :: (s: string) -> string {
|
||||
b: String_Builder;
|
||||
b.allocator = temp;
|
||||
append(*b, "\"");
|
||||
for i: 0..s.count-1 {
|
||||
c := s[i];
|
||||
if c == {
|
||||
case #char "\""; append(*b, "\\\"");
|
||||
case #char "\\"; append(*b, "\\\\");
|
||||
case #char "\n"; append(*b, "\\n");
|
||||
case #char "\r";
|
||||
case; append(*b, c);
|
||||
}
|
||||
}
|
||||
append(*b, "\"");
|
||||
return builder_to_string(*b,, allocator = temp);
|
||||
}
|
||||
|
||||
//
|
||||
// Parsing
|
||||
//
|
||||
|
||||
Line_Reader :: struct {
|
||||
line: string;
|
||||
pos: int;
|
||||
}
|
||||
|
||||
next_token :: (r: *Line_Reader) -> string, bool {
|
||||
while r.pos < r.line.count && (r.line[r.pos] == #char " " || r.line[r.pos] == #char "\t") r.pos += 1;
|
||||
if r.pos >= r.line.count return "", false;
|
||||
|
||||
if r.line[r.pos] == #char "\"" {
|
||||
r.pos += 1;
|
||||
b: String_Builder;
|
||||
b.allocator = temp;
|
||||
while r.pos < r.line.count {
|
||||
c := r.line[r.pos];
|
||||
r.pos += 1;
|
||||
if c == #char "\"" break;
|
||||
if c == #char "\\" && r.pos < r.line.count {
|
||||
e := r.line[r.pos];
|
||||
r.pos += 1;
|
||||
if e == #char "n" append(*b, #char "\n");
|
||||
else append(*b, e);
|
||||
continue;
|
||||
}
|
||||
append(*b, c);
|
||||
}
|
||||
return builder_to_string(*b,, allocator = temp), true;
|
||||
}
|
||||
|
||||
start := r.pos;
|
||||
while r.pos < r.line.count && r.line[r.pos] != #char " " && r.line[r.pos] != #char "\t" r.pos += 1;
|
||||
return substring(r.line, start, r.pos), true;
|
||||
}
|
||||
|
||||
next_u32 :: (r: *Line_Reader) -> u32 {
|
||||
s := next_token(r);
|
||||
value := string_to_int(s, T = s64);
|
||||
return cast,trunc(u32) value;
|
||||
}
|
||||
|
||||
next_f64 :: (r: *Line_Reader) -> float64 {
|
||||
s := next_token(r);
|
||||
value := string_to_float64(s);
|
||||
return value;
|
||||
}
|
||||
|
||||
parse_project :: (text: string) -> *Project, bool {
|
||||
p := New(Project);
|
||||
version := 0;
|
||||
start := 0;
|
||||
while start < text.count {
|
||||
end := start;
|
||||
while end < text.count && text[end] != #char "\n" end += 1;
|
||||
line := substring(text, start, end);
|
||||
start = end + 1;
|
||||
if line.count && line[line.count - 1] == #char "\r" line.count -= 1;
|
||||
if !line.count continue;
|
||||
|
||||
if begins_with(line, "taskforge ") {
|
||||
version = string_to_int(substring(line, 10, line.count));
|
||||
} else if begins_with(line, "next_id ") {
|
||||
p.next_id = cast(u32) string_to_int(substring(line, 8, line.count));
|
||||
} else if version == 1 {
|
||||
apply_v1_record(p, line);
|
||||
} else if begins_with(line, "val ") {
|
||||
// Values reference tasks and fields, which may come later in the file.
|
||||
continue;
|
||||
} else {
|
||||
apply_record(p, line);
|
||||
}
|
||||
}
|
||||
if !version return p, false;
|
||||
|
||||
// Second pass for values.
|
||||
if version >= 2 {
|
||||
start = 0;
|
||||
while start < text.count {
|
||||
end := start;
|
||||
while end < text.count && text[end] != #char "\n" end += 1;
|
||||
line := substring(text, start, end);
|
||||
start = end + 1;
|
||||
if line.count && line[line.count - 1] == #char "\r" line.count -= 1;
|
||||
if begins_with(line, "val ") apply_record(p, line);
|
||||
}
|
||||
}
|
||||
|
||||
if version == 1 {
|
||||
for p.fields it.order = cast(float64) it_index + 1;
|
||||
for p.views it.order = cast(float64) it_index + 1;
|
||||
for p.rules it.order = cast(float64) it_index + 1;
|
||||
}
|
||||
sort_project(p);
|
||||
|
||||
// Make sure ids never collide even if next_id was edited by hand.
|
||||
p.next_id = max(p.next_id, max_entity_id(p) + 1);
|
||||
return p, true;
|
||||
}
|
||||
|
||||
max_entity_id :: (p: *Project) -> u32 {
|
||||
max_id: u32;
|
||||
for p.fields {
|
||||
max_id = max(max_id, it.id);
|
||||
for o: it.options max_id = max(max_id, o.id);
|
||||
}
|
||||
for p.tasks max_id = max(max_id, it.id);
|
||||
for p.views max_id = max(max_id, it.id);
|
||||
for p.rules max_id = max(max_id, it.id);
|
||||
return max_id;
|
||||
}
|
||||
|
||||
// Creates or updates (in place, so pointers stay valid) the entity a record line describes.
|
||||
apply_record :: (p: *Project, line: string) {
|
||||
r := Line_Reader.{line = line};
|
||||
kind := next_token(*r);
|
||||
if kind == {
|
||||
case "project";
|
||||
set_string(*p.name, next_token(*r));
|
||||
|
||||
case "field";
|
||||
id := next_u32(*r);
|
||||
f := find_field(p, id);
|
||||
if !f {
|
||||
f = New(Field);
|
||||
f.id = id;
|
||||
array_add(*p.fields, f);
|
||||
}
|
||||
f.order = next_f64(*r);
|
||||
f.kind = xx next_u32(*r);
|
||||
f.scope = xx next_u32(*r);
|
||||
set_string(*f.name, next_token(*r));
|
||||
for f.options free(it.name);
|
||||
f.options.count = 0;
|
||||
while true {
|
||||
oid_text, ok := next_token(*r);
|
||||
if !ok break;
|
||||
o: Option;
|
||||
o.id = cast(u32) string_to_int(oid_text);
|
||||
o.color = cast(u32) string_to_int(next_token(*r), base = 16);
|
||||
o.name = copy_string(next_token(*r));
|
||||
array_add(*f.options, o);
|
||||
}
|
||||
|
||||
case "task";
|
||||
id := next_u32(*r);
|
||||
t := find_task(p, id);
|
||||
if !t {
|
||||
t = New(Task);
|
||||
t.id = id;
|
||||
array_add(*p.tasks, t);
|
||||
}
|
||||
t.is_epic = next_u32(*r) != 0;
|
||||
t.epic = next_u32(*r);
|
||||
t.order = next_f64(*r);
|
||||
set_string(*t.title, next_token(*r));
|
||||
set_string(*t.notes, next_token(*r));
|
||||
|
||||
case "val";
|
||||
t := find_task(p, next_u32(*r));
|
||||
field := next_u32(*r);
|
||||
if !t || !find_field(p, field) return;
|
||||
v := value_for_edit(t, field);
|
||||
what := next_token(*r);
|
||||
if what == {
|
||||
case "text"; set_string(*v.text, next_token(*r));
|
||||
case "num";
|
||||
set_string(*v.text, next_token(*r));
|
||||
v.num = string_to_float(v.text);
|
||||
case "sel"; v.sel = next_u32(*r);
|
||||
case "tags";
|
||||
v.tags.count = 0;
|
||||
while true {
|
||||
s, ok := next_token(*r);
|
||||
if !ok break;
|
||||
array_add(*v.tags, cast(u32) string_to_int(s));
|
||||
}
|
||||
}
|
||||
|
||||
case "view";
|
||||
id := next_u32(*r);
|
||||
v := find_view(p, id);
|
||||
if !v {
|
||||
v = New(View);
|
||||
v.id = id;
|
||||
array_add(*p.views, v);
|
||||
}
|
||||
v.order = next_f64(*r);
|
||||
v.kind = xx next_u32(*r);
|
||||
v.items = xx next_u32(*r);
|
||||
v.group_field = next_u32(*r);
|
||||
v.check_field = next_u32(*r);
|
||||
v.check_value = next_u32(*r);
|
||||
v.group_by_epic = next_u32(*r) != 0;
|
||||
v.filter_epic = next_u32(*r);
|
||||
v.filter_field = next_u32(*r);
|
||||
v.filter_option = next_u32(*r);
|
||||
set_string(*v.name, next_token(*r));
|
||||
v.card_fields.count = 0;
|
||||
while true {
|
||||
s, ok := next_token(*r);
|
||||
if !ok break;
|
||||
array_add(*v.card_fields, cast(u32) string_to_int(s));
|
||||
}
|
||||
|
||||
case "rule";
|
||||
id := next_u32(*r);
|
||||
rule: *Epic_Rule;
|
||||
for p.rules if it.id == id rule = it;
|
||||
if !rule {
|
||||
rule = New(Epic_Rule);
|
||||
rule.id = id;
|
||||
array_add(*p.rules, rule);
|
||||
}
|
||||
rule.order = next_f64(*r);
|
||||
rule.epic = next_u32(*r);
|
||||
rule.target_field = next_u32(*r);
|
||||
rule.target_value = next_u32(*r);
|
||||
rule.quant = xx next_u32(*r);
|
||||
rule.filter_field = next_u32(*r);
|
||||
rule.filter_option = next_u32(*r);
|
||||
rule.cond_field = next_u32(*r);
|
||||
rule.cond_op = xx next_u32(*r);
|
||||
rule.cond_value = next_u32(*r);
|
||||
}
|
||||
}
|
||||
|
||||
// Removes the entity with this record key. No cascading: related records are deleted by their own keys.
|
||||
delete_record :: (p: *Project, key: string) {
|
||||
r := Line_Reader.{line = key};
|
||||
kind := next_token(*r);
|
||||
id := next_u32(*r);
|
||||
if kind == {
|
||||
case "field";
|
||||
for p.fields if it.id == id { array_ordered_remove_by_index(*p.fields, it_index); break; }
|
||||
case "task";
|
||||
for p.tasks if it.id == id { array_ordered_remove_by_index(*p.tasks, it_index); break; }
|
||||
case "view";
|
||||
for p.views if it.id == id { array_ordered_remove_by_index(*p.views, it_index); break; }
|
||||
case "rule";
|
||||
for p.rules if it.id == id { array_ordered_remove_by_index(*p.rules, it_index); break; }
|
||||
case "val";
|
||||
t := find_task(p, id);
|
||||
if t clear_value(t, next_u32(*r));
|
||||
}
|
||||
}
|
||||
|
||||
// The original (version 1) file format.
|
||||
apply_v1_record :: (p: *Project, line: string) {
|
||||
r := Line_Reader.{line = line};
|
||||
kind := next_token(*r);
|
||||
if kind == {
|
||||
case "project";
|
||||
p.name = copy_string(next_token(*r));
|
||||
case "field";
|
||||
f := New(Field);
|
||||
f.id = next_u32(*r);
|
||||
f.kind = xx next_u32(*r);
|
||||
f.scope = xx next_u32(*r);
|
||||
f.name = copy_string(next_token(*r));
|
||||
array_add(*p.fields, f);
|
||||
case "option";
|
||||
f := find_field(p, next_u32(*r));
|
||||
if !f return;
|
||||
o: Option;
|
||||
o.id = next_u32(*r);
|
||||
o.color = cast(u32) string_to_int(next_token(*r), base = 16);
|
||||
o.name = copy_string(next_token(*r));
|
||||
array_add(*f.options, o);
|
||||
case "task";
|
||||
t := New(Task);
|
||||
t.id = next_u32(*r);
|
||||
t.is_epic = next_u32(*r) != 0;
|
||||
t.epic = next_u32(*r);
|
||||
t.order = next_f64(*r);
|
||||
t.title = copy_string(next_token(*r));
|
||||
array_add(*p.tasks, t);
|
||||
case "notes";
|
||||
t := find_task(p, next_u32(*r));
|
||||
if t t.notes = copy_string(next_token(*r));
|
||||
case "val";
|
||||
apply_record(p, line);
|
||||
case "view";
|
||||
v := New(View);
|
||||
v.id = next_u32(*r);
|
||||
v.kind = xx next_u32(*r);
|
||||
v.items = xx next_u32(*r);
|
||||
v.group_field = next_u32(*r);
|
||||
v.check_field = next_u32(*r);
|
||||
v.check_value = next_u32(*r);
|
||||
v.group_by_epic = next_u32(*r) != 0;
|
||||
v.filter_epic = next_u32(*r);
|
||||
v.filter_field = next_u32(*r);
|
||||
v.filter_option = next_u32(*r);
|
||||
v.name = copy_string(next_token(*r));
|
||||
array_add(*p.views, v);
|
||||
case "viewfield";
|
||||
v := find_view(p, next_u32(*r));
|
||||
if v array_add(*v.card_fields, next_u32(*r));
|
||||
case "rule";
|
||||
rule := New(Epic_Rule);
|
||||
rule.id = next_u32(*r);
|
||||
rule.epic = next_u32(*r);
|
||||
rule.target_field = next_u32(*r);
|
||||
rule.target_value = next_u32(*r);
|
||||
rule.quant = xx next_u32(*r);
|
||||
rule.filter_field = next_u32(*r);
|
||||
rule.filter_option = next_u32(*r);
|
||||
rule.cond_field = next_u32(*r);
|
||||
rule.cond_op = xx next_u32(*r);
|
||||
rule.cond_value = next_u32(*r);
|
||||
array_add(*p.rules, rule);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Starter projects.
|
||||
//
|
||||
|
||||
new_view :: (p: *Project, name: string, kind: View_Kind) -> *View {
|
||||
v := New(View);
|
||||
v.id = new_id(p);
|
||||
v.name = copy_string(name);
|
||||
v.kind = kind;
|
||||
v.order = next_order(p.views);
|
||||
array_add(*p.views, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
add_rule :: (p: *Project) -> *Epic_Rule {
|
||||
r := New(Epic_Rule);
|
||||
r.id = new_id(p);
|
||||
r.order = next_order(p.rules);
|
||||
array_add(*p.rules, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// A fresh project for a new server: the common fields, a board, "My tasks" and epic status rules.
|
||||
starter_project :: (demo: bool) -> *Project {
|
||||
if demo return default_project();
|
||||
|
||||
p := New(Project);
|
||||
p.name = copy_string("My Game");
|
||||
status := add_field(p, "Status", .SELECT);
|
||||
backlog := add_option(p, status, "Backlog", 0x8B949E);
|
||||
add_option(p, status, "To do", 0x58A6FF);
|
||||
in_progress := add_option(p, status, "In progress", 0xD29922);
|
||||
add_option(p, status, "Review", 0xBC8CFF);
|
||||
done := add_option(p, status, "Done", 0x3FB950);
|
||||
assignee := add_field(p, "Assignee", .USER);
|
||||
discipline := add_field(p, "Discipline", .TAGS, .TASKS);
|
||||
for string.["Code", "Art", "Design", "Audio", "Writing", "QA"] add_option(p, discipline, it);
|
||||
priority := add_field(p, "Priority", .SELECT);
|
||||
add_option(p, priority, "Low", 0x8B949E);
|
||||
add_option(p, priority, "Medium", 0x58A6FF);
|
||||
add_option(p, priority, "High", 0xDB6D28);
|
||||
add_option(p, priority, "Critical", 0xF85149);
|
||||
|
||||
board := new_view(p, "Board", .KANBAN);
|
||||
board.group_field = status.id;
|
||||
board.check_field = status.id;
|
||||
board.check_value = done;
|
||||
array_add(*board.card_fields, assignee.id);
|
||||
array_add(*board.card_fields, discipline.id);
|
||||
array_add(*board.card_fields, priority.id);
|
||||
|
||||
mine := new_view(p, "My tasks", .CHECKLIST);
|
||||
mine.group_field = status.id;
|
||||
mine.check_field = status.id;
|
||||
mine.check_value = done;
|
||||
mine.group_by_epic = true;
|
||||
mine.filter_field = assignee.id;
|
||||
mine.filter_option = ME_OPTION;
|
||||
array_add(*mine.card_fields, status.id);
|
||||
array_add(*mine.card_fields, priority.id);
|
||||
|
||||
r := add_rule(p);
|
||||
r.target_field, r.target_value = status.id, done;
|
||||
r.quant = .ALL;
|
||||
r.cond_field, r.cond_op, r.cond_value = status.id, Compare.IS, done;
|
||||
r = add_rule(p);
|
||||
r.target_field, r.target_value = status.id, in_progress;
|
||||
r.quant = .ANY;
|
||||
r.cond_field, r.cond_op, r.cond_value = status.id, Compare.IS_NOT, backlog;
|
||||
return p;
|
||||
}
|
||||
|
||||
default_project :: () -> *Project {
|
||||
p := New(Project);
|
||||
p.name = copy_string("My Game");
|
||||
|
||||
status := add_field(p, "Status", .SELECT);
|
||||
backlog := add_option(p, status, "Backlog", 0x8B949E);
|
||||
todo := add_option(p, status, "To do", 0x58A6FF);
|
||||
in_progress := add_option(p, status, "In progress", 0xD29922);
|
||||
review := add_option(p, status, "Review", 0xBC8CFF);
|
||||
done := add_option(p, status, "Done", 0x3FB950);
|
||||
|
||||
discipline := add_field(p, "Discipline", .TAGS, .TASKS);
|
||||
code := add_option(p, discipline, "Code", 0x58A6FF);
|
||||
art := add_option(p, discipline, "Art", 0xF778BA);
|
||||
design := add_option(p, discipline, "Design", 0xD29922);
|
||||
audio := add_option(p, discipline, "Audio", 0x39C5CF);
|
||||
add_option(p, discipline, "Writing", 0xA371F7);
|
||||
add_option(p, discipline, "QA", 0xDB6D28);
|
||||
|
||||
priority := add_field(p, "Priority", .SELECT);
|
||||
add_option(p, priority, "Low", 0x8B949E);
|
||||
medium := add_option(p, priority, "Medium", 0x58A6FF);
|
||||
high := add_option(p, priority, "High", 0xDB6D28);
|
||||
add_option(p, priority, "Critical", 0xF85149);
|
||||
|
||||
estimate := add_field(p, "Estimate (h)", .NUMBER, .TASKS);
|
||||
assignee := add_field(p, "Assignee", .USER);
|
||||
|
||||
phase_names :: string.["Not started", "In progress", "Done"];
|
||||
phase_colors :: u32.[0x8B949E, 0xD29922, 0x3FB950];
|
||||
art_phase := add_field(p, "Art", .SELECT, .EPICS);
|
||||
for phase_names add_option(p, art_phase, it, phase_colors[it_index]);
|
||||
code_phase := add_field(p, "Code", .SELECT, .EPICS);
|
||||
for phase_names add_option(p, code_phase, it, phase_colors[it_index]);
|
||||
audio_phase := add_field(p, "Audio", .SELECT, .EPICS);
|
||||
for phase_names add_option(p, audio_phase, it, phase_colors[it_index]);
|
||||
|
||||
// Epics and tasks.
|
||||
level := create_task(p, "Level 1: Forest", is_epic = true);
|
||||
set_sel(level, status.id, in_progress);
|
||||
combat := create_task(p, "Combat system", is_epic = true);
|
||||
set_sel(combat, status.id, todo);
|
||||
|
||||
add :: (title: string, epic: *Task, st: u32, tag: u32, prio: u32 = 0, hours := "") #expand {
|
||||
t := create_task(`p, title);
|
||||
t.epic = epic.id;
|
||||
set_sel(t, `status.id, st);
|
||||
toggle_tag(t, `discipline.id, tag);
|
||||
if prio set_sel(t, `priority.id, prio);
|
||||
if hours.count {
|
||||
v := value_for_edit(t, `estimate.id);
|
||||
v.text = copy_string(hours);
|
||||
v.num = string_to_float(hours);
|
||||
}
|
||||
}
|
||||
add("Block out level layout", level, done, design, medium, "6");
|
||||
add("Tree and rock props", level, in_progress, art, medium, "12");
|
||||
add("Forest ground textures", level, todo, art, 0, "8");
|
||||
add("Ambient forest soundscape", level, backlog, audio, 0, "4");
|
||||
add("Level streaming triggers", level, review, code, high, "5");
|
||||
add("Melee hit detection", combat, in_progress, code, high, "10");
|
||||
add("Enemy attack animations", combat, todo, art, medium, "16");
|
||||
add("Combo input buffering", combat, backlog, code, 0, "6");
|
||||
add("Hit sound effects", combat, todo, audio, 0, "3");
|
||||
|
||||
// Views.
|
||||
board := new_view(p, "Board", .KANBAN);
|
||||
board.group_field = status.id;
|
||||
board.check_field = status.id;
|
||||
board.check_value = done;
|
||||
array_add(*board.card_fields, discipline.id);
|
||||
array_add(*board.card_fields, priority.id);
|
||||
array_add(*board.card_fields, estimate.id);
|
||||
array_add(*board.card_fields, assignee.id);
|
||||
|
||||
art_list := new_view(p, "Art checklist", .CHECKLIST);
|
||||
art_list.group_field = status.id;
|
||||
art_list.check_field = status.id;
|
||||
art_list.check_value = done;
|
||||
art_list.group_by_epic = true;
|
||||
art_list.filter_field = discipline.id;
|
||||
art_list.filter_option = art;
|
||||
array_add(*art_list.card_fields, status.id);
|
||||
|
||||
epics := new_view(p, "Epics", .KANBAN);
|
||||
epics.items = .EPICS;
|
||||
epics.group_field = status.id;
|
||||
epics.check_field = status.id;
|
||||
epics.check_value = done;
|
||||
array_add(*epics.card_fields, art_phase.id);
|
||||
array_add(*epics.card_fields, code_phase.id);
|
||||
array_add(*epics.card_fields, audio_phase.id);
|
||||
|
||||
// Epic rules: the epic's Art/Code/Audio fields follow the status of tasks with that discipline,
|
||||
// and the epic's own Status follows all of its tasks.
|
||||
rule :: (target: *Field, value: u32, quant: Quantifier, filter_option: u32, op: Compare, cond_value: u32) #expand {
|
||||
r := add_rule(`p);
|
||||
r.target_field = target.id;
|
||||
r.target_value = value;
|
||||
r.quant = quant;
|
||||
r.filter_field = ifx filter_option then `discipline.id else 0;
|
||||
r.filter_option = filter_option;
|
||||
r.cond_field = `status.id;
|
||||
r.cond_op = op;
|
||||
r.cond_value = cond_value;
|
||||
}
|
||||
rule(status, done, .ALL, 0, .IS, done);
|
||||
rule(status, in_progress, .ANY, 0, .IS_NOT, backlog);
|
||||
|
||||
phases: [3] *Field;
|
||||
phases[0], phases[1], phases[2] = art_phase, code_phase, audio_phase;
|
||||
tags := u32.[art, code, audio];
|
||||
for target, index: phases {
|
||||
tag := tags[index];
|
||||
rule(target, target.options[2].id, .ALL, tag, .IS, done);
|
||||
rule(target, target.options[1].id, .ANY, tag, .IS_NOT, backlog);
|
||||
rule(target, target.options[0].id, .NONE, tag, .IS_NOT, backlog);
|
||||
}
|
||||
|
||||
apply_epic_rules(p);
|
||||
return p;
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
// Client side of server mode: sign in, and keep the project in sync with the server.
|
||||
//
|
||||
// We keep `base`: the records (key -> line) as the server last confirmed them. Every sync we serialize the
|
||||
// live project, send the records that differ from base (puts) and the keys that disappeared (dels), and get
|
||||
// back everything other people changed since our last revision. Remote changes are applied in place, so UI
|
||||
// state (selection, focused text field) survives.
|
||||
|
||||
Http_Tag :: enum s32 {
|
||||
SYNC :: 0;
|
||||
LOGIN :: 1;
|
||||
USERS :: 2;
|
||||
LOGOUT :: 3;
|
||||
}
|
||||
|
||||
SYNC_INTERVAL :: 2.0; // Seconds between polls when nothing changed locally.
|
||||
SYNC_DEBOUNCE :: 0.3; // Wait this long after a local change before sending it.
|
||||
|
||||
Sync_State :: struct {
|
||||
enabled: bool; // Running against a server.
|
||||
token: string;
|
||||
logged_in: bool;
|
||||
initial_done: bool; // The first full download has arrived.
|
||||
|
||||
rev: s64;
|
||||
users_rev: s64;
|
||||
base: Table(string, Base_Record);
|
||||
|
||||
in_flight: [4] bool; // Per Http_Tag.
|
||||
sent: [..] Sent_Record;
|
||||
last_sync: float64;
|
||||
offline: bool;
|
||||
|
||||
// Sign-in screen.
|
||||
login_name: string;
|
||||
login_password: string;
|
||||
login_error: string;
|
||||
|
||||
// Users page.
|
||||
users_message: string;
|
||||
users_error: bool;
|
||||
}
|
||||
|
||||
// The table key points into this record's own `key` allocation.
|
||||
Base_Record :: struct {
|
||||
key: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
Sent_Record :: struct {
|
||||
key: string;
|
||||
line: string; // Empty for a delete.
|
||||
}
|
||||
|
||||
sync: Sync_State;
|
||||
|
||||
sync_start :: (has_server: bool) {
|
||||
sync.enabled = has_server;
|
||||
if !has_server return;
|
||||
sync.token = platform_load_session();
|
||||
sync.logged_in = sync.token.count > 0;
|
||||
sync.last_sync = -100; // Sync immediately on start.
|
||||
}
|
||||
|
||||
reset_sync_state :: () {
|
||||
for sync.base {
|
||||
free(it.key);
|
||||
free(it.line);
|
||||
}
|
||||
table_reset(*sync.base);
|
||||
sync.rev = 0;
|
||||
sync.users_rev = 0;
|
||||
sync.initial_done = false;
|
||||
// Start from an empty project; everything comes from the server. No ids until the server hands out a block.
|
||||
p := New(Project);
|
||||
p.next_id = 1;
|
||||
p.id_limit = 1;
|
||||
app.project = p;
|
||||
app.selected = 0;
|
||||
app.dirty = false;
|
||||
}
|
||||
|
||||
sign_in :: () {
|
||||
if sync.in_flight[Http_Tag.LOGIN] return;
|
||||
set_string(*sync.login_error, "");
|
||||
body := tprint("% %", quoted(sync.login_name), quoted(sync.login_password));
|
||||
sync.in_flight[Http_Tag.LOGIN] = true;
|
||||
platform_http_post(.LOGIN, "/api/login", body, "");
|
||||
}
|
||||
|
||||
sign_out :: () {
|
||||
if sync.token.count platform_http_post(.LOGOUT, "/api/logout", "", sync.token);
|
||||
sync.logged_in = false;
|
||||
set_string(*sync.token, "");
|
||||
platform_save_session("");
|
||||
reset_sync_state();
|
||||
}
|
||||
|
||||
users_request :: (body: string) {
|
||||
if sync.in_flight[Http_Tag.USERS] return;
|
||||
sync.in_flight[Http_Tag.USERS] = true;
|
||||
platform_http_post(.USERS, "/api/users", body, sync.token);
|
||||
}
|
||||
|
||||
// Called once per frame by the app.
|
||||
sync_frame :: () {
|
||||
if !sync.enabled || !sync.logged_in || sync.in_flight[Http_Tag.SYNC] return;
|
||||
p := app.project;
|
||||
now := ui.time;
|
||||
due := now - sync.last_sync > SYNC_INTERVAL;
|
||||
if app.dirty && now - app.dirty_time > SYNC_DEBOUNCE due = true;
|
||||
if !due return;
|
||||
|
||||
b: String_Builder;
|
||||
b.allocator = temp;
|
||||
print_to_builder(*b, "since %\nusers_rev %\n", sync.rev, sync.users_rev);
|
||||
if !p.id_limit || p.id_limit < p.next_id + 200 append(*b, "ids\n");
|
||||
|
||||
sync.sent.count = 0;
|
||||
if sync.initial_done {
|
||||
lines: [..] string;
|
||||
lines.allocator = temp;
|
||||
project_records(p, *lines);
|
||||
current: Table(string, string);
|
||||
current.allocator = temp;
|
||||
for lines {
|
||||
key := record_key(it);
|
||||
table_set(*current, key, it);
|
||||
found, old := table_find(*sync.base, key);
|
||||
if !found || old.line != it {
|
||||
print_to_builder(*b, "put %\n", it);
|
||||
array_add(*sync.sent, .{copy_string(key), copy_string(it)});
|
||||
}
|
||||
}
|
||||
for value, key: sync.base {
|
||||
if !table_contains(*current, key) {
|
||||
print_to_builder(*b, "del %\n", key);
|
||||
array_add(*sync.sent, .{copy_string(key), ""});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.dirty = false;
|
||||
sync.last_sync = now;
|
||||
sync.in_flight[Http_Tag.SYNC] = true;
|
||||
platform_http_post(.SYNC, "/api/sync", builder_to_string(*b,, allocator = temp), sync.token);
|
||||
}
|
||||
|
||||
// Called by the platform layer when a request finishes. status 0 means the network request failed.
|
||||
http_on_response :: (tag: Http_Tag, status: int, body: string) {
|
||||
sync.in_flight[tag] = false;
|
||||
ui.wants_frame = true;
|
||||
if tag == {
|
||||
case .SYNC; on_sync_response(status, body);
|
||||
case .LOGIN; on_login_response(status, body);
|
||||
case .USERS; on_users_response(status, body);
|
||||
case .LOGOUT;
|
||||
}
|
||||
}
|
||||
|
||||
on_login_response :: (status: int, body: string) {
|
||||
if status == 200 {
|
||||
r := Line_Reader.{line = body};
|
||||
next_token(*r);
|
||||
token := next_token(*r);
|
||||
set_string(*sync.token, trim(token));
|
||||
platform_save_session(sync.token);
|
||||
sync.logged_in = true;
|
||||
set_string(*sync.login_password, "");
|
||||
reset_sync_state();
|
||||
sync.last_sync = -100;
|
||||
} else if status == 0 {
|
||||
set_string(*sync.login_error, "Can't reach the server.");
|
||||
} else {
|
||||
set_string(*sync.login_error, response_error(body, "Sign-in failed."));
|
||||
}
|
||||
}
|
||||
|
||||
on_users_response :: (status: int, body: string) {
|
||||
sync.users_error = status != 200;
|
||||
if status == 200 {
|
||||
set_string(*sync.users_message, "Saved.");
|
||||
sync.last_sync = -100; // Fetch the updated user list now.
|
||||
} else if status == 0 {
|
||||
set_string(*sync.users_message, "Can't reach the server.");
|
||||
} else {
|
||||
set_string(*sync.users_message, response_error(body, "That didn't work."));
|
||||
}
|
||||
}
|
||||
|
||||
response_error :: (body: string, fallback: string) -> string {
|
||||
r := Line_Reader.{line = body};
|
||||
if next_token(*r) == "error" {
|
||||
message := next_token(*r);
|
||||
if message.count return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
on_sync_response :: (status: int, body: string) {
|
||||
p := app.project;
|
||||
if status == 401 {
|
||||
for sync.sent { free(it.key); free(it.line); }
|
||||
sync.sent.count = 0;
|
||||
sync.logged_in = false;
|
||||
set_string(*sync.token, "");
|
||||
platform_save_session("");
|
||||
set_string(*sync.login_error, "Your session ended. Please sign in again.");
|
||||
reset_sync_state();
|
||||
return;
|
||||
}
|
||||
if status != 200 {
|
||||
// Nothing was applied; base is unchanged, so the same changes are sent again next time.
|
||||
sync.offline = true;
|
||||
for sync.sent { free(it.key); free(it.line); }
|
||||
sync.sent.count = 0;
|
||||
return;
|
||||
}
|
||||
sync.offline = false;
|
||||
|
||||
// What we sent is now the server's state.
|
||||
sent_keys: Table(string, bool);
|
||||
sent_keys.allocator = temp;
|
||||
for sync.sent {
|
||||
table_set(*sent_keys, it.key, true);
|
||||
base_set(it.key, it.line);
|
||||
free(it.key);
|
||||
if it.line.count free(it.line);
|
||||
}
|
||||
sync.sent.count = 0;
|
||||
|
||||
puts: [..] string;
|
||||
puts.allocator = temp;
|
||||
dels: [..] string;
|
||||
dels.allocator = temp;
|
||||
users_changed := false;
|
||||
|
||||
text := body;
|
||||
while text.count {
|
||||
end := find_index_from_left(text, #char "\n");
|
||||
if end < 0 end = text.count;
|
||||
line := substring(text, 0, end);
|
||||
advance(*text, min(end + 1, text.count));
|
||||
if begins_with(line, "put ") {
|
||||
record := substring(line, 4, line.count);
|
||||
if !table_contains(*sent_keys, record_key(record)) array_add(*puts, record);
|
||||
} else if begins_with(line, "del ") {
|
||||
key := substring(line, 4, line.count);
|
||||
if !table_contains(*sent_keys, key) array_add(*dels, key);
|
||||
} else {
|
||||
r := Line_Reader.{line = line};
|
||||
kind := next_token(*r);
|
||||
if kind == {
|
||||
case "rev";
|
||||
sync.rev = string_to_int(next_token(*r));
|
||||
case "ids";
|
||||
start := next_u32(*r);
|
||||
count := next_u32(*r);
|
||||
p.next_id = start;
|
||||
p.id_limit = start + count;
|
||||
case "me";
|
||||
p.me = next_u32(*r);
|
||||
p.me_admin = next_u32(*r) != 0;
|
||||
case "users_rev";
|
||||
sync.users_rev = string_to_int(next_token(*r));
|
||||
if !users_changed {
|
||||
users_changed = true;
|
||||
for p.users { free(it.username); free(it.name); }
|
||||
p.users.count = 0;
|
||||
}
|
||||
case "user";
|
||||
u: User_Info;
|
||||
u.id = next_u32(*r);
|
||||
u.username = copy_string(next_token(*r));
|
||||
u.name = copy_string(next_token(*r));
|
||||
u.is_admin = next_u32(*r) != 0;
|
||||
u.active = next_u32(*r) != 0;
|
||||
array_add(*p.users, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entities before the values that reference them.
|
||||
for puts if !begins_with(it, "val ") apply_record(p, it);
|
||||
for puts if begins_with(it, "val ") apply_record(p, it);
|
||||
for dels delete_record(p, it);
|
||||
if puts.count || dels.count {
|
||||
sort_project(p);
|
||||
// Record base in our own canonical formatting, so it compares equal to what we'd serialize.
|
||||
lines: [..] string;
|
||||
lines.allocator = temp;
|
||||
project_records(p, *lines);
|
||||
current: Table(string, string);
|
||||
current.allocator = temp;
|
||||
for lines table_set(*current, record_key(it), it);
|
||||
for puts {
|
||||
key := record_key(it);
|
||||
found, line := table_find(*current, key);
|
||||
base_set(key, ifx found then line else "");
|
||||
}
|
||||
for dels base_set(it, "");
|
||||
}
|
||||
|
||||
if !sync.initial_done {
|
||||
sync.initial_done = true;
|
||||
if !find_view(p, app.view_id) app.view_id = ifx p.views.count then p.views[0].id else 0;
|
||||
}
|
||||
}
|
||||
|
||||
base_set :: (key: string, line: string) {
|
||||
existing := table_find_pointer(*sync.base, key);
|
||||
if line.count {
|
||||
if existing {
|
||||
free(existing.line);
|
||||
existing.line = copy_string(line);
|
||||
} else {
|
||||
record: Base_Record;
|
||||
record.key = copy_string(key);
|
||||
record.line = copy_string(line);
|
||||
table_set(*sync.base, record.key, record);
|
||||
}
|
||||
} else if existing {
|
||||
old := existing.*;
|
||||
table_remove(*sync.base, key);
|
||||
free(old.key);
|
||||
free(old.line);
|
||||
}
|
||||
}
|
||||
+842
@@ -0,0 +1,842 @@
|
||||
// A small immediate-mode UI toolkit on top of render.jai.
|
||||
|
||||
COL_BG :: #run hex(0x111318);
|
||||
COL_SIDEBAR :: #run hex(0x171a20);
|
||||
COL_PANEL :: #run hex(0x1b1f26);
|
||||
COL_COLUMN :: #run hex(0x171a20);
|
||||
COL_CARD :: #run hex(0x232831);
|
||||
COL_CARD_HOVER :: #run hex(0x2a303a);
|
||||
COL_BORDER :: #run hex(0x2e343e);
|
||||
COL_INPUT :: #run hex(0x13161b);
|
||||
COL_TEXT :: #run hex(0xE6EDF3);
|
||||
COL_TEXT_DIM :: #run hex(0x9DA7B3);
|
||||
COL_TEXT_FAINT :: #run hex(0x6E7781);
|
||||
COL_ACCENT :: #run hex(0x4F8CFF);
|
||||
COL_DANGER :: #run hex(0xF85149);
|
||||
COL_HOVER :: #run hex(0xFFFFFF, 0.06);
|
||||
COL_SELECTED :: #run hex(0x4F8CFF, 0.20);
|
||||
COL_EPIC :: #run hex(0xBC8CFF);
|
||||
|
||||
RADIUS :: 6.0;
|
||||
|
||||
Popup_Item :: struct {
|
||||
label: string;
|
||||
color: u32; // 0xRRGGBB swatch, used if has_color.
|
||||
has_color: bool;
|
||||
checked: bool; // Shows a check mark (multi-select, or current value).
|
||||
}
|
||||
|
||||
Popup :: struct {
|
||||
id: u64;
|
||||
anchor: Rect;
|
||||
rect: Rect;
|
||||
items: [..] Popup_Item;
|
||||
multi: bool;
|
||||
result: int = -1;
|
||||
scroll: float;
|
||||
touched: bool;
|
||||
}
|
||||
|
||||
Scroll_State :: struct {
|
||||
id: u64;
|
||||
offset: float;
|
||||
content: float;
|
||||
}
|
||||
|
||||
UI_State :: struct {
|
||||
input: *Frame_Input;
|
||||
mx, my: float;
|
||||
pressed, released, down: bool;
|
||||
time: float64;
|
||||
|
||||
hot, active, focus: u64;
|
||||
focus_claimed: bool;
|
||||
input_layer: s32;
|
||||
|
||||
cursor: int; // Byte offset of the caret in the focused text field.
|
||||
anchor: int; // Other end of the selection (== cursor when nothing is selected).
|
||||
click_count: int;
|
||||
last_click_id: u64;
|
||||
last_click_time: float64;
|
||||
last_click_x, last_click_y: float;
|
||||
edit_scroll: float;
|
||||
blink_start: float64;
|
||||
|
||||
popup: Popup;
|
||||
scrolls: [..] Scroll_State;
|
||||
confirm_id: u64;
|
||||
|
||||
cursor_kind: Cursor_Kind;
|
||||
wants_frame: bool;
|
||||
}
|
||||
|
||||
ui: UI_State;
|
||||
|
||||
//
|
||||
// Ids
|
||||
//
|
||||
|
||||
ui_id :: (s: string, salt: int = 0) -> u64 {
|
||||
h: u64 = 14695981039346656037;
|
||||
for i: 0..s.count-1 {
|
||||
h ^= s[i];
|
||||
h *= 1099511628211;
|
||||
}
|
||||
h ^= cast(u64) salt * 0x9E3779B97F4A7C15;
|
||||
h *= 1099511628211;
|
||||
return h | 1;
|
||||
}
|
||||
|
||||
ui_id :: (p: *void, salt: int = 0) -> u64 {
|
||||
h := cast(u64) p;
|
||||
h ^= cast(u64) salt * 0x9E3779B97F4A7C15;
|
||||
h *= 1099511628211;
|
||||
return h | 1;
|
||||
}
|
||||
|
||||
//
|
||||
// Frame
|
||||
//
|
||||
|
||||
ui_begin_frame :: (input: *Frame_Input) {
|
||||
ui.input = input;
|
||||
ui.mx = input.mouse_x;
|
||||
ui.my = input.mouse_y;
|
||||
ui.pressed = input.mouse_pressed[MOUSE_LEFT];
|
||||
ui.released = input.mouse_released[MOUSE_LEFT];
|
||||
ui.down = input.mouse_down[MOUSE_LEFT];
|
||||
ui.time = input.time;
|
||||
ui.hot = 0;
|
||||
ui.focus_claimed = false;
|
||||
ui.cursor_kind = .ARROW;
|
||||
ui.wants_frame = input.keys.count > 0 || input.chars.count > 0 || ui.pressed || ui.released;
|
||||
|
||||
ui.input_layer = LAYER_BASE;
|
||||
if ui.popup.id {
|
||||
ui.input_layer = LAYER_POPUP;
|
||||
if ui.pressed && !contains(ui.popup.rect, ui.mx, ui.my) {
|
||||
close_popup();
|
||||
ui.pressed = false; // The click that closes a popup does nothing else.
|
||||
}
|
||||
ui.popup.touched = false;
|
||||
}
|
||||
}
|
||||
|
||||
ui_end_frame :: () {
|
||||
if ui.popup.id {
|
||||
if ui.popup.touched draw_popup();
|
||||
else close_popup();
|
||||
}
|
||||
if ui.released ui.active = 0;
|
||||
if ui.pressed && !ui.focus_claimed ui.focus = 0;
|
||||
platform_set_cursor(ui.cursor_kind);
|
||||
}
|
||||
|
||||
ui_hover :: (r: Rect) -> bool {
|
||||
if renderer.layer != ui.input_layer return false;
|
||||
if !contains(r, ui.mx, ui.my) return false;
|
||||
return contains(current_clip(), ui.mx, ui.my);
|
||||
}
|
||||
|
||||
// Press-then-release-inside button logic. Returns clicked, hovered, held.
|
||||
button_behavior :: (id: u64, r: Rect) -> bool, bool, bool {
|
||||
hovered := ui_hover(r);
|
||||
if hovered {
|
||||
ui.hot = id;
|
||||
ui.cursor_kind = .HAND;
|
||||
if ui.pressed && ui.active == 0 ui.active = id;
|
||||
}
|
||||
clicked := ui.released && ui.active == id && hovered;
|
||||
return clicked, hovered, ui.active == id;
|
||||
}
|
||||
|
||||
//
|
||||
// Widgets
|
||||
//
|
||||
|
||||
Button_Style :: enum {
|
||||
NORMAL;
|
||||
PRIMARY;
|
||||
GHOST;
|
||||
DANGER;
|
||||
}
|
||||
|
||||
button :: (r: Rect, label: string, style := Button_Style.NORMAL, selected := false, size := Font_Size.NORMAL, id: u64 = 0) -> bool {
|
||||
if !id id = ui_id(label, cast(int) (r.x * 7919 + r.y * 104729));
|
||||
clicked, hovered, held := button_behavior(id, r);
|
||||
|
||||
bg: u32;
|
||||
fg := COL_TEXT;
|
||||
if style == {
|
||||
case .NORMAL; bg = ifx hovered then hex(0x343b47) else hex(0x2a303a);
|
||||
case .PRIMARY; bg = ifx hovered then hex(0x6aa0ff) else COL_ACCENT; fg = hex(0xFFFFFF);
|
||||
case .GHOST; bg = ifx hovered then COL_HOVER else 0; fg = ifx selected || hovered then COL_TEXT else COL_TEXT_DIM;
|
||||
case .DANGER; bg = ifx hovered then hex(0xF85149, 0.30) else hex(0xF85149, 0.16); fg = hex(0xFF8A84);
|
||||
}
|
||||
if selected bg = COL_SELECTED;
|
||||
if held && hovered bg = mix_color(bg, hex(0x000000), 0.15);
|
||||
draw_rect(r, bg, RADIUS);
|
||||
|
||||
if style == .GHOST {
|
||||
draw_text_in_rect(label, inset(r, 10, 0), size, fg);
|
||||
} else {
|
||||
draw_text_centered(fit_text(label, r.w - 12, size), r, size, fg);
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
// A square icon button; draw_icon is called with the icon rect and the color to use.
|
||||
Icon :: enum {
|
||||
CLOSE;
|
||||
PLUS;
|
||||
UP;
|
||||
DOWN;
|
||||
TRASH;
|
||||
}
|
||||
|
||||
icon_button :: (r: Rect, icon: Icon, id: u64, tint: u32 = 0) -> bool {
|
||||
clicked, hovered := button_behavior(id, r);
|
||||
if hovered draw_rect(r, COL_HOVER, RADIUS);
|
||||
color := ifx tint then tint else (ifx hovered then COL_TEXT else COL_TEXT_DIM);
|
||||
cx := r.x + r.w * 0.5;
|
||||
cy := r.y + r.h * 0.5;
|
||||
if icon == {
|
||||
case .CLOSE; draw_cross(cx, cy, 4.5, color);
|
||||
case .PLUS; draw_plus(cx, cy, 5, color);
|
||||
case .UP;
|
||||
draw_line(cx - 4.5, cy + 2.5, cx, cy - 2.5, 1.5, color);
|
||||
draw_line(cx, cy - 2.5, cx + 4.5, cy + 2.5, 1.5, color);
|
||||
case .DOWN; draw_chevron_down(cx, cy, 4.5, color);
|
||||
case .TRASH;
|
||||
draw_rect(make_rect(cx - 5, cy - 4, 10, 1.5), color);
|
||||
draw_rect(make_rect(cx - 2, cy - 6, 4, 2), color);
|
||||
draw_rect_outline(make_rect(cx - 4, cy - 2.5, 8, 9), color, 1.5, 1.5);
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
// A delete button that needs two clicks.
|
||||
confirm_button :: (r: Rect, label: string, id: u64, confirm_label := "Really?") -> bool {
|
||||
armed := ui.confirm_id == id;
|
||||
if armed && ui.pressed && !ui_hover(r) {
|
||||
ui.confirm_id = 0;
|
||||
armed = false;
|
||||
}
|
||||
if button(r, ifx armed then confirm_label else label, ifx armed then .DANGER else .NORMAL, id = id) {
|
||||
if armed {
|
||||
ui.confirm_id = 0;
|
||||
return true;
|
||||
}
|
||||
ui.confirm_id = id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
checkbox :: (r: Rect, checked: bool, id: u64) -> bool {
|
||||
clicked, hovered := button_behavior(id, r);
|
||||
if checked {
|
||||
draw_rect(r, ifx hovered then hex(0x6aa0ff) else COL_ACCENT, 4);
|
||||
draw_check_mark(r, hex(0xFFFFFF));
|
||||
} else {
|
||||
draw_rect(r, COL_INPUT, 4);
|
||||
draw_rect_outline(r, ifx hovered then COL_ACCENT else hex(0x4a5260), 1.5, 4);
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
segmented :: (r: Rect, labels: [] string, selected: int, id: u64) -> int {
|
||||
draw_rect(r, COL_INPUT, RADIUS);
|
||||
w := r.w / labels.count;
|
||||
result := selected;
|
||||
for labels {
|
||||
cell := make_rect(r.x + it_index * w, r.y, w, r.h);
|
||||
clicked, hovered := button_behavior(id + cast(u64) it_index * 2, inset(cell, 2));
|
||||
if it_index == selected draw_rect(inset(cell, 2), hex(0x343b47), RADIUS - 1);
|
||||
else if hovered draw_rect(inset(cell, 2), COL_HOVER, RADIUS - 1);
|
||||
draw_text_centered(it, cell, .NORMAL, ifx it_index == selected then COL_TEXT else COL_TEXT_DIM);
|
||||
if clicked result = it_index;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
chip_width :: (label: string, size := Font_Size.SMALL) -> float {
|
||||
return measure_text(label, size) + 14;
|
||||
}
|
||||
|
||||
chip :: (x: float, y: float, label: string, color: u32, size := Font_Size.SMALL) -> float {
|
||||
w := chip_width(label, size);
|
||||
h := line_height(size) + 4;
|
||||
r := make_rect(x, y, w, h);
|
||||
draw_rect(r, hex(color, 0.18), h * 0.5);
|
||||
draw_text(label, x + 7, y + 2, size, mix_color(hex(color), hex(0xFFFFFF), 0.35));
|
||||
return w;
|
||||
}
|
||||
|
||||
chip_height :: (size := Font_Size.SMALL) -> float {
|
||||
return line_height(size) + 4;
|
||||
}
|
||||
|
||||
color_dot :: (cx: float, cy: float, color: u32, radius := 4.0) {
|
||||
draw_rect(make_rect(cx - radius, cy - radius, radius * 2, radius * 2), hex(color), radius);
|
||||
}
|
||||
|
||||
//
|
||||
// Dropdowns. The popup is drawn at the end of the frame on top of everything; a pick made there is
|
||||
// returned by the next call to dropdown() with the same id.
|
||||
//
|
||||
|
||||
dropdown :: (r: Rect, label: string, items: [] Popup_Item, id: u64, multi := false, swatch: u32 = 0, has_swatch := false, flat := false) -> int {
|
||||
result := -1;
|
||||
if ui.popup.id == id {
|
||||
ui.popup.touched = true;
|
||||
if ui.popup.result >= 0 {
|
||||
result = ui.popup.result;
|
||||
ui.popup.result = -1;
|
||||
if !multi close_popup();
|
||||
}
|
||||
ui.popup.items.count = 0;
|
||||
for items array_add(*ui.popup.items, it);
|
||||
ui.popup.anchor = r;
|
||||
ui.popup.multi = multi;
|
||||
}
|
||||
|
||||
clicked, hovered := button_behavior(id, r);
|
||||
open := ui.popup.id == id;
|
||||
if !flat || hovered || open {
|
||||
draw_rect(r, ifx flat then COL_HOVER else COL_INPUT, RADIUS);
|
||||
if !flat draw_rect_outline(r, ifx open || hovered then hex(0x4a5260) else COL_BORDER, 1, RADIUS);
|
||||
}
|
||||
text_x := r.x + 10;
|
||||
if has_swatch {
|
||||
color_dot(r.x + 14, r.y + r.h * 0.5, swatch);
|
||||
text_x += 14;
|
||||
}
|
||||
draw_text_in_rect(label, make_rect(text_x, r.y, r.x + r.w - 24 - text_x, r.h), .NORMAL, COL_TEXT);
|
||||
draw_chevron_down(r.x + r.w - 13, r.y + r.h * 0.5, 3.5, COL_TEXT_DIM);
|
||||
|
||||
if clicked {
|
||||
if open close_popup();
|
||||
else open_popup(id, r, items, multi);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
open_popup :: (id: u64, anchor: Rect, items: [] Popup_Item, multi: bool) {
|
||||
ui.popup.id = id;
|
||||
ui.popup.anchor = anchor;
|
||||
ui.popup.multi = multi;
|
||||
ui.popup.result = -1;
|
||||
ui.popup.scroll = 0;
|
||||
ui.popup.touched = true;
|
||||
ui.popup.items.count = 0;
|
||||
for items array_add(*ui.popup.items, it);
|
||||
layout_popup();
|
||||
}
|
||||
|
||||
close_popup :: () {
|
||||
ui.popup.id = 0;
|
||||
ui.popup.result = -1;
|
||||
ui.popup.items.count = 0;
|
||||
}
|
||||
|
||||
POPUP_ITEM_H :: 28.0;
|
||||
|
||||
layout_popup :: () {
|
||||
using ui.popup;
|
||||
w := anchor.w;
|
||||
for items w = max(w, measure_text(it.label, .NORMAL) + 56);
|
||||
h := min(items.count * POPUP_ITEM_H + 8, 340.0);
|
||||
x := anchor.x;
|
||||
y := anchor.y + anchor.h + 4;
|
||||
if y + h > renderer.height - 8 && anchor.y - h - 4 > 8 y = anchor.y - h - 4;
|
||||
x = clamp(x, 8.0, max(8.0, renderer.width - w - 8));
|
||||
y = clamp(y, 8.0, max(8.0, renderer.height - h - 8));
|
||||
rect = make_rect(x, y, w, h);
|
||||
}
|
||||
|
||||
draw_popup :: () {
|
||||
using ui.popup;
|
||||
layout_popup();
|
||||
set_layer(LAYER_POPUP);
|
||||
defer set_layer(LAYER_BASE);
|
||||
|
||||
draw_rect(make_rect(rect.x, rect.y + 4, rect.w, rect.h), hex(0x000000, 0.35), RADIUS + 2);
|
||||
draw_rect(rect, hex(0x262b34), RADIUS);
|
||||
draw_rect_outline(rect, hex(0x3a414d), 1, RADIUS);
|
||||
|
||||
content_h := items.count * POPUP_ITEM_H + 8;
|
||||
if contains(rect, ui.mx, ui.my) && ui.input.wheel_y != 0 {
|
||||
scroll += ui.input.wheel_y * 40;
|
||||
ui.input.wheel_y = 0;
|
||||
}
|
||||
scroll = clamp(scroll, 0, max(0.0, content_h - rect.h));
|
||||
|
||||
push_clip(inset(rect, 1));
|
||||
for items {
|
||||
row := make_rect(rect.x + 4, rect.y + 4 + it_index * POPUP_ITEM_H - scroll, rect.w - 8, POPUP_ITEM_H);
|
||||
if row.y + row.h < rect.y || row.y > rect.y + rect.h continue;
|
||||
hovered := ui_hover(row);
|
||||
if hovered {
|
||||
draw_rect(row, COL_HOVER, RADIUS - 2);
|
||||
ui.cursor_kind = .HAND;
|
||||
if ui.pressed {
|
||||
result = it_index;
|
||||
ui.pressed = false;
|
||||
ui.focus_claimed = true;
|
||||
ui.wants_frame = true;
|
||||
}
|
||||
}
|
||||
x := row.x + 8;
|
||||
if it.checked draw_check_mark(make_rect(x - 2, row.y + 6, 16, 16), COL_ACCENT);
|
||||
x += 18;
|
||||
if it.has_color {
|
||||
color_dot(x + 4, row.y + row.h * 0.5, it.color);
|
||||
x += 16;
|
||||
}
|
||||
draw_text_in_rect(it.label, make_rect(x, row.y, row.x + row.w - x - 6, row.h), .NORMAL, COL_TEXT);
|
||||
}
|
||||
pop_clip();
|
||||
}
|
||||
|
||||
//
|
||||
// Text editing. Edits the string in place (it must be heap allocated or empty).
|
||||
//
|
||||
|
||||
Edit_Style :: enum {
|
||||
BOXED;
|
||||
GHOST; // No box until hovered/focused; used for titles.
|
||||
}
|
||||
|
||||
EDIT_PAD_X :: 8.0;
|
||||
EDIT_PAD_Y :: 6.0;
|
||||
|
||||
text_edit_height :: (text: string, width: float, size := Font_Size.NORMAL, min_lines := 1) -> float {
|
||||
lines := max(cast(int) (measure_wrapped_height(text, width - 2 * EDIT_PAD_X, size) / line_height(size) + 0.5), min_lines);
|
||||
return lines * line_height(size) + 2 * EDIT_PAD_Y;
|
||||
}
|
||||
|
||||
text_edit :: (r: Rect, s: *string, id: u64, placeholder := "", multiline := false, size := Font_Size.NORMAL, style := Edit_Style.BOXED, text_color: u32 = 0, password := false) -> changed: bool, submitted: bool {
|
||||
changed, submitted := false, false;
|
||||
lh := line_height(size);
|
||||
inner := ifx multiline then inset(r, EDIT_PAD_X, EDIT_PAD_Y) else inset(r, EDIT_PAD_X, 0);
|
||||
if !text_color text_color = COL_TEXT;
|
||||
if password multiline = false;
|
||||
|
||||
lines: [..] Text_Line;
|
||||
lines.allocator = temp;
|
||||
layout_edit_lines(s.*, multiline, inner.w, size, *lines);
|
||||
|
||||
hovered := ui_hover(r);
|
||||
if hovered ui.cursor_kind = .TEXT;
|
||||
focused := ui.focus == id;
|
||||
shift_held := (ui.input.mods & MOD_SHIFT) != 0;
|
||||
|
||||
if hovered && ui.pressed && (ui.active == 0 || ui.active == id) {
|
||||
if !focused {
|
||||
ui.focus = id;
|
||||
ui.edit_scroll = 0;
|
||||
ui.anchor = -1;
|
||||
focused = true;
|
||||
}
|
||||
ui.active = id; // So dragging extends the selection.
|
||||
ui.focus_claimed = true;
|
||||
hit := edit_hit_test(s.*, multiline, lines, inner, size, ui.mx, ui.my, password);
|
||||
|
||||
// Double click selects a word, triple click everything.
|
||||
if ui.last_click_id == id && ui.time - ui.last_click_time < 0.4 && abs(ui.mx - ui.last_click_x) < 4 && abs(ui.my - ui.last_click_y) < 4 {
|
||||
ui.click_count += 1;
|
||||
} else {
|
||||
ui.click_count = 1;
|
||||
}
|
||||
ui.last_click_id, ui.last_click_time = id, ui.time;
|
||||
ui.last_click_x, ui.last_click_y = ui.mx, ui.my;
|
||||
|
||||
if ui.click_count == 2 && !password {
|
||||
ui.anchor = word_start(s.*, hit);
|
||||
ui.cursor = word_end(s.*, hit);
|
||||
} else if ui.click_count >= 3 {
|
||||
ui.anchor = 0;
|
||||
ui.cursor = s.count;
|
||||
} else {
|
||||
if !(shift_held && ui.anchor >= 0) ui.anchor = hit;
|
||||
ui.cursor = hit;
|
||||
}
|
||||
ui.blink_start = ui.time;
|
||||
} else if focused && ui.active == id && ui.down && ui.click_count == 1 {
|
||||
// Drag to select.
|
||||
ui.cursor = edit_hit_test(s.*, multiline, lines, inner, size, ui.mx, ui.my, password);
|
||||
}
|
||||
|
||||
if focused {
|
||||
ui.cursor = clamp(ui.cursor, 0, s.count);
|
||||
if ui.anchor < 0 || ui.anchor > s.count ui.anchor = ui.cursor;
|
||||
if ui.input.chars.count || ui.input.keys.count ui.blink_start = ui.time;
|
||||
|
||||
for c: ui.input.chars {
|
||||
ch := c;
|
||||
if ch == #char "\r" continue;
|
||||
if ch == #char "\n" && !multiline ch = #char " ";
|
||||
if ch < 32 && ch != #char "\n" continue;
|
||||
buffer: [4] u8;
|
||||
edit_replace_selection(s, encode_utf8(ch, *buffer));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for k: ui.input.keys {
|
||||
ctrl := (k.mods & MOD_CTRL) != 0;
|
||||
shift := (k.mods & MOD_SHIFT) != 0;
|
||||
has_selection := ui.anchor != ui.cursor;
|
||||
sel_start, sel_end := min(ui.anchor, ui.cursor), max(ui.anchor, ui.cursor);
|
||||
|
||||
if k.key == {
|
||||
case KEY_BACKSPACE;
|
||||
if has_selection {
|
||||
edit_replace_selection(s, "");
|
||||
changed = true;
|
||||
} else if ui.cursor > 0 {
|
||||
start := ifx ctrl then word_left(s.*, ui.cursor) else prev_char_start(s.*, ui.cursor);
|
||||
string_remove(s, start, ui.cursor - start);
|
||||
ui.cursor, ui.anchor = start, start;
|
||||
changed = true;
|
||||
}
|
||||
case KEY_DELETE;
|
||||
if has_selection {
|
||||
edit_replace_selection(s, "");
|
||||
changed = true;
|
||||
} else if ui.cursor < s.count {
|
||||
end := ifx ctrl then word_right(s.*, ui.cursor) else next_char_start(s.*, ui.cursor);
|
||||
string_remove(s, ui.cursor, end - ui.cursor);
|
||||
changed = true;
|
||||
}
|
||||
case KEY_LEFT;
|
||||
if has_selection && !shift edit_move(sel_start, false);
|
||||
else edit_move(ifx ctrl then word_left(s.*, ui.cursor) else prev_char_start(s.*, ui.cursor), shift);
|
||||
case KEY_RIGHT;
|
||||
if has_selection && !shift edit_move(sel_end, false);
|
||||
else edit_move(ifx ctrl then word_right(s.*, ui.cursor) else next_char_start(s.*, ui.cursor), shift);
|
||||
case KEY_HOME;
|
||||
layout_edit_lines(s.*, multiline, inner.w, size, *lines);
|
||||
edit_move(ifx ctrl then 0 else lines[cursor_line(lines, ui.cursor)].start, shift);
|
||||
case KEY_END;
|
||||
layout_edit_lines(s.*, multiline, inner.w, size, *lines);
|
||||
edit_move(ifx ctrl then s.count else lines[cursor_line(lines, ui.cursor)].end, shift);
|
||||
case KEY_UP; #through;
|
||||
case KEY_DOWN;
|
||||
if multiline {
|
||||
layout_edit_lines(s.*, multiline, inner.w, size, *lines);
|
||||
li := cursor_line(lines, ui.cursor);
|
||||
x := measure_text(substring(s.*, lines[li].start, ui.cursor), size);
|
||||
li += ifx k.key == KEY_UP then -1 else 1;
|
||||
target: int;
|
||||
if li < 0 target = 0;
|
||||
else if li >= lines.count target = s.count;
|
||||
else target = lines[li].start + hit_test_text(substring(s.*, lines[li].start, lines[li].end), x, size);
|
||||
edit_move(target, shift);
|
||||
}
|
||||
case KEY_ENTER;
|
||||
if multiline && !ctrl {
|
||||
edit_replace_selection(s, "\n");
|
||||
changed = true;
|
||||
} else {
|
||||
submitted = true;
|
||||
ui.focus = 0;
|
||||
}
|
||||
case KEY_ESCAPE; #through;
|
||||
case KEY_TAB;
|
||||
ui.focus = 0;
|
||||
case #char "A";
|
||||
if ctrl {
|
||||
ui.anchor = 0;
|
||||
ui.cursor = s.count;
|
||||
}
|
||||
case #char "V";
|
||||
if ctrl {
|
||||
pasted := platform_clipboard_get();
|
||||
if !multiline for i: 0..pasted.count-1 if pasted[i] == #char "\n" || pasted[i] == #char "\r" pasted[i] = #char " ";
|
||||
if pasted.count {
|
||||
edit_replace_selection(s, pasted);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
case #char "C";
|
||||
if ctrl && !password platform_clipboard_set(ifx has_selection then substring(s.*, sel_start, sel_end) else s.*);
|
||||
case #char "X";
|
||||
if ctrl && !password {
|
||||
if !has_selection {
|
||||
ui.anchor = 0;
|
||||
ui.cursor = s.count;
|
||||
sel_start, sel_end = 0, s.count;
|
||||
}
|
||||
platform_clipboard_set(copy_string(substring(s.*, sel_start, sel_end),, temp));
|
||||
edit_replace_selection(s, "");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keys were consumed by this field.
|
||||
ui.input.chars.count = 0;
|
||||
ui.input.keys.count = 0;
|
||||
if changed layout_edit_lines(s.*, multiline, inner.w, size, *lines);
|
||||
focused = ui.focus == id;
|
||||
}
|
||||
|
||||
// Drawing.
|
||||
if style == .BOXED {
|
||||
draw_rect(r, COL_INPUT, RADIUS);
|
||||
draw_rect_outline(r, ifx focused then COL_ACCENT else (ifx hovered then hex(0x4a5260) else COL_BORDER), 1, RADIUS);
|
||||
} else if focused {
|
||||
draw_rect(r, COL_INPUT, RADIUS);
|
||||
draw_rect_outline(r, COL_ACCENT, 1, RADIUS);
|
||||
} else if hovered {
|
||||
draw_rect(r, COL_HOVER, RADIUS);
|
||||
}
|
||||
|
||||
push_clip(make_rect(r.x + 2, r.y + 1, r.w - 4, r.h - 2));
|
||||
defer pop_clip();
|
||||
|
||||
sel_start, sel_end := 0, 0;
|
||||
if focused {
|
||||
sel_start, sel_end = min(ui.anchor, ui.cursor), max(ui.anchor, ui.cursor);
|
||||
}
|
||||
selection_color :: #run hex(0x4F8CFF, 0.35);
|
||||
|
||||
if !multiline {
|
||||
text_y := round_f(r.y + (r.h - lh) * 0.5);
|
||||
if focused {
|
||||
caret_x := edit_measure(s.*, 0, ui.cursor, size, password);
|
||||
if caret_x - ui.edit_scroll > inner.w - 2 ui.edit_scroll = caret_x - inner.w + 2;
|
||||
if caret_x - ui.edit_scroll < 0 ui.edit_scroll = caret_x;
|
||||
ui.edit_scroll = max(0.0, min(ui.edit_scroll, edit_measure(s.*, 0, s.count, size, password) - inner.w + 2));
|
||||
}
|
||||
scroll := ifx focused then ui.edit_scroll else 0.0;
|
||||
if sel_end > sel_start {
|
||||
x0 := edit_measure(s.*, 0, sel_start, size, password);
|
||||
x1 := edit_measure(s.*, 0, sel_end, size, password);
|
||||
draw_rect(make_rect(inner.x - scroll + x0, text_y, x1 - x0, lh), selection_color, 2);
|
||||
}
|
||||
if s.count draw_text(ifx password then masked(s.*) else s.*, inner.x - scroll, text_y, size, text_color);
|
||||
else if placeholder.count draw_text(placeholder, inner.x, text_y, size, COL_TEXT_FAINT);
|
||||
if focused draw_caret(inner.x - scroll + edit_measure(s.*, 0, ui.cursor, size, password), text_y, lh);
|
||||
} else {
|
||||
for lines {
|
||||
if sel_end > sel_start && sel_start <= it.end && sel_end >= it.start {
|
||||
a := max(sel_start, it.start);
|
||||
b := min(sel_end, it.end);
|
||||
x0 := measure_text(substring(s.*, it.start, a), size);
|
||||
x1 := measure_text(substring(s.*, it.start, b), size);
|
||||
if sel_end > it.end x1 += 4; // Show that the line break is selected too.
|
||||
draw_rect(make_rect(inner.x + x0, inner.y + it_index * lh, x1 - x0, lh), selection_color, 2);
|
||||
}
|
||||
}
|
||||
if s.count {
|
||||
for lines draw_text(substring(s.*, it.start, it.end), inner.x, inner.y + it_index * lh, size, text_color);
|
||||
} else if placeholder.count {
|
||||
draw_text(placeholder, inner.x, inner.y, size, COL_TEXT_FAINT);
|
||||
}
|
||||
if focused {
|
||||
li := cursor_line(lines, ui.cursor);
|
||||
x := inner.x + measure_text(substring(s.*, lines[li].start, ui.cursor), size);
|
||||
draw_caret(x, inner.y + li * lh, lh);
|
||||
}
|
||||
}
|
||||
return changed, submitted;
|
||||
}
|
||||
|
||||
// Gives a text field keyboard focus from code, optionally selecting its whole content.
|
||||
focus_text :: (id: u64, text: string, select_all := false) {
|
||||
ui.focus = id;
|
||||
ui.cursor = text.count;
|
||||
ui.anchor = ifx select_all then 0 else text.count;
|
||||
ui.edit_scroll = 0;
|
||||
ui.focus_claimed = true;
|
||||
}
|
||||
|
||||
edit_move :: (position: int, extend: bool) {
|
||||
ui.cursor = position;
|
||||
if !extend ui.anchor = position;
|
||||
}
|
||||
|
||||
edit_replace_selection :: (s: *string, text: string) {
|
||||
a, b := min(ui.anchor, ui.cursor), max(ui.anchor, ui.cursor);
|
||||
if b > a string_remove(s, a, b - a);
|
||||
ui.cursor = a;
|
||||
string_insert(s, a, text);
|
||||
ui.cursor += text.count;
|
||||
ui.anchor = ui.cursor;
|
||||
}
|
||||
|
||||
layout_edit_lines :: (s: string, multiline: bool, width: float, size: Font_Size, lines: *[..] Text_Line) {
|
||||
if multiline {
|
||||
wrap_text(s, width, size, lines);
|
||||
} else {
|
||||
lines.count = 0;
|
||||
array_add(lines, .{0, s.count});
|
||||
}
|
||||
}
|
||||
|
||||
PASSWORD_DOT :: "*";
|
||||
|
||||
masked :: (s: string) -> string {
|
||||
n := 0;
|
||||
i := 0;
|
||||
while i < s.count { i = next_char_start(s, i); n += 1; }
|
||||
result := alloc_string(n,, temp);
|
||||
for 0..n-1 result[it] = PASSWORD_DOT[0];
|
||||
return result;
|
||||
}
|
||||
|
||||
// Width of s[start..end] as displayed (password fields show one dot per character).
|
||||
edit_measure :: (s: string, start: int, end: int, size: Font_Size, password: bool) -> float {
|
||||
if !password return measure_text(substring(s, start, end), size);
|
||||
return measure_text(masked(substring(s, start, end)), size);
|
||||
}
|
||||
|
||||
edit_hit_test :: (s: string, multiline: bool, lines: [] Text_Line, inner: Rect, size: Font_Size, mx: float, my: float, password := false) -> int {
|
||||
if password {
|
||||
dot := measure_text(PASSWORD_DOT, size);
|
||||
n := cast(int) floor_f((mx - inner.x + ui.edit_scroll) / dot + 0.5);
|
||||
i := 0;
|
||||
while n > 0 && i < s.count { i = next_char_start(s, i); n -= 1; }
|
||||
return i;
|
||||
}
|
||||
if !multiline return hit_test_text(s, mx - inner.x + ui.edit_scroll, size);
|
||||
li := clamp(cast(int) floor_f((my - inner.y) / line_height(size)), 0, lines.count - 1);
|
||||
line := lines[li];
|
||||
return line.start + hit_test_text(substring(s, line.start, line.end), mx - inner.x, size);
|
||||
}
|
||||
|
||||
draw_caret :: (x: float, y: float, h: float) {
|
||||
t := ui.time - ui.blink_start;
|
||||
if t - floor_f(cast(float) t) < 0.55 draw_rect(make_rect(x, y, 1.5, h), COL_TEXT);
|
||||
}
|
||||
|
||||
cursor_line :: (lines: [] Text_Line, cursor: int) -> int {
|
||||
result := 0;
|
||||
for lines if it.start <= cursor && cursor <= it.end result = it_index;
|
||||
return result;
|
||||
}
|
||||
|
||||
is_word_char :: (c: u8) -> bool {
|
||||
return !is_space(c) && !(c == #char "." || c == #char "," || c == #char ";" || c == #char ":" || c == #char "!" || c == #char "?" ||
|
||||
c == #char "(" || c == #char ")" || c == #char "\"" || c == #char "'" || c == #char "/" || c == #char "-");
|
||||
}
|
||||
|
||||
word_start :: (s: string, i: int) -> int {
|
||||
while i > 0 && is_word_char(s[i - 1]) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
word_end :: (s: string, i: int) -> int {
|
||||
while i < s.count && is_word_char(s[i]) i += 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
word_left :: (s: string, i: int) -> int {
|
||||
while i > 0 && is_space(s[i - 1]) i -= 1;
|
||||
while i > 0 && !is_space(s[i - 1]) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
word_right :: (s: string, i: int) -> int {
|
||||
while i < s.count && is_space(s[i]) i += 1;
|
||||
while i < s.count && !is_space(s[i]) i += 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
//
|
||||
// Scroll regions.
|
||||
//
|
||||
|
||||
get_scroll :: (id: u64) -> *Scroll_State {
|
||||
for * ui.scrolls if it.id == id return it;
|
||||
s: Scroll_State;
|
||||
s.id = id;
|
||||
array_add(*ui.scrolls, s);
|
||||
return *ui.scrolls[ui.scrolls.count - 1];
|
||||
}
|
||||
|
||||
// Begins a clipped region; returns the current scroll offset. Pair with scroll_end().
|
||||
scroll_begin :: (r: Rect, id: u64, horizontal := false) -> float {
|
||||
s := get_scroll(id);
|
||||
view := ifx horizontal then r.w else r.h;
|
||||
if ui_hover(r) {
|
||||
shift := (ui.input.mods & MOD_SHIFT) != 0;
|
||||
delta: float;
|
||||
if horizontal {
|
||||
delta = ui.input.wheel_x;
|
||||
if shift { delta += ui.input.wheel_y; ui.input.wheel_y = 0; }
|
||||
ui.input.wheel_x = 0;
|
||||
} else if !shift {
|
||||
delta = ui.input.wheel_y;
|
||||
ui.input.wheel_y = 0;
|
||||
}
|
||||
s.offset += delta * 40;
|
||||
}
|
||||
s.offset = clamp(s.offset, 0, max(0.0, s.content - view));
|
||||
push_clip(r);
|
||||
return s.offset;
|
||||
}
|
||||
|
||||
scroll_end :: (r: Rect, id: u64, content: float, horizontal := false) {
|
||||
pop_clip();
|
||||
s := get_scroll(id);
|
||||
s.content = content;
|
||||
view := ifx horizontal then r.w else r.h;
|
||||
if content > view + 1 {
|
||||
t := s.offset / (content - view);
|
||||
if horizontal {
|
||||
len := max(30.0, r.w * view / content);
|
||||
draw_rect(make_rect(r.x + (r.w - len) * t, r.y + r.h - 5, len, 3), hex(0xFFFFFF, 0.18), 1.5);
|
||||
} else {
|
||||
len := max(30.0, r.h * view / content);
|
||||
draw_rect(make_rect(r.x + r.w - 5, r.y + (r.h - len) * t, 3, len), hex(0xFFFFFF, 0.18), 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Flow layout: places widgets left to right, wrapping when a row is full.
|
||||
//
|
||||
|
||||
Flow :: struct {
|
||||
x0, x, y, max_x: float;
|
||||
row_h: float;
|
||||
gap: float = 6;
|
||||
}
|
||||
|
||||
make_flow :: (r: Rect, row_h: float, gap := 6.0) -> Flow {
|
||||
f: Flow;
|
||||
f.x0, f.x, f.y = r.x, r.x, r.y;
|
||||
f.max_x = r.x + r.w;
|
||||
f.row_h = row_h;
|
||||
f.gap = gap;
|
||||
return f;
|
||||
}
|
||||
|
||||
flow_next :: (f: *Flow, w: float) -> Rect {
|
||||
if f.x > f.x0 && f.x + w > f.max_x {
|
||||
f.x = f.x0;
|
||||
f.y += f.row_h + f.gap;
|
||||
}
|
||||
r := make_rect(f.x, f.y, w, f.row_h);
|
||||
f.x += w + f.gap;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Wraps to a new row unless the next `total` width still fits, so a label stays next to its control.
|
||||
flow_keep :: (f: *Flow, total: float) {
|
||||
if f.x > f.x0 && f.x + total > f.max_x {
|
||||
f.x = f.x0;
|
||||
f.y += f.row_h + f.gap;
|
||||
}
|
||||
}
|
||||
|
||||
flow_label :: (f: *Flow, text: string, color := COL_TEXT_DIM) {
|
||||
w := measure_text(text, .NORMAL);
|
||||
r := flow_next(f, w);
|
||||
draw_text_in_rect(text, r, .NORMAL, color);
|
||||
}
|
||||
|
||||
flow_height :: (f: *Flow, start_y: float) -> float {
|
||||
return f.y + f.row_h - start_y;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// The subset of OpenGL (ES 3.0 / WebGL2) that the renderer uses, declared with the same names and
|
||||
// signatures as the native GL module. Each call is a WASM import implemented as a one-liner in web/taskforge.js,
|
||||
// so render.jai is the same code on both platforms.
|
||||
|
||||
GLenum :: u32;
|
||||
GLuint :: u32;
|
||||
GLint :: s32;
|
||||
GLsizei :: u32;
|
||||
GLboolean :: u8;
|
||||
GLbitfield :: u32;
|
||||
GLfloat :: float;
|
||||
GLchar :: u8;
|
||||
GLsizeiptr :: s64;
|
||||
|
||||
GL_FALSE :: 0;
|
||||
GL_TRUE :: 1;
|
||||
GL_TRIANGLES :: 0x0004;
|
||||
GL_SRC_ALPHA :: 0x0302;
|
||||
GL_ONE_MINUS_SRC_ALPHA :: 0x0303;
|
||||
GL_CULL_FACE :: 0x0B44;
|
||||
GL_DEPTH_TEST :: 0x0B71;
|
||||
GL_BLEND :: 0x0BE2;
|
||||
GL_SCISSOR_TEST :: 0x0C11;
|
||||
GL_UNPACK_ALIGNMENT :: 0x0CF5;
|
||||
GL_TEXTURE_2D :: 0x0DE1;
|
||||
GL_UNSIGNED_BYTE :: 0x1401;
|
||||
GL_FLOAT :: 0x1406;
|
||||
GL_RED :: 0x1903;
|
||||
GL_LINEAR :: 0x2601;
|
||||
GL_TEXTURE_MAG_FILTER :: 0x2800;
|
||||
GL_TEXTURE_MIN_FILTER :: 0x2801;
|
||||
GL_TEXTURE_WRAP_S :: 0x2802;
|
||||
GL_TEXTURE_WRAP_T :: 0x2803;
|
||||
GL_COLOR_BUFFER_BIT :: 0x4000;
|
||||
GL_CLAMP_TO_EDGE :: 0x812F;
|
||||
GL_R8 :: 0x8229;
|
||||
GL_TEXTURE0 :: 0x84C0;
|
||||
GL_ARRAY_BUFFER :: 0x8892;
|
||||
GL_STREAM_DRAW :: 0x88E0;
|
||||
GL_FRAGMENT_SHADER :: 0x8B30;
|
||||
GL_VERTEX_SHADER :: 0x8B31;
|
||||
GL_COMPILE_STATUS :: 0x8B81;
|
||||
GL_LINK_STATUS :: 0x8B82;
|
||||
GL_INFO_LOG_LENGTH :: 0x8B84;
|
||||
|
||||
glCreateShader :: (type: GLenum) -> GLuint #foreign;
|
||||
glShaderSource :: (shader: GLuint, count: GLsizei, strings: **GLchar, lengths: *GLint) #foreign;
|
||||
glCompileShader :: (shader: GLuint) #foreign;
|
||||
glGetShaderiv :: (shader: GLuint, pname: GLenum, params: *GLint) #foreign;
|
||||
glGetShaderInfoLog :: (shader: GLuint, max_length: GLsizei, length: *GLsizei, info_log: *GLchar) #foreign;
|
||||
glCreateProgram :: () -> GLuint #foreign;
|
||||
glAttachShader :: (program: GLuint, shader: GLuint) #foreign;
|
||||
glBindAttribLocation :: (program: GLuint, index: GLuint, name: *GLchar) #foreign;
|
||||
glLinkProgram :: (program: GLuint) #foreign;
|
||||
glGetProgramiv :: (program: GLuint, pname: GLenum, params: *GLint) #foreign;
|
||||
glGetProgramInfoLog :: (program: GLuint, max_length: GLsizei, length: *GLsizei, info_log: *GLchar) #foreign;
|
||||
glUseProgram :: (program: GLuint) #foreign;
|
||||
glGetUniformLocation :: (program: GLuint, name: *GLchar) -> GLint #foreign;
|
||||
glUniform1i :: (location: GLint, v0: GLint) #foreign;
|
||||
glUniform1f :: (location: GLint, v0: GLfloat) #foreign;
|
||||
glUniform2f :: (location: GLint, v0: GLfloat, v1: GLfloat) #foreign;
|
||||
glGenVertexArrays :: (n: GLsizei, arrays: *GLuint) #foreign;
|
||||
glBindVertexArray :: (array: GLuint) #foreign;
|
||||
glGenBuffers :: (n: GLsizei, buffers: *GLuint) #foreign;
|
||||
glBindBuffer :: (target: GLenum, buffer: GLuint) #foreign;
|
||||
glBufferData :: (target: GLenum, size: GLsizeiptr, data: *void, usage: GLenum) #foreign;
|
||||
glVertexAttribPointer :: (index: GLuint, size: GLint, type: GLenum, normalized: GLboolean, stride: GLsizei, pointer: *void) #foreign;
|
||||
glEnableVertexAttribArray :: (index: GLuint) #foreign;
|
||||
glGenTextures :: (n: GLsizei, textures: *GLuint) #foreign;
|
||||
glBindTexture :: (target: GLenum, texture: GLuint) #foreign;
|
||||
glActiveTexture :: (texture: GLenum) #foreign;
|
||||
glTexImage2D :: (target: GLenum, level: GLint, internal_format: GLint, width: GLsizei, height: GLsizei, border: GLint, format: GLenum, type: GLenum, pixels: *void) #foreign;
|
||||
glTexParameteri :: (target: GLenum, pname: GLenum, param: GLint) #foreign;
|
||||
glPixelStorei :: (pname: GLenum, param: GLint) #foreign;
|
||||
glEnable :: (cap: GLenum) #foreign;
|
||||
glDisable :: (cap: GLenum) #foreign;
|
||||
glBlendFunc :: (sfactor: GLenum, dfactor: GLenum) #foreign;
|
||||
glScissor :: (x: GLint, y: GLint, width: GLsizei, height: GLsizei) #foreign;
|
||||
glViewport :: (x: GLint, y: GLint, width: GLsizei, height: GLsizei) #foreign;
|
||||
glClearColor :: (r: GLfloat, g: GLfloat, b: GLfloat, a: GLfloat) #foreign;
|
||||
glClear :: (mask: GLbitfield) #foreign;
|
||||
glDrawArrays :: (mode: GLenum, first: GLint, count: GLsizei) #foreign;
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskForge</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #111318; }
|
||||
#app { display: block; width: 100vw; height: 100vh; outline: none; }
|
||||
#message { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #9DA7B3; font: 15px system-ui, sans-serif; text-align: center; padding: 2em; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="app" tabindex="0"></canvas>
|
||||
<div id="message">Loading…</div>
|
||||
<input id="import" type="file" accept=".taskforge,.txt" style="display: none">
|
||||
<script src="taskforge.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,329 @@
|
||||
'use strict';
|
||||
// Thin browser shim for TaskForge. All app logic and rendering is in Jai (taskforge.wasm); this file only
|
||||
// 1. forwards WebGL2 calls (same names as OpenGL, see src/web_gl.jai),
|
||||
// 2. forwards input events to the exported web_* procedures,
|
||||
// 3. provides storage (localStorage), clipboard and file import/export.
|
||||
// Pointers and s64 values arrive as BigInt (WASM64), everything else as Number.
|
||||
|
||||
const canvas = document.getElementById('app');
|
||||
const message = document.getElementById('message');
|
||||
const importInput = document.getElementById('import');
|
||||
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: false });
|
||||
|
||||
let memory = null;
|
||||
let X = null; // WASM exports.
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const u8 = () => new Uint8Array(memory.buffer);
|
||||
const dv = () => new DataView(memory.buffer);
|
||||
const str = (ptr, len) => decoder.decode(u8().subarray(Number(ptr), Number(ptr) + Number(len)));
|
||||
const cstr = (ptr) => {
|
||||
const m = u8();
|
||||
const start = Number(ptr);
|
||||
let end = start;
|
||||
while (m[end]) end++;
|
||||
return decoder.decode(m.subarray(start, end));
|
||||
};
|
||||
|
||||
// WebGL objects are referenced from Jai by integer handles.
|
||||
const objects = [null];
|
||||
const handle = (o) => { objects.push(o); return objects.length - 1; };
|
||||
|
||||
function writeInfoLog(log, maxLength, lengthPtr, bufferPtr) {
|
||||
const bytes = encoder.encode(log || '').subarray(0, Math.max(0, maxLength - 1));
|
||||
u8().set(bytes, Number(bufferPtr));
|
||||
u8()[Number(bufferPtr) + bytes.length] = 0;
|
||||
if (lengthPtr) dv().setInt32(Number(lengthPtr), bytes.length, true);
|
||||
}
|
||||
|
||||
function genObjects(n, out, create) {
|
||||
for (let i = 0; i < n; i++) dv().setUint32(Number(out) + i * 4, handle(create()), true);
|
||||
}
|
||||
|
||||
let pendingLoad = null;
|
||||
let consoleLine = '';
|
||||
|
||||
const env = {
|
||||
// Jai runtime.
|
||||
wasm_write_string(count, data, toStderr) {
|
||||
consoleLine += str(data, count);
|
||||
const lines = consoleLine.split('\n');
|
||||
consoleLine = lines.pop();
|
||||
for (const line of lines) (toStderr ? console.error : console.log)(line);
|
||||
},
|
||||
wasm_debug_break() { debugger; },
|
||||
memcmp(a, b, n) {
|
||||
const m = u8();
|
||||
const pa = Number(a), pb = Number(b);
|
||||
for (let i = 0; i < Number(n); i++) {
|
||||
const d = m[pa + i] - m[pb + i];
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// OpenGL ES 3.0 -> WebGL2.
|
||||
glCreateShader: (type) => handle(gl.createShader(type)),
|
||||
glShaderSource(shader, count, strings, lengths) {
|
||||
const d = dv();
|
||||
let source = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = d.getBigUint64(Number(strings) + i * 8, true);
|
||||
const len = lengths ? d.getInt32(Number(lengths) + i * 4, true) : -1;
|
||||
source += len >= 0 ? str(p, len) : cstr(p);
|
||||
}
|
||||
gl.shaderSource(objects[shader], source);
|
||||
},
|
||||
glCompileShader: (shader) => gl.compileShader(objects[shader]),
|
||||
glGetShaderiv(shader, pname, out) {
|
||||
const value = pname === 0x8B84
|
||||
? (gl.getShaderInfoLog(objects[shader]) || '').length + 1
|
||||
: Number(gl.getShaderParameter(objects[shader], pname));
|
||||
dv().setInt32(Number(out), value, true);
|
||||
},
|
||||
glGetShaderInfoLog: (shader, max, len, buf) => writeInfoLog(gl.getShaderInfoLog(objects[shader]), max, len, buf),
|
||||
glCreateProgram: () => handle(gl.createProgram()),
|
||||
glAttachShader: (program, shader) => gl.attachShader(objects[program], objects[shader]),
|
||||
glBindAttribLocation: (program, index, name) => gl.bindAttribLocation(objects[program], index, cstr(name)),
|
||||
glLinkProgram: (program) => gl.linkProgram(objects[program]),
|
||||
glGetProgramiv(program, pname, out) {
|
||||
const value = pname === 0x8B84
|
||||
? (gl.getProgramInfoLog(objects[program]) || '').length + 1
|
||||
: Number(gl.getProgramParameter(objects[program], pname));
|
||||
dv().setInt32(Number(out), value, true);
|
||||
},
|
||||
glGetProgramInfoLog: (program, max, len, buf) => writeInfoLog(gl.getProgramInfoLog(objects[program]), max, len, buf),
|
||||
glUseProgram: (program) => gl.useProgram(objects[program]),
|
||||
glGetUniformLocation: (program, name) => handle(gl.getUniformLocation(objects[program], cstr(name))),
|
||||
glUniform1i: (loc, v) => gl.uniform1i(objects[loc], v),
|
||||
glUniform1f: (loc, v) => gl.uniform1f(objects[loc], v),
|
||||
glUniform2f: (loc, a, b) => gl.uniform2f(objects[loc], a, b),
|
||||
glGenVertexArrays: (n, out) => genObjects(n, out, () => gl.createVertexArray()),
|
||||
glBindVertexArray: (vao) => gl.bindVertexArray(objects[vao]),
|
||||
glGenBuffers: (n, out) => genObjects(n, out, () => gl.createBuffer()),
|
||||
glBindBuffer: (target, buffer) => gl.bindBuffer(target, objects[buffer]),
|
||||
glBufferData(target, size, data, usage) {
|
||||
if (data) gl.bufferData(target, u8().subarray(Number(data), Number(data) + Number(size)), usage);
|
||||
else gl.bufferData(target, Number(size), usage);
|
||||
},
|
||||
glVertexAttribPointer: (index, size, type, normalized, stride, offset) =>
|
||||
gl.vertexAttribPointer(index, size, type, !!normalized, stride, Number(offset)),
|
||||
glEnableVertexAttribArray: (index) => gl.enableVertexAttribArray(index),
|
||||
glGenTextures: (n, out) => genObjects(n, out, () => gl.createTexture()),
|
||||
glBindTexture: (target, texture) => gl.bindTexture(target, objects[texture]),
|
||||
glActiveTexture: (unit) => gl.activeTexture(unit),
|
||||
glTexImage2D(target, level, internalFormat, w, h, border, format, type, pixels) {
|
||||
const bytesPerPixel = format === gl.RED ? 1 : 4;
|
||||
const data = pixels ? u8().subarray(Number(pixels), Number(pixels) + w * h * bytesPerPixel) : null;
|
||||
gl.texImage2D(target, level, internalFormat, w, h, border, format, type, data);
|
||||
},
|
||||
glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param),
|
||||
glPixelStorei: (pname, param) => gl.pixelStorei(pname, param),
|
||||
glEnable: (cap) => gl.enable(cap),
|
||||
glDisable: (cap) => gl.disable(cap),
|
||||
glBlendFunc: (s, d) => gl.blendFunc(s, d),
|
||||
glScissor: (x, y, w, h) => gl.scissor(x, y, w, h),
|
||||
glViewport: (x, y, w, h) => gl.viewport(x, y, w, h),
|
||||
glClearColor: (r, g, b, a) => gl.clearColor(r, g, b, a),
|
||||
glClear: (mask) => gl.clear(mask),
|
||||
glDrawArrays: (mode, first, count) => gl.drawArrays(mode, first, count),
|
||||
|
||||
// Platform services.
|
||||
js_storage_save(key, keyCount, data, count) {
|
||||
try { localStorage.setItem(str(key, keyCount), str(data, count)); }
|
||||
catch (e) { console.error('Saving failed:', e); }
|
||||
},
|
||||
js_storage_load_size(key, keyCount) {
|
||||
const text = localStorage.getItem(str(key, keyCount));
|
||||
pendingLoad = text ? encoder.encode(text) : null;
|
||||
return BigInt(pendingLoad ? pendingLoad.length : 0);
|
||||
},
|
||||
js_storage_load(dest) {
|
||||
if (pendingLoad) u8().set(pendingLoad, Number(dest));
|
||||
pendingLoad = null;
|
||||
},
|
||||
js_clipboard_write(data, count) {
|
||||
navigator.clipboard?.writeText(str(data, count)).catch(() => {});
|
||||
},
|
||||
js_set_cursor(kind) {
|
||||
canvas.style.cursor = ['default', 'pointer', 'text', 'grabbing'][kind] || 'default';
|
||||
},
|
||||
js_download(data, count) {
|
||||
const blob = new Blob([u8().slice(Number(data), Number(data) + Number(count))], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'project.taskforge';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
},
|
||||
js_request_import() { importInput.click(); },
|
||||
js_http_post(tag, path, pathCount, body, bodyCount, token, tokenCount) {
|
||||
const headers = { 'Content-Type': 'text/plain; charset=utf-8' };
|
||||
if (tokenCount) headers['Authorization'] = 'Bearer ' + str(token, tokenCount);
|
||||
const request = fetch(str(path, pathCount), { method: 'POST', headers, body: u8().slice(Number(body), Number(body) + Number(bodyCount)) });
|
||||
request.then(async (response) => [response.status, new Uint8Array(await response.arrayBuffer())])
|
||||
.catch(() => [0, new Uint8Array(0)])
|
||||
.then(([status, bytes]) => {
|
||||
const ptr = bytes.length ? X.web_alloc(BigInt(bytes.length)) : 0n;
|
||||
if (bytes.length) u8().set(bytes, Number(ptr));
|
||||
X.web_http_response(tag, status, ptr, BigInt(bytes.length));
|
||||
requestFrame();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Report any import we forgot instead of failing to instantiate.
|
||||
const imports = {
|
||||
env: new Proxy(env, {
|
||||
get(target, name) {
|
||||
if (name in target) return target[name];
|
||||
return () => { throw new Error('Missing import: ' + String(name)); };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
//
|
||||
// Frames are only rendered when something happened (input, resize, or the app asks for more).
|
||||
//
|
||||
let frameScheduled = false;
|
||||
function requestFrame() {
|
||||
if (!frameScheduled) {
|
||||
frameScheduled = true;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
function frame(timeMs) {
|
||||
frameScheduled = false;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
const pw = Math.round(w * dpr), ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
if (X.web_frame(w, h, dpr, timeMs / 1000)) requestFrame();
|
||||
}
|
||||
|
||||
function sendString(text, exportName) {
|
||||
const bytes = encoder.encode(text);
|
||||
const ptr = X.web_alloc(BigInt(bytes.length));
|
||||
u8().set(bytes, Number(ptr));
|
||||
X[exportName](ptr, BigInt(bytes.length));
|
||||
}
|
||||
|
||||
//
|
||||
// Input
|
||||
//
|
||||
const KEYS = {
|
||||
Backspace: 8, Tab: 9, Enter: 13, Escape: 27, Delete: 127,
|
||||
ArrowUp: 128, ArrowDown: 129, ArrowLeft: 130, ArrowRight: 131,
|
||||
PageUp: 132, PageDown: 133, Home: 134, End: 135,
|
||||
};
|
||||
const MOUSE_BUTTONS = [0, 2, 1]; // DOM left/middle/right -> app left/middle/right indices.
|
||||
|
||||
function modifiers(e) {
|
||||
return (e.shiftKey ? 1 : 0) | (e.ctrlKey || e.metaKey ? 2 : 0) | (e.altKey ? 4 : 0);
|
||||
}
|
||||
|
||||
function mouseMove(e) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
X.web_mouse_move(e.clientX - r.left, e.clientY - r.top);
|
||||
X.web_modifiers(modifiers(e));
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
function installInput() {
|
||||
window.addEventListener('pointermove', mouseMove);
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
canvas.focus();
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
mouseMove(e);
|
||||
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 1);
|
||||
e.preventDefault();
|
||||
});
|
||||
window.addEventListener('pointerup', (e) => {
|
||||
mouseMove(e);
|
||||
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 0);
|
||||
});
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
const scale = e.deltaMode === 1 ? 1 : e.deltaMode === 2 ? 10 : 1 / 40;
|
||||
X.web_modifiers(modifiers(e));
|
||||
X.web_wheel(e.deltaX * scale, e.deltaY * scale);
|
||||
requestFrame();
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const mods = modifiers(e);
|
||||
const ctrl = (mods & 2) !== 0;
|
||||
X.web_modifiers(mods);
|
||||
if (KEYS[e.key] !== undefined) {
|
||||
X.web_key(KEYS[e.key], mods);
|
||||
e.preventDefault();
|
||||
} else if (ctrl) {
|
||||
// Ctrl+V is left to the browser so the 'paste' event delivers the clipboard text.
|
||||
const k = e.key.toUpperCase();
|
||||
if (k === 'C' || k === 'X' || k === 'S' || k === 'A') {
|
||||
X.web_key(k.charCodeAt(0), mods);
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ([...e.key].length === 1) {
|
||||
X.web_char(e.key.codePointAt(0));
|
||||
e.preventDefault();
|
||||
}
|
||||
requestFrame();
|
||||
});
|
||||
window.addEventListener('keyup', (e) => { X.web_modifiers(modifiers(e)); });
|
||||
window.addEventListener('paste', (e) => {
|
||||
const text = e.clipboardData?.getData('text') || '';
|
||||
for (const ch of text.replace(/\r\n/g, '\n')) X.web_char(ch.codePointAt(0));
|
||||
e.preventDefault();
|
||||
requestFrame();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', requestFrame);
|
||||
window.addEventListener('beforeunload', () => X.web_flush());
|
||||
// Keep the caret blinking and autosave ticking even without input.
|
||||
setInterval(requestFrame, 500);
|
||||
|
||||
importInput.addEventListener('change', async () => {
|
||||
const file = importInput.files[0];
|
||||
importInput.value = '';
|
||||
if (!file) return;
|
||||
sendString(await file.text(), 'web_import');
|
||||
requestFrame();
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!gl) throw new Error('WebGL2 is not available in this browser.');
|
||||
// When served by taskforge-server we run against it (with sign-in); otherwise the project lives in this browser.
|
||||
const serverMode = await fetch('api/info').then((r) => r.ok ? r.text() : '').then((t) => t.startsWith('taskforge-server')).catch(() => false);
|
||||
const [wasm, fonts] = await Promise.all([
|
||||
fetch('taskforge.wasm').then((r) => r.arrayBuffer()).then((bytes) => WebAssembly.instantiate(bytes, imports)),
|
||||
fetch('fonts.bin').then((r) => r.arrayBuffer()),
|
||||
]);
|
||||
X = wasm.instance.exports;
|
||||
memory = X.memory;
|
||||
X.main(0, 0n); // Runs the Jai runtime init + our main(), which just stores the context.
|
||||
|
||||
const ptr = X.web_alloc(BigInt(fonts.byteLength));
|
||||
u8().set(new Uint8Array(fonts), Number(ptr));
|
||||
if (!X.web_init(ptr, BigInt(fonts.byteLength), serverMode ? 1 : 0)) throw new Error('Initialization failed (see console).');
|
||||
|
||||
message.remove();
|
||||
installInput();
|
||||
canvas.focus();
|
||||
requestFrame();
|
||||
}
|
||||
|
||||
start().catch((e) => {
|
||||
console.error(e);
|
||||
const memory64 = /memory64|i64|Memory64|table64/i.test(String(e));
|
||||
message.textContent = memory64
|
||||
? 'This browser does not support WebAssembly Memory64 yet. Try a current Chrome, Edge or Firefox.'
|
||||
: 'TaskForge failed to start: ' + e.message;
|
||||
});
|
||||
Reference in New Issue
Block a user