// 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"; #import "Windows_Utf8"; #import "stb_image"; #import "stb_image_resize"; #import "stb_image_write"; 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); DragAcceptFiles_(native_window, 1); #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); video_native_init(); 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 .DRAG_AND_DROP_FILES; for event.files queue_file_from_disk(it); 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(); input.unix_time = unix_now(); input.utc_offset = utc_offset_seconds(); poll_snip(); for platform_pending_files array_add(*input.files, it); platform_pending_files.count = 0; 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 || snip_waiting 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; // MoveFileW refuses to overwrite; replace the old file atomically instead. MOVEFILE_REPLACE_EXISTING :: 0x1; MOVEFILE_WRITE_THROUGH :: 0x8; return MoveFileExW_(utf8_to_wide(temp_path,, temp), utf8_to_wide(project_path,, temp), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; } 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; method: string; key: string; url: string; body: string; token: string; status: int; response: String_Builder; thread: Thread; } http_jobs: [..] *Http_Job; platform_http_request :: (tag: Http_Tag, method: string, path: string, body: string, token: string, key := "") { job := New(Http_Job); job.tag = tag; job.method = copy_string(method); job.key = copy_string(key); 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)); if job.method == "GET" { curl_easy_setopt(curl, .HTTPGET, cast(s64) 1); } else { curl_easy_setopt(curl, .POST, cast(s64) 1); curl_easy_setopt(curl, .POSTFIELDS, job.body.data); curl_easy_setopt(curl, .POSTFIELDSIZE_LARGE, 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) ifx job.tag == .BLOB_PUT || job.tag == .BLOB_GET then 600 else 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, job.key); free(response); free(job.method); free(job.key); free(job.url); free(job.body); free(job.token); free(job); } } // The session token is kept per user profile, tied to the server it belongs to. session_path :: () -> string { home := get_home_directory(); dir := tprint("%/AppData/Roaming/TaskForge", home); make_directory_if_it_does_not_exist(dir); return tprint("%/session.txt", dir); } platform_load_session :: () -> string { text, ok := read_entire_file(session_path(), log_errors = false); if !ok return ""; lines := split(text, "\n",, temp); if lines.count < 2 || lines[0] != server_url return ""; return copy_string(trim(lines[1])); } platform_save_session :: (token: string) { write_entire_file(session_path(), tprint("%\n%\n", server_url, token)); } platform_clipboard_get :: () -> string { text := os_clipboard_get_text(); result := copy_temporary_string(text); free(text); return result; } platform_clipboard_set :: (text: string) { os_clipboard_set_text(text); } platform_set_cursor :: (kind: Cursor_Kind) { name: *s16; if kind == { case .HAND; name = cast(*s16) IDC_HAND; case .TEXT; name = cast(*s16) IDC_IBEAM; case .GRAB; name = cast(*s16) IDC_SIZEALL; case; name = cast(*s16) IDC_ARROW; } SetCursor(LoadCursorW(null, name)); } platform_export_project :: (data: string) {} platform_request_import :: () {} // // Test harness: --sim "" drives the app with scripted input, one command per frame, separated by ';'. // move X Y | down | up | click X Y | type TEXT | key CODE [MODS] | wheel LINES | wait N | shot FILE.bmp | quit // Coordinates are logical pixels. // sim_script: string; sim_screenshot: string; sim_wait: int; sim_pending_up: bool; simulate_frame :: (input: *Frame_Input, pw: s32, ph: s32) -> bool { if sim_pending_up { sim_pending_up = false; input.mouse_down[MOUSE_LEFT] = false; input.mouse_released[MOUSE_LEFT] = true; return true; } if sim_wait > 0 { sim_wait -= 1; return true; } while sim_script.count && (sim_script[0] == #char ";" || sim_script[0] == #char " ") advance(*sim_script); if !sim_script.count return false; end := find_index_from_left(sim_script, #char ";"); if end < 0 end = sim_script.count; command := substring(sim_script, 0, end); advance(*sim_script, end); parts := split(command, " ",, allocator = temp); if !parts.count return true; if parts[0] == { case "move"; input.mouse_x = string_to_float(parts[1]); input.mouse_y = string_to_float(parts[2]); case "down"; input.mouse_down[MOUSE_LEFT] = true; input.mouse_pressed[MOUSE_LEFT] = true; case "up"; input.mouse_down[MOUSE_LEFT] = false; input.mouse_released[MOUSE_LEFT] = true; case "click"; input.mouse_x = string_to_float(parts[1]); input.mouse_y = string_to_float(parts[2]); input.mouse_down[MOUSE_LEFT] = true; input.mouse_pressed[MOUSE_LEFT] = true; sim_pending_up = true; case "type"; text := substring(command, 5, command.count); for 0..text.count-1 array_add(*input.chars, cast(u32) text[it]); case "key"; k: Key_Press; k.key = cast(u32) string_to_int(parts[1]); if parts.count > 2 k.mods = cast(u32) string_to_int(parts[2]); array_add(*input.keys, k); case "drop"; queue_file_from_disk(substring(command, 5, command.count)); 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); } // // Media: local blob files, image decoding, clipboard screenshots, file picker, drag & drop, snipping tool. // tf_shell32 :: #system_library "shell32"; tf_comdlg :: #system_library "comdlg32"; tf_kernel :: #system_library "kernel32"; DragAcceptFiles_ :: (hwnd: HWND, accept: s32) #foreign tf_shell32 "DragAcceptFiles"; ShellExecuteW_ :: (hwnd: HWND, op: *u16, file: *u16, params: *u16, dir: *u16, show: s32) -> *void #foreign tf_shell32 "ShellExecuteW"; IsClipboardFormatAvailable_ :: (format: u32) -> s32 #foreign tf_user32 "IsClipboardFormatAvailable"; GetClipboardSequenceNumber_ :: () -> u32 #foreign tf_user32 "GetClipboardSequenceNumber"; OpenClipboard_ :: (hwnd: HWND) -> s32 #foreign tf_user32 "OpenClipboard"; CloseClipboard_ :: () -> s32 #foreign tf_user32 "CloseClipboard"; GetClipboardData_ :: (format: u32) -> *void #foreign tf_user32 "GetClipboardData"; GlobalLock_ :: (mem: *void) -> *u8 #foreign tf_kernel "GlobalLock"; GlobalUnlock_ :: (mem: *void) -> s32 #foreign tf_kernel "GlobalUnlock"; GlobalSize_ :: (mem: *void) -> u64 #foreign tf_kernel "GlobalSize"; GetSystemTimeAsFileTime_ :: (time: *u64) #foreign tf_kernel "GetSystemTimeAsFileTime"; GetOpenFileNameW_ :: (ofn: *Open_File_Name) -> s32 #foreign tf_comdlg "GetOpenFileNameW"; MoveFileExW_ :: (from: *u16, to: *u16, flags: u32) -> s32 #foreign tf_kernel "MoveFileExW"; CF_DIB_ :: 8; Open_File_Name :: struct { lStructSize: u32; hwndOwner: HWND; hInstance: *void; lpstrFilter: *u16; lpstrCustomFilter: *u16; nMaxCustFilter: u32; nFilterIndex: u32; lpstrFile: *u16; nMaxFile: u32; lpstrFileTitle: *u16; nMaxFileTitle: u32; lpstrInitialDir: *u16; lpstrTitle: *u16; Flags: u32; nFileOffset: u16; nFileExtension: u16; lpstrDefExt: *u16; lCustData: *void; lpfnHook: *void; lpTemplateName: *u16; pvReserved: *void; dwReserved: u32; FlagsEx: u32; } // Files from the picker or the snipping tool; handed to the app with the next frame's input. platform_pending_files: [..] Input_File; unix_now :: () -> float64 { ft: u64; GetSystemTimeAsFileTime_(*ft); return cast(float64) (ft - 116444736000000000) / 10000000.0; } utc_offset_seconds :: () -> float64 { now := current_time_consensus(); local := to_calendar(now, .LOCAL); utc := to_calendar(now, .UTC); minutes: s64 = (cast(s64) local.hour * 60 + local.minute) - (cast(s64) utc.hour * 60 + utc.minute); if local.day_of_month_starting_at_0 != utc.day_of_month_starting_at_0 { // Crossed midnight: the side that is "ahead" is one day later. if local.year > utc.year || (local.year == utc.year && (local.month_starting_at_0 > utc.month_starting_at_0 || (local.month_starting_at_0 == utc.month_starting_at_0 && local.day_of_month_starting_at_0 > utc.day_of_month_starting_at_0))) minutes += 24 * 60; else minutes -= 24 * 60; } return cast(float64) minutes * 60; } // Blobs of a local project live next to the project file. blob_dir :: () -> string { return tprint("%.files", project_path); } platform_local_blob_save :: (hash: string, data: string) { dir := blob_dir(); make_directory_if_it_does_not_exist(dir); path := tprint("%/%", dir, hash); if !file_exists(path) write_entire_file(path, data); } platform_local_blob_load :: (hash: string) { data, ok := read_entire_file(tprint("%/%", blob_dir(), hash), log_errors = false); blob_loaded(hash, ok, data); if ok free(data); } platform_decode_image :: (key: string, data: string) { w, h, n: s32; pixels := stbi_load_from_memory(data.data, cast(s32) data.count, *w, *h, *n, 4); if !pixels { image_decoded(key, false, 0, 0, null); return; } defer stbi_image_free(pixels); if max(w, h) > MAX_IMAGE_SIDE { scale := cast(float) MAX_IMAGE_SIDE / max(w, h); sw := max(cast(s32) 1, cast(s32) (w * scale)); sh := max(cast(s32) 1, cast(s32) (h * scale)); small := alloc(sw * sh * 4); defer free(small); stbir_resize_uint8(pixels, w, h, 0, small, sw, sh, 0, 4); image_decoded(key, true, sw, sh, small); } else { image_decoded(key, true, w, h, pixels); } } encode_png :: (rgba: *u8, w: s32, h: s32) -> string { b: String_Builder; write :: (ctx: *void, data: *void, size: s32) #c_call { push_context { append(cast(*String_Builder) ctx, data, size); } } stbi_write_png_to_func(write, *b, w, h, 4, rgba, w * 4); return builder_to_string(*b); } // The clipboard's bitmap as PNG bytes, or "" if it holds no image. platform_clipboard_image :: () -> string { if !IsClipboardFormatAvailable_(CF_DIB_) return ""; if !OpenClipboard_(native_window) return ""; defer CloseClipboard_(); handle := GetClipboardData_(CF_DIB_); if !handle return ""; dib := GlobalLock_(handle); if !dib return ""; defer GlobalUnlock_(handle); total := cast(s64) GlobalSize_(handle); header_size := (cast(*u32) dib).*; w := (cast(*s32) (dib + 4)).*; h := (cast(*s32) (dib + 8)).*; bits := (cast(*u16) (dib + 14)).*; compression := (cast(*u32) (dib + 16)).*; if (bits != 24 && bits != 32) || w <= 0 || h == 0 return ""; offset := cast(s64) header_size; if compression == 3 && header_size == 40 offset += 12; // BI_BITFIELDS masks follow a plain header. bottom_up := h > 0; height := abs(h); stride := ((w * bits + 31) / 32) * 4; if offset + cast(s64) stride * height > total return ""; rgba := cast(*u8) alloc(w * height * 4); defer free(rgba); for y: 0..height-1 { src := dib + offset + (ifx bottom_up then height - 1 - y else y) * stride; dst := rgba + y * w * 4; step := bits / 8; for x: 0..w-1 { dst[x*4 + 0] = src[x*step + 2]; dst[x*4 + 1] = src[x*step + 1]; dst[x*4 + 2] = src[x*step + 0]; dst[x*4 + 3] = 255; // Screenshot DIBs usually carry no meaningful alpha. } } return encode_png(rgba, w, xx height); } screenshot_name :: () -> string { c := to_calendar(current_time_consensus(), .LOCAL); return tprint("Screenshot %-%-% %.%.%.png", c.year, formatInt(c.month_starting_at_0 + 1, minimum_digits = 2), formatInt(c.day_of_month_starting_at_0 + 1, minimum_digits = 2), formatInt(c.hour, minimum_digits = 2), formatInt(c.minute, minimum_digits = 2), formatInt(c.second, minimum_digits = 2)); } platform_pick_files :: () { buffer := NewArray(65536, u16,, temp); filter := utf8_to_wide("Images and videos\0*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.webp;*.mp4;*.mov;*.m4v;*.webm;*.mkv;*.avi\0All files\0*.*\0\0",, temp); ofn: Open_File_Name; ofn.lStructSize = size_of(Open_File_Name); ofn.hwndOwner = native_window; ofn.lpstrFilter = filter; ofn.lpstrFile = buffer.data; ofn.nMaxFile = xx buffer.count; OFN_FILEMUSTEXIST :: 0x00001000; OFN_ALLOWMULTISELECT :: 0x00000200; OFN_EXPLORER :: 0x00080000; OFN_NOCHANGEDIR :: 0x00000008; ofn.Flags = OFN_FILEMUSTEXIST | OFN_ALLOWMULTISELECT | OFN_EXPLORER | OFN_NOCHANGEDIR; if !GetOpenFileNameW_(*ofn) return; // Either one full path, or a directory followed by file names, each zero terminated. parts: [..] string; parts.allocator = temp; start := 0; for i: 0..buffer.count-1 { if buffer[i] != 0 continue; if i == start break; array_add(*parts, wide_to_utf8(buffer.data + start, cast(s32) (i - start),, temp)); start = i + 1; } paths: [..] string; paths.allocator = temp; if parts.count == 1 array_add(*paths, parts[0]); else for 1..parts.count-1 array_add(*paths, tprint("%\\%", parts[0], parts[it])); for paths queue_file_from_disk(it); } queue_file_from_disk :: (path: string) { data, ok := read_entire_file(path, log_errors = false); if !ok return; array_add(*platform_pending_files, .{copy_string(path_filename(path)), data}); } // Opens the Windows snipping overlay; the snip is picked up from the clipboard when it arrives. snip_waiting: bool; snip_sequence: u32; snip_deadline: float64; platform_capture_screenshot :: () { snip_sequence = GetClipboardSequenceNumber_(); snip_waiting = true; snip_deadline = seconds_since_init() + 120; ShellExecuteW_(null, utf8_to_wide("open",, temp), utf8_to_wide("ms-screenclip:",, temp), null, null, 1); } poll_snip :: () { if !snip_waiting return; if seconds_since_init() > snip_deadline { snip_waiting = false; return; } if GetClipboardSequenceNumber_() == snip_sequence return; png := platform_clipboard_image(); if !png.count return; // Clipboard changed but no image yet (the snip may still be processing). snip_waiting = false; array_add(*platform_pending_files, .{copy_string(screenshot_name()), png}); } platform_capture_label :: () -> string { return "Snip screen"; }