From e0a61196ce26c8fa15fafc0c3f2e6a0783fe9f00 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Sat, 17 Feb 2024 02:27:03 +0100 Subject: [PATCH] First commit --- .gitignore | 18 +++++ build.zig | 90 ++++++++++++++++++++++ src/curl.zig | 73 ++++++++++++++++++ src/departure.zig | 22 ++++++ src/home.zig | 192 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 46 +++++++++++ src/raylib.zig | 52 +++++++++++++ src/state.zig | 36 +++++++++ 8 files changed, 529 insertions(+) create mode 100644 .gitignore create mode 100644 build.zig create mode 100644 src/curl.zig create mode 100644 src/departure.zig create mode 100644 src/home.zig create mode 100644 src/main.zig create mode 100644 src/raylib.zig create mode 100644 src/state.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..feda423 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# This file is for zig-specific build artifacts. +# If you have OS-specific or editor-specific files to ignore, +# such as *.swp or .DS_Store, put those in your global +# ~/.gitignore and put this in your ~/.gitconfig: +# +# [core] +# excludesfile = ~/.gitignore +# +# Cheers! +# -andrewrk + +zig-cache/ +zig-out/ +/release/ +/debug/ +/build/ +/build-*/ +/docgen_tmp/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..d20f765 --- /dev/null +++ b/build.zig @@ -0,0 +1,90 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "raylib-test", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + // Link Raylib + exe.addLibraryPath(.{ + .cwd_relative = "/opt/homebrew/Cellar/raylib/5.0/lib", + }); + exe.linkSystemLibrary("raylib"); + exe.linkSystemLibrary("curl"); + // exe.addObjectFile(.{ + // .cwd_relative = "/opt/homebrew/Cellar/raylib/5.0/lib/libraylib.a", + // }); + exe.addIncludePath(.{ + .cwd_relative = "/opt/homebrew/Cellar/raylib/5.0/include", + }); + // Raylib dependencies + exe.linkFramework("Foundation"); + exe.linkFramework("CoreVideo"); + exe.linkFramework("IOKit"); + exe.linkFramework("Cocoa"); + exe.linkFramework("GLUT"); + exe.linkFramework("OpenGL"); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/src/curl.zig b/src/curl.zig new file mode 100644 index 0000000..25e57af --- /dev/null +++ b/src/curl.zig @@ -0,0 +1,73 @@ +pub const c_api = @cImport({ + @cInclude("curl/curl.h"); +}); + +pub const Option = enum(c_api.CURLoption) { + write_data = c_api.CURLOPT_WRITEDATA, + url = c_api.CURLOPT_URL, + port = c_api.CURLOPT_PORT, + proxy = c_api.CURLOPT_PROXY, + userpwd = c_api.CURLOPT_USERPWD, + proxy_userpwd = c_api.CURLOPT_PROXYUSERPWD, + range = c_api.CURLOPT_RANGE, + read_data = c_api.CURLOPT_READDATA, + error_buffer = c_api.CURLOPT_ERRORBUFFER, + write_function = c_api.CURLOPT_WRITEFUNCTION, + read_function = c_api.CURLOPT_READFUNCTION, + timeout = c_api.CURLOPT_TIMEOUT, + in_file_size = c_api.CURLOPT_INFILESIZE, + post_fields = c_api.CURLOPT_POSTFIELDS, + referer = c_api.CURLOPT_REFERER, + ftp_port = c_api.CURLOPT_FTPPORT, + user_agent = c_api.CURLOPT_USERAGENT, + low_speed_limit = c_api.CURLOPT_LOW_SPEED_LIMIT, + low_speed_time = c_api.CURLOPT_LOW_SPEED_TIME, + resume_from = c_api.CURLOPT_RESUME_FROM, + cookie = c_api.CURLOPT_COOKIE, + http_header = c_api.CURLOPT_HTTPHEADER, + // ... +}; + +handle: *c_api.CURL, + +pub fn init() ?@This() { + if (c_api.curl_easy_init()) |handle| { + return .{ + .handle = handle, + }; + } else { + return null; + } +} + +pub fn deinit(self: *@This()) void { + c_api.curl_easy_cleanup(self.handle); +} + +pub fn reset(self: *@This()) void { + c_api.curl_easy_reset(self.handle); +} + +pub fn perform(self: *@This()) c_api.CURLcode { + return c_api.curl_easy_perform(self.handle); +} + +pub fn setopt_raw( + self: *@This(), + option: c_api.CURLoption, + args: anytype, +) c_api.CURLcode { + return @call( + .auto, + c_api.curl_easy_setopt, + .{ self.handle, option } ++ args, + ); +} + +pub fn setopt( + self: *@This(), + option: Option, + args: anytype, +) c_api.CURLcode { + return self.setopt_raw(@intFromEnum(option), args); +} diff --git a/src/departure.zig b/src/departure.zig new file mode 100644 index 0000000..4e6fded --- /dev/null +++ b/src/departure.zig @@ -0,0 +1,22 @@ +const raylib = @import("raylib.zig"); +const rl = raylib.rl; +const stateMod = @import("state.zig"); + +pub fn render(state: *stateMod.AppState) !void { + while (raylib.GetKeyPressed()) |key| { + switch (key) { + rl.KEY_LEFT => { + state.screen = .home; + }, + else => {}, + } + } + + rl.BeginDrawing(); + defer rl.EndDrawing(); + + rl.ClearBackground(raylib.ColorInt(0x18226f)); + rl.DrawText(state.departure_screen_state.station_id.items.ptr, 16, 16, 32, rl.WHITE); + + state.close_app = rl.WindowShouldClose(); +} diff --git a/src/home.zig b/src/home.zig new file mode 100644 index 0000000..7a02f97 --- /dev/null +++ b/src/home.zig @@ -0,0 +1,192 @@ +const std = @import("std"); +const raylib = @import("raylib.zig"); +const rl = raylib.rl; +const state_mod = @import("state.zig"); +const curl_mod = @import("curl.zig"); + +fn curlWriteHandler(ptr: [*]u8, size: usize, nmemb: usize, userdata: *std.ArrayList(u8)) callconv(.C) usize { + _ = size; + userdata.appendSlice(ptr[0..nmemb]) catch return 0; + return nmemb; +} + +fn fetchThread(state: *state_mod.AppState) !void { + std.debug.print("Started fetchThread\n", .{}); + defer std.debug.print("Ended fetchThread\n", .{}); + defer state.home_screen_state.fetch_thread = null; + const allocator = state.allocator; + var station_name_buf = std.BoundedArray(u8, 200){}; + var curl = curl_mod.init() orelse return; + defer curl.deinit(); + const locations_base = "https://v6.db.transport.rest/locations"; + var locations_uri = std.Uri.parse(locations_base) catch unreachable; + + while (state.home_screen_state.fetch_thread != null) { + if (std.mem.eql(u8, station_name_buf.slice(), state.home_screen_state.station_name.items)) { + std.time.sleep(100 * 1000); + continue; + } + + station_name_buf.resize(state.home_screen_state.station_name.items.len) catch continue; + std.mem.copyForwards(u8, station_name_buf.slice(), state.home_screen_state.station_name.items); + + std.debug.print("[fetchThread] Detected update: {s}\n", .{station_name_buf.slice()}); + + curl.reset(); + + const query = try std.fmt.allocPrint(allocator, "query={s}&results=10&addresses=false&poi=false&pretty=false", .{station_name_buf.slice()}); + defer allocator.free(query); + locations_uri.query = query; + defer locations_uri.query = null; + std.debug.print("[fetchThread] Making request to: {}\n", .{locations_uri}); + + const url = try std.fmt.allocPrintZ(allocator, "{}", .{locations_uri}); + defer allocator.free(url); + _ = curl.setopt(.url, .{url.ptr}); + + var result = std.ArrayList(u8).init(allocator); + defer result.deinit(); + _ = curl.setopt(.write_function, .{curlWriteHandler}); + _ = curl.setopt(.write_data, .{&result}); + + const code = curl.perform(); + std.debug.print("[fetchThread] cURL Code: {}\n", .{code}); + if (code != 0) continue; + + std.debug.print("[fetchThread] Fetched data: (len: {})\n", .{result.items.len}); + const parsed = std.json.parseFromSlice([]const std.json.Value, allocator, result.items, .{}) catch |err| { + std.debug.print("[fetchThread] JSON parse error: {}\n", .{err}); + continue; + }; + defer parsed.deinit(); + + var results = std.ArrayList(state_mod.HSSuggestion).init(allocator); + for (parsed.value) |station| { + if (station.object.get("name")) |nameValue| { + const name = nameValue.string; + if (station.object.get("id")) |idValue| { + const id = idValue.string; + + results.append(.{ + .id = std.fmt.allocPrintZ(allocator, "{s}", .{id}) catch continue, + .name = std.fmt.allocPrintZ(allocator, "{s}", .{name}) catch continue, + }) catch continue; + } + } + } + if (state.home_screen_state.suggestions.len > 0) { + for (state.home_screen_state.suggestions) |suggestion| { + allocator.free(suggestion.id); + allocator.free(suggestion.name); + } + allocator.free(state.home_screen_state.suggestions); + } + state.home_screen_state.suggestions = results.toOwnedSlice() catch continue; + } +} + +pub fn render(state: *state_mod.AppState) !void { + var hs = &state.home_screen_state; + + if (hs.fetch_thread == null) { + hs.fetch_thread = std.Thread.spawn(.{}, fetchThread, .{state}) catch null; + } + if (hs.suggestions.len > 0 and hs.selection_idx > hs.suggestions.len - 1) { + hs.selection_idx = @intCast(hs.suggestions.len - 1); + } + + while (raylib.GetCharPressed()) |char| { + hs.station_name.appendAssumeCapacity(@intCast(char)); + } + while (raylib.GetKeyPressed()) |key| { + switch (key) { + rl.KEY_BACKSPACE => { + if (hs.station_name.items.len > 0) { + hs.station_name.items[hs.station_name.items.len - 1] = 0; + _ = hs.station_name.pop(); + } + }, + rl.KEY_UP => { + hs.selection_idx -= 1; + if (hs.suggestions.len > 0 and hs.selection_idx < 0) { + hs.selection_idx = @intCast(hs.suggestions.len - 1); + } + }, + rl.KEY_DOWN => { + hs.selection_idx += 1; + if (hs.suggestions.len > 0 and hs.selection_idx > hs.suggestions.len - 1) { + hs.selection_idx = 0; + } + }, + rl.KEY_ENTER => { + if (hs.suggestions.len > 0 and hs.selection_idx < hs.suggestions.len) { + state.departure_screen_state.station_id.clearRetainingCapacity(); + state.departure_screen_state.station_id.appendSliceAssumeCapacity(hs.suggestions[@intCast(hs.selection_idx)].id); + state.screen = .departure; + hs.fetch_thread = null; + } + }, + else => {}, + } + } + + rl.BeginDrawing(); + defer rl.EndDrawing(); + + var x: c_int = 16; + var y: c_int = 16; + + const title_size: c_int = 32; + const body_size: c_int = 28; + + rl.ClearBackground(rl.BLACK); + x += raylib.DrawAndMeasureText("Station: ", x, y, title_size, rl.WHITE).width + 8; + rl.DrawLine(x, y + title_size + 2, rl.GetScreenWidth() - 16, y + title_size + 2, rl.WHITE); + if (state.db_font) |db_font| { + rl.DrawTextEx(db_font, hs.station_name.items.ptr, rl.Vector2{ .x = @floatFromInt(x), .y = @floatFromInt(y) }, title_size, 0.9, rl.WHITE); + } else { + rl.DrawText(hs.station_name.items.ptr, x, y, title_size, rl.WHITE); + } + + y += title_size + 2 + 16; + + for (hs.suggestions, 0..) |suggestion, idx| { + var color = if (hs.selection_idx == idx) rl.YELLOW else rl.WHITE; + + // Draw arrow for selection + if (hs.selection_idx == idx) { + const arrow_margin: c_int = 16; + rl.DrawLine(x - 10 - arrow_margin, y + body_size / 4, x - arrow_margin, y + body_size / 2, color); + rl.DrawLine(x - arrow_margin, y + body_size / 2, x - 10 - arrow_margin, y + body_size * 3 / 4, color); + } + + // Check if mouse is hovering + if (rl.CheckCollisionPointRec(rl.GetMousePosition(), rl.Rectangle{ + .x = @floatFromInt(x), + .y = @floatFromInt(y), + .width = @floatFromInt(rl.GetScreenWidth() - 16 - x), + .height = @floatFromInt(body_size), + })) { + color = rl.BLUE; + + if (rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT)) { + // Select + state.departure_screen_state.station_id.clearRetainingCapacity(); + state.departure_screen_state.station_id.appendSliceAssumeCapacity(suggestion.id); + state.screen = .departure; + hs.fetch_thread = null; + return; + } + } + + if (state.db_font) |db_font| { + rl.DrawTextEx(db_font, suggestion.name.ptr, rl.Vector2{ .x = @floatFromInt(x), .y = @floatFromInt(y) }, body_size, 0.9, color); + } else { + rl.DrawText(suggestion.name.ptr, x, y, body_size, color); + } + + y += body_size + 2; + } + + state.close_app = rl.WindowShouldClose(); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..200d7d6 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const raylib = @import("raylib.zig"); +const rl = raylib.rl; +const stateMod = @import("state.zig"); +const home = @import("home.zig"); +const departure = @import("departure.zig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + rl.SetConfigFlags(rl.FLAG_WINDOW_RESIZABLE | rl.FLAG_VSYNC_HINT); + rl.SetTargetFPS(60); + rl.InitWindow(800, 600, "Testing Raylib"); + defer rl.CloseWindow(); + + // const font = blk: { + // const maybeFont = rl.LoadFontEx("./db.ttf", 64, null, 0); + // if (std.meta.eql(maybeFont, rl.GetFontDefault())) { + // break :blk null; + // } + // break :blk maybeFont; + // }; + + var station_name_buffer: [100]u8 = .{0} ** 100; + var platform_buffer: [20]u8 = .{0} ** 20; + var station_id_buffer: [10]u8 = .{0} ** 10; + var appState = stateMod.AppState{ + .allocator = allocator, + // .db_font = font, + .home_screen_state = .{ + .station_name = std.ArrayListUnmanaged(u8).initBuffer(&station_name_buffer), + }, + .departure_screen_state = .{ + .platform = std.ArrayListUnmanaged(u8).initBuffer(&platform_buffer), + .station_id = std.ArrayListUnmanaged(u8).initBuffer(&station_id_buffer), // 7 digit id + .departure_date = std.time.Instant.now() catch @panic("Idk buddy, hook a wall clock to your CPU ig"), + }, + }; + while (!appState.close_app) { + switch (appState.screen) { + .home => try home.render(&appState), + .departure => try departure.render(&appState), + } + } +} diff --git a/src/raylib.zig b/src/raylib.zig new file mode 100644 index 0000000..09cdaf0 --- /dev/null +++ b/src/raylib.zig @@ -0,0 +1,52 @@ +pub const rl = @cImport({ + @cInclude("raylib.h"); +}); + +pub fn Color(r: u8, g: u8, b: u8, a: u8) rl.Color { + return .{ + .r = r, + .g = g, + .b = b, + .a = a, + }; +} +pub fn ColorInt(whole: u24) rl.Color { + return ColorIntA(@as(u32, whole) << 8 | 0xFF); +} +pub fn ColorIntA(whole: u32) rl.Color { + return .{ + // zig fmt: off + .r = @truncate(whole >> 24), + .g = @truncate(whole >> 16), + .b = @truncate(whole >> 8), + .a = @truncate(whole >> 0), + // zig fmt: on + }; +} +pub fn DrawAndMeasureText( + text: [*c]const u8, + pos_x: c_int, + pos_y: c_int, + font_size: c_int, + color: rl.Color, +) struct { width: c_int, height: c_int } { + rl.DrawText(text, pos_x, pos_y, font_size, color); + return .{ + .width = rl.MeasureText(text, font_size), + .height = 10, + }; +} +pub fn GetKeyPressed() ?c_int { + const result = rl.GetKeyPressed(); + return if (result == 0) + null + else + result; +} +pub fn GetCharPressed() ?c_int { + const result = rl.GetCharPressed(); + return if (result == 0) + null + else + result; +} diff --git a/src/state.zig b/src/state.zig new file mode 100644 index 0000000..fad94c9 --- /dev/null +++ b/src/state.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const raylib = @import("raylib.zig"); +const rl = raylib.rl; + +pub const Screen = enum { + home, + departure, +}; + +pub const HSSuggestion = struct { + id: [:0]u8, + name: [:0]u8, +}; + +pub const HomeScreenState = struct { + station_name: std.ArrayListUnmanaged(u8), + fetch_thread: ?std.Thread = null, + suggestions: []HSSuggestion = &.{}, + selection_idx: i8 = 0, +}; + +pub const DepartureScreenState = struct { + station_id: std.ArrayListUnmanaged(u8), + platform: std.ArrayListUnmanaged(u8), + departure_date: std.time.Instant, + loading: bool = false, +}; + +pub const AppState = struct { + allocator: std.mem.Allocator, + close_app: bool = false, + db_font: ?rl.Font = null, + screen: Screen = .home, + home_screen_state: HomeScreenState, + departure_screen_state: DepartureScreenState, +};