diff --git a/src/app.zig b/src/app.zig index 0d44f07..c69ccd0 100644 --- a/src/app.zig +++ b/src/app.zig @@ -8,7 +8,7 @@ const Graph = @import("./graph.zig"); const UI = @import("./ui.zig"); const MainScreen = @import("./screens/main_screen.zig"); const ChannelFromDeviceScreen = @import("./screens/channel_from_device.zig"); -const Platform = @import("./platform.zig"); +const Platform = @import("./platform/root.zig"); const constants = @import("./constants.zig"); const Allocator = std.mem.Allocator; @@ -168,6 +168,7 @@ pub const Channel = struct { // Persistent name: Name = .{}, + saved_collected_samples: ?[]u8 = null, // Runtime device: Device = .{}, @@ -178,10 +179,22 @@ pub const Channel = struct { write_pattern: std.ArrayListUnmanaged(f64) = .{}, output_task: ?NIDaq.Task = null, graph_min_max_cache: Graph.MinMaxCache = .{}, + last_sample_save_at: ?i128 = null, pub fn deinit(self: *Channel, allocator: Allocator) void { self.clear(allocator); + if (self.saved_collected_samples) |str| { + allocator.free(str); + self.saved_collected_samples = null; + } + } + + pub fn clear(self: *Channel, allocator: Allocator) void { + self.allowed_sample_rates = null; + self.allowed_sample_values = null; + self.last_sample_save_at = null; + self.collected_samples.clearAndFree(allocator); self.write_pattern.clearAndFree(allocator); if (self.output_task) |task| { @@ -192,12 +205,6 @@ pub const Channel = struct { self.graph_min_max_cache.deinit(allocator); } - pub fn clear(self: *Channel, allocator: Allocator) void { - _ = allocator; - self.allowed_sample_rates = null; - self.allowed_sample_values = null; - } - pub fn generateSine( samples: *std.ArrayListUnmanaged(f64), allocator: Allocator, @@ -217,6 +224,24 @@ pub const Channel = struct { samples.appendAssumeCapacity(sample); } } + + pub fn isSavedSampleFileStale(self: *const Channel, dir: std.fs.Dir) !bool { + const path = self.saved_collected_samples orelse return false; + const last_sample_save_at = self.last_sample_save_at orelse return true; + + const stat = dir.statFile(path) catch |e| { + if (e == error.FileNotFound) { + return true; + } + return e; + }; + + return stat.mtime != last_sample_save_at; + } + + pub fn invalidateSavedSamples(self: *Channel) void { + self.last_sample_save_at = null; + } }; pub const File = struct { @@ -254,6 +279,7 @@ pub const View = struct { // Persistent reference: Reference, height: f32 = 300, + // TODO: Implement different styles of following: Look ahead, sliding, sliding window follow: bool = false, graph_opts: Graph.ViewOptions = .{}, sync_controls: bool = false, @@ -291,6 +317,8 @@ pub const Project = struct { save_location: ?[]u8 = null, sample_rate: ?f64 = null, + + // TODO: How this to computer local settings, like appdata. Because this option shouldn't be project specific. show_rulers: bool = true, channels: GenerationalArray(Channel) = .{}, @@ -379,9 +407,17 @@ pub const Project = struct { const channel_name = try readString(reader, allocator); defer allocator.free(channel_name); + var saved_collected_samples: ?[]u8 = try readString(reader, allocator); + if (saved_collected_samples.?.len == 0) { + allocator.free(saved_collected_samples.?); + saved_collected_samples = null; + } + errdefer if (saved_collected_samples) |str| allocator.free(str); + const channel = self.channels.get(id).?; channel.* = Channel{ - .name = try utils.initBoundedStringZ(Channel.Name, channel_name) + .name = try utils.initBoundedStringZ(Channel.Name, channel_name), + .saved_collected_samples = saved_collected_samples }; } } @@ -470,6 +506,21 @@ pub const Project = struct { try writeId(writer, channel_id); try writeString(writer, channel_name); + try writeString(writer, channel.saved_collected_samples orelse &[0]u8{}); + + if (channel.saved_collected_samples != null and try channel.isSavedSampleFileStale(dir)) { + const path = channel.saved_collected_samples.?; + + { + const samples_file = try dir.createFile(path, .{}); + defer samples_file.close(); + + try writeFileF64(samples_file, channel.collected_samples.items); + } + + const stat = try dir.statFile(path); + channel.last_sample_save_at = stat.mtime; + } } } @@ -612,6 +663,8 @@ collection_task: ?CollectionTask = null, command_queue: std.BoundedArray(Command, 16) = .{}, +file_picker_id: ?Platform.FilePickerId = null, + pub fn init(self: *App, allocator: Allocator) !void { self.* = App{ .allocator = allocator, @@ -689,6 +742,14 @@ fn deinitUI(self: *App) void { } fn loadProject(self: *App) !void { + if (self.isCollectionInProgress()) { + log.warn("Attempt to load while collection is still in progress. Loading canceled", .{}); + return; + } + + self.collection_mutex.lock(); + defer self.collection_mutex.unlock(); + const save_location = self.project.save_location orelse return error.MissingSaveLocation; log.info("Load project from: {s}", .{save_location}); @@ -725,6 +786,14 @@ fn loadProject(self: *App) !void { } fn saveProject(self: *App) !void { + if (self.isCollectionInProgress()) { + log.warn("Attempt to save while collection is still in progress. Saving canceled", .{}); + return; + } + + self.collection_mutex.lock(); + defer self.collection_mutex.unlock(); + const save_location = self.project.save_location orelse return error.MissingSaveLocation; log.info("Save project to: {s}", .{save_location}); @@ -748,7 +817,17 @@ pub fn tick(self: *App) !void { var ui = &self.ui; self.command_queue.len = 0; - ui.pullOsEvents(); + if (Platform.waitUntilFilePickerDone(self.allocator, &self.file_picker_id)) |path| { + defer self.allocator.free(path); + + const file_id = try self.addFile(path); + _ = try self.addView(.{ .file = file_id }); + } + + const cover_ui = Platform.isFilePickerOpen(); + if (!cover_ui) { + ui.pullOsEvents(); + } if (ui.isCtrlDown() and ui.isKeyboardPressed(.key_s)) { self.pushCommand(.save_project); @@ -809,6 +888,18 @@ pub fn tick(self: *App) !void { rl.clearBackground(srcery.black); ui.draw(); + if (cover_ui) { + rl.drawRectangleRec( + rl.Rectangle{ + .x = 0, + .y = 0, + .width = @floatFromInt(rl.getScreenWidth()), + .height = @floatFromInt(rl.getScreenHeight()) + }, + rl.Color.black.alpha(0.6) + ); + } + for (self.command_queue.constSlice()) |command| { switch (command) { .start_collection => { @@ -855,11 +946,15 @@ pub fn pushCommand(self: *App, command: Command) void { } fn addFileFromPicker(self: *App) !void { - const filename = try Platform.openFilePicker(self.allocator); - defer self.allocator.free(filename); + if (self.file_picker_id == null) { + var opts: Platform.OpenFileOptions = .{}; + opts.style = .open; + opts.file_must_exist = true; + opts.appendFilter("All", "*") catch unreachable; + opts.appendFilter("Binary", "*.bin") catch unreachable; - const file_id = try self.addFile(filename); - _ = try self.addView(.{ .file = file_id }); + self.file_picker_id = try Platform.spawnFilePicker(&opts); + } } fn startCollection(self: *App) !void { @@ -1041,6 +1136,7 @@ pub fn collectionThreadCallback(self: *App) void { log.err("Failed to append samples for channel: {}", .{e}); continue; }; + channel.invalidateSavedSamples(); } } @@ -1104,6 +1200,15 @@ fn readFileF64(allocator: Allocator, file: std.fs.File) ![]f64 { return samples; } +fn writeFileF64(file: std.fs.File, data: []const f64) !void { + try file.seekTo(0); + + for (data) |number| { + const number_bytes = std.mem.asBytes(&number); + try file.writeAll(number_bytes); + } +} + pub fn loadFile(self: *App, id: Id) !void { const file = self.getFile(id) orelse return; file.clear(self.allocator); @@ -1184,6 +1289,22 @@ fn getAllowedSampleRate(self: *App, id: Id) !RangeF64 { return RangeF64.init(min_sample_rate, max_sample_rate); } +fn loadSavedSamples(self: *App, channel_id: Id) !void { + const channel = self.getChannel(channel_id) orelse return; + const saved_samples_location = channel.saved_collected_samples orelse return; + + const dir = std.fs.cwd(); + + const samples_file = try dir.openFile(saved_samples_location, .{ }); + defer samples_file.close(); + + const samples = try readFileF64(self.allocator, samples_file); + channel.collected_samples = std.ArrayListUnmanaged(f64).fromOwnedSlice(samples); + + const stat = try dir.statFile(saved_samples_location); + channel.last_sample_save_at = stat.mtime; +} + pub fn loadChannel(self: *App, id: Id) !void { const channel = self.getChannel(id) orelse return; channel.clear(self.allocator); @@ -1201,6 +1322,10 @@ pub fn loadChannel(self: *App, id: Id) !void { log.err("Failed to get allowed sample rate: {}", .{e}); break :blk null; }; + + self.loadSavedSamples(id) catch |e| { + log.warn("Failed to load saved samples on channel: {}", .{e}); + }; } pub fn isChannelOutputing(self: *App, id: Id) bool { diff --git a/src/assets.zig b/src/assets.zig index b404413..fc73e89 100644 --- a/src/assets.zig +++ b/src/assets.zig @@ -49,6 +49,7 @@ pub var fullscreen: rl.Texture2D = undefined; pub var output_generation: rl.Texture2D = undefined; pub var checkbox_mark: rl.Texture2D = undefined; pub var cross: rl.Texture2D = undefined; +pub var file: rl.Texture2D = undefined; pub fn font(font_id: FontId) FontFace { var found_font: ?LoadedFont = null; @@ -125,6 +126,7 @@ pub fn init(allocator: std.mem.Allocator) !void { output_generation = try loadTextureFromAseprite(allocator, @embedFile("./assets/output-generation-icon.ase")); checkbox_mark = try loadTextureFromAseprite(allocator, @embedFile("./assets/checkbox-mark.ase")); cross = try loadTextureFromAseprite(allocator, @embedFile("./assets/cross.ase")); + file = try loadTextureFromAseprite(allocator, @embedFile("./assets/file.ase")); } fn loadTextureFromAseprite(allocator: std.mem.Allocator, memory: []const u8) !rl.Texture { diff --git a/src/components/systems/view_controls.zig b/src/components/systems/view_controls.zig index 56b19a2..b4a69a7 100644 --- a/src/components/systems/view_controls.zig +++ b/src/components/systems/view_controls.zig @@ -36,7 +36,12 @@ pub const ViewAxisPosition = struct { if (self.axis != axis) return null; const view = project.views.get(view_id) orelse return null; - if (!view.sync_controls) { + if (view.sync_controls) { + const owner_view = project.views.get(self.view_id).?; + if (!owner_view.sync_controls) { + return null; + } + } else { if (!self.view_id.eql(view_id)) { return null; } diff --git a/src/graph.zig b/src/graph.zig index b706da6..266ccc4 100644 --- a/src/graph.zig +++ b/src/graph.zig @@ -50,8 +50,7 @@ pub const MinMaxCache = struct { max: f64 }; - const chunk_size = 256; - + chunk_size: usize = 256, min_max_pairs: std.ArrayListUnmanaged(MinMaxPair) = .{}, sample_count: usize = 0, @@ -60,8 +59,9 @@ pub const MinMaxCache = struct { self.sample_count = 0; } - fn getMinMaxPair(chunk: []const f64) MinMaxPair { + fn getMinMaxPair(self: *MinMaxCache, chunk: []const f64) MinMaxPair { assert(chunk.len > 0); + assert(chunk.len <= self.chunk_size); var min_sample = chunk[0]; var max_sample = chunk[0]; @@ -81,9 +81,9 @@ pub const MinMaxCache = struct { if (samples.len == 0) return; - var iter = std.mem.window(f64, samples, chunk_size, chunk_size); + var iter = std.mem.window(f64, samples, self.chunk_size, self.chunk_size); while (iter.next()) |chunk| { - try self.min_max_pairs.append(allocator, getMinMaxPair(chunk)); + try self.min_max_pairs.append(allocator, self.getMinMaxPair(chunk)); } self.sample_count = samples.len; @@ -97,16 +97,16 @@ pub const MinMaxCache = struct { return; } - const from_chunk = @divFloor(self.sample_count, chunk_size); - const to_chunk = @divFloor(samples.len - 1, chunk_size); + const from_chunk = @divFloor(self.sample_count, self.chunk_size); + const to_chunk = @divFloor(samples.len - 1, self.chunk_size); for (from_chunk..(to_chunk+1)) |i| { const chunk = samples[ - (chunk_size*i)..(@min(chunk_size*(i+1), samples.len)) + (self.chunk_size*i)..(@min(self.chunk_size*(i+1), samples.len)) ]; - const min_max_pair = getMinMaxPair(chunk); + const min_max_pair = self.getMinMaxPair(chunk); - if (i <= self.min_max_pairs.items.len) { + if (i >= self.min_max_pairs.items.len) { try self.min_max_pairs.append(allocator, min_max_pair); } else { self.min_max_pairs.items[i] = min_max_pair; @@ -115,6 +115,39 @@ pub const MinMaxCache = struct { self.sample_count = samples.len; } + + test { + const allocator = std.testing.allocator; + var min_max_cache: MinMaxCache = .{ .chunk_size = 4 }; + defer min_max_cache.deinit(allocator); + + try min_max_cache.updateLast(allocator, &.{ 1, 2 }); + try std.testing.expectEqualSlices( + MinMaxPair, + &.{ + MinMaxPair{ .min = 1, .max = 2 } + }, + min_max_cache.min_max_pairs.items + ); + + try min_max_cache.updateLast(allocator, &.{ 1, 2, 3 }); + try std.testing.expectEqualSlices( + MinMaxPair, + &.{ + MinMaxPair{ .min = 1, .max = 3 } + }, + min_max_cache.min_max_pairs.items + ); + + try min_max_cache.updateLast(allocator, &.{ 1, 2, 3, -1 }); + try std.testing.expectEqualSlices( + MinMaxPair, + &.{ + MinMaxPair{ .min = -1, .max = 3 } + }, + min_max_cache.min_max_pairs.items + ); + } }; pub const RenderCache = struct { @@ -269,8 +302,8 @@ fn drawSamplesMinMax(draw_rect: rl.Rectangle, options: ViewOptions, min_max_cach var column_start: usize = @intFromFloat(i); var column_end: usize = @intFromFloat(i + samples_per_column); - column_start = @divFloor(column_start, MinMaxCache.chunk_size); - column_end = @divFloor(column_end, MinMaxCache.chunk_size); + column_start = @divFloor(column_start, min_max_cache.chunk_size); + column_end = @divFloor(column_end, min_max_cache.chunk_size); const min_max_pairs = min_max_cache.min_max_pairs.items[column_start..column_end]; if (min_max_pairs.len == 0) continue; @@ -304,7 +337,7 @@ fn drawSamplesMinMax(draw_rect: rl.Rectangle, options: ViewOptions, min_max_cach } } -fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64, min_max_level: ?MinMaxCache) void { +fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64, min_max_cache: ?MinMaxCache) void { const x_range = options.x_range; if (x_range.lower >= @as(f64, @floatFromInt(samples.len))) return; @@ -312,8 +345,8 @@ fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f const samples_per_column = x_range.size() / draw_rect.width; if (samples_per_column >= 2) { - if (min_max_level != null and samples_per_column > 2*MinMaxCache.chunk_size) { - drawSamplesMinMax(draw_rect, options, min_max_level.?); + if (min_max_cache != null and samples_per_column > @as(f64, @floatFromInt(2*min_max_cache.?.chunk_size))) { + drawSamplesMinMax(draw_rect, options, min_max_cache.?); } else { drawSamplesApproximate(draw_rect, options, samples); } @@ -392,4 +425,8 @@ pub fn draw(cache: ?*RenderCache, draw_rect: rl.Rectangle, options: ViewOptions, } else { drawSamples(draw_rect, options, samples, null); } +} + +test { + _ = MinMaxCache; } \ No newline at end of file diff --git a/src/main.zig b/src/main.zig index 1f5392a..09a8b9f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,7 +4,7 @@ const builtin = @import("builtin"); const Application = @import("./app.zig"); const Assets = @import("./assets.zig"); const Profiler = @import("./my-profiler.zig"); -const Platform = @import("./platform.zig"); +const Platform = @import("./platform/root.zig"); const raylib_h = @cImport({ @cInclude("stdio.h"); @cInclude("raylib.h"); @@ -65,18 +65,19 @@ fn raylibTraceLogCallback(logType: c_int, text: [*c]const u8, args: raylib_h.va_ } pub fn main() !void { - Platform.init(); - - // TODO: Setup logging to a file - raylib_h.SetTraceLogCallback(raylibTraceLogCallback); - rl.setTraceLogLevel(toRaylibLogLevel(std.options.log_level)); - var gpa = std.heap.GeneralPurposeAllocator(.{ .thread_safe = true }){}; const allocator = gpa.allocator(); defer _ = gpa.deinit(); + Platform.init(allocator); + defer Platform.deinit(allocator); + + // TODO: Setup logging to a file + raylib_h.SetTraceLogCallback(raylibTraceLogCallback); + rl.setTraceLogLevel(toRaylibLogLevel(std.options.log_level)); + const icon_png = @embedFile("./assets/icon.png"); var icon_image = rl.loadImageFromMemory(".png", icon_png); defer icon_image.unload(); @@ -205,4 +206,5 @@ pub fn main() !void { test { _ = @import("./ni-daq/root.zig"); _ = @import("./range.zig"); + _ = @import("./graph.zig"); } \ No newline at end of file diff --git a/src/platform.zig b/src/platform.zig deleted file mode 100644 index 70fa8f4..0000000 --- a/src/platform.zig +++ /dev/null @@ -1,135 +0,0 @@ -const std = @import("std"); -const rl = @import("raylib"); -const builtin = @import("builtin"); -const windows_h = @cImport({ - @cDefine("_WIN32_WINNT", "0x0500"); - @cInclude("windows.h"); -}); - -const assert = std.debug.assert; -const log = std.log.scoped(.platform); - -// Because `windows_h.HWND` has an alignment of 4, -// we need to redefined every struct that uses `HWND` if we want to change the alignment of `HWND`. -// Ugh... WHYYYYYY -const HWND = [*c]align(2) windows_h.struct_HWND__; -const OPENFILENAMEW = extern struct { - lStructSize: windows_h.DWORD, - hwndOwner: HWND, - hInstance: windows_h.HINSTANCE, - lpstrFilter: windows_h.LPCWSTR, - lpstrCustomFilter: windows_h.LPWSTR, - nMaxCustFilter: windows_h.DWORD, - nFilterIndex: windows_h.DWORD, - lpstrFile: windows_h.LPWSTR, - nMaxFile: windows_h.DWORD, - lpstrFileTitle: windows_h.LPWSTR, - nMaxFileTitle: windows_h.DWORD, - lpstrInitialDir: windows_h.LPCWSTR, - lpstrTitle: windows_h.LPCWSTR, - Flags: windows_h.DWORD, - nFileOffset: windows_h.WORD, - nFileExtension: windows_h.WORD, - lpstrDefExt: windows_h.LPCWSTR, - lCustData: windows_h.LPARAM, - lpfnHook: windows_h.LPOFNHOOKPROC, - lpTemplateName: windows_h.LPCWSTR, - pvReserved: ?*anyopaque, - dwReserved: windows_h.DWORD, - FlagsEx: windows_h.DWORD, -}; -extern fn GetOpenFileNameW([*c]OPENFILENAMEW) windows_h.WINBOOL; - -fn printLastWindowsError(function_name: []const u8) void { - const err = windows_h.GetLastError(); - if (err == 0) { - return; - } - - var message: [*c]u8 = null; - - // TODO: Use `FormatMessageW` - const size = windows_h.FormatMessageA( - windows_h.FORMAT_MESSAGE_ALLOCATE_BUFFER | windows_h.FORMAT_MESSAGE_FROM_SYSTEM | windows_h.FORMAT_MESSAGE_IGNORE_INSERTS, - null, - err, - windows_h.MAKELANGID(windows_h.LANG_ENGLISH, windows_h.SUBLANG_ENGLISH_US), - @ptrCast(&message), - 0, - null - ); - log.err("{s}() failed ({}): {s}", .{ function_name, err, message[0..size] }); - - _ = windows_h.LocalFree(message); -} - -pub fn toggleConsoleWindow() void { - if (builtin.os.tag != .windows) { - // TODO: Maybe just toggle outputing or not outputing to terminal on linux? - return; - } - - var hWnd = windows_h.GetConsoleWindow(); - if (hWnd == null) { - if (windows_h.AllocConsole() == 0) { - printLastWindowsError("AllocConsole"); - return; - } - - hWnd = windows_h.GetConsoleWindow(); - assert(hWnd != null); - } - - if (windows_h.IsWindowVisible(hWnd) != 0) { - _ = windows_h.ShowWindow(hWnd, windows_h.SW_HIDE); - } else { - _ = windows_h.ShowWindow(hWnd, windows_h.SW_SHOWNOACTIVATE); - } -} - -// TODO: Maybe return the file path instead of an opened file handle? -// So the user of this function could do something more interesting. -pub fn openFilePicker(allocator: std.mem.Allocator) ![]u8 { - if (builtin.os.tag != .windows) { - return error.NotSupported; - } - - const hWnd: HWND = @alignCast(@ptrCast(rl.getWindowHandle())); - assert(hWnd != null); - - var ofn = std.mem.zeroes(OPENFILENAMEW); - var filename_w_buffer = std.mem.zeroes([std.os.windows.PATH_MAX_WIDE]u16); - - // Zig doesn't let you have NULL bytes in the middle of a string literal, so... - // I guess you are forced to do this kind of string concatenation to insert those NULL bytes - const lpstrFilter = "All" ++ .{ 0 } ++ "*" ++ .{ 0 } ++ "Binary" ++ .{ 0 } ++ "*.bin" ++ .{ 0 }; - - ofn.lStructSize = @sizeOf(@TypeOf(ofn)); - ofn.hwndOwner = hWnd; - ofn.lpstrFile = &filename_w_buffer; - ofn.nMaxFile = filename_w_buffer.len; - ofn.lpstrFilter = std.unicode.utf8ToUtf16LeStringLiteral(lpstrFilter); - ofn.nFilterIndex = 2; - ofn.Flags = windows_h.OFN_PATHMUSTEXIST | windows_h.OFN_FILEMUSTEXIST | windows_h.OFN_EXPLORER | windows_h.OFN_LONGNAMES; - - if (GetOpenFileNameW(&ofn) != windows_h.TRUE) { - const err = windows_h.CommDlgExtendedError(); - if (err == err) { - return error.Canceled; - } - - log.err("GetOpenFileNameW() failed, erro code: {}", .{ err }); - return error.GetOpenFileNameW; - } - - const filename_len = std.mem.indexOfScalar(u16, &filename_w_buffer, 0).?; - const filename_w = filename_w_buffer[0..filename_len]; - - return try std.fmt.allocPrint(allocator, "{s}", .{std.fs.path.fmtWtf16LeAsUtf8Lossy(filename_w)}); -} - -pub fn init() void { - if (builtin.os.tag == .windows) { - _ = windows_h.SetConsoleOutputCP(65001); - } -} \ No newline at end of file diff --git a/src/platform/root.zig b/src/platform/root.zig new file mode 100644 index 0000000..2a95cc4 --- /dev/null +++ b/src/platform/root.zig @@ -0,0 +1,103 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const Allocator = std.mem.Allocator; +const log = std.log.scoped(.platform); + +const OSPlatform = switch (builtin.os.tag) { + .windows => @import("windows.zig"), + else => @panic("Platform layer doesn't support OS") +}; + +var impl: OSPlatform = undefined; + +pub const OpenFileOptions = struct { + pub const Filter = struct { + const Name = std.BoundedArray(u8, 32); + const Format = std.BoundedArray(u8, 32); + + name: Name = .{}, + format: Format = .{} + }; + + pub const Style = enum { open, save }; + + filters: std.BoundedArray(Filter, 4) = .{}, + default_filter: ?usize = null, + file_must_exist: bool = false, + prompt_creation: bool = false, + prompt_overwrite: bool = false, + style: Style = .open, + + pub fn appendFilter(self: *OpenFileOptions, name: []const u8, format: []const u8) !void { + try self.filters.append(Filter{ + .name = try Filter.Name.fromSlice(name), + .format = try Filter.Format.fromSlice(format) + }); + } +}; + +pub const FilePickerId = usize; +pub const FilePickerStatus = enum { + in_progress, + success, + canceled, + failure +}; + +pub fn toggleConsoleWindow() void { + impl.toggleConsoleWindow(); +} + +pub fn spawnFilePicker(opts: *const OpenFileOptions) !FilePickerId { + return impl.spawnFilePicker(opts); +} + +pub fn isFilePickerOpen() bool { + return impl.isFilePickerOpen(); +} + +pub fn checkFilePicker(id: FilePickerId) ?FilePickerStatus { + return impl.checkFilePicker(id); +} + +pub fn getFilePickerPath(allocator: std.mem.Allocator, id: FilePickerId) ![]u8 { + return impl.getFilePickerPath(allocator, id); +} + +pub fn waitUntilFilePickerDone(allocator: std.mem.Allocator, optional_id: *?FilePickerId) ?[]u8 { + const id = optional_id.* orelse return null; + + const status = checkFilePicker(id) orelse { + optional_id.* = null; + return null; + }; + + if (status == .in_progress) { + return null; + } + + defer optional_id.* = null; + + if (status == .success) { + if (getFilePickerPath(allocator, id)) |path| { + return path; + } else |e| { + log.err("getFilePickerPath(): {}", .{ e }); + } + } else if (status == .canceled) { + // Ignore this + } else if (status == .failure) { + log.err("Unknown error occured while file picker was open", .{ }); + } + + return null; +} + +pub fn init(allocator: std.mem.Allocator) void { + impl.init(allocator); +} + +pub fn deinit(allocator: std.mem.Allocator) void { + impl.deinit(allocator); +} \ No newline at end of file diff --git a/src/platform/windows.zig b/src/platform/windows.zig new file mode 100644 index 0000000..27f18fd --- /dev/null +++ b/src/platform/windows.zig @@ -0,0 +1,303 @@ +const std = @import("std"); +const rl = @import("raylib"); +const Platform = @import("root.zig"); +const windows_h = @cImport({ + @cDefine("_WIN32_WINNT", "0x0500"); + @cInclude("windows.h"); + @cInclude("shobjidl.h"); +}); + +const assert = std.debug.assert; +const log = std.log.scoped(.platform); + +const OpenFileOptions = Platform.OpenFileOptions; +const FilePickerId = Platform.FilePickerId; +const FilePickerStatus = Platform.FilePickerStatus; + +const Self = @This(); + +// Because `windows_h.HWND` has an alignment of 4, +// we need to redefined every struct that uses `HWND` if we want to change the alignment of `HWND`. +// Ugh... WHYYYYYY +const HWND = [*c]align(2) windows_h.struct_HWND__; +const OPENFILENAMEW = extern struct { + lStructSize: windows_h.DWORD, + hwndOwner: HWND, + hInstance: windows_h.HINSTANCE, + lpstrFilter: windows_h.LPCWSTR, + lpstrCustomFilter: windows_h.LPWSTR, + nMaxCustFilter: windows_h.DWORD, + nFilterIndex: windows_h.DWORD, + lpstrFile: windows_h.LPWSTR, + nMaxFile: windows_h.DWORD, + lpstrFileTitle: windows_h.LPWSTR, + nMaxFileTitle: windows_h.DWORD, + lpstrInitialDir: windows_h.LPCWSTR, + lpstrTitle: windows_h.LPCWSTR, + Flags: windows_h.DWORD, + nFileOffset: windows_h.WORD, + nFileExtension: windows_h.WORD, + lpstrDefExt: windows_h.LPCWSTR, + lCustData: windows_h.LPARAM, + lpfnHook: windows_h.LPOFNHOOKPROC, + lpTemplateName: windows_h.LPCWSTR, + pvReserved: ?*anyopaque, + dwReserved: windows_h.DWORD, + FlagsEx: windows_h.DWORD, +}; +extern fn GetOpenFileNameW([*c]OPENFILENAMEW) windows_h.WINBOOL; +extern fn GetSaveFileNameW([*c]OPENFILENAMEW) windows_h.WINBOOL; + +const OpenedFilePicker = struct { + style: OpenFileOptions.Style, + filename_w_buffer: [std.os.windows.PATH_MAX_WIDE]u16, + lpstrFilter_utf16: [:0]u16, + ofn: OPENFILENAMEW, + thread: std.Thread, + status: Platform.FilePickerStatus, + + fn spawn(self: *OpenedFilePicker, windows_platform: *Self, opts: *const OpenFileOptions) !void { + const allocator = windows_platform.allocator; + + self.* = OpenedFilePicker{ + .ofn = std.mem.zeroes(OPENFILENAMEW), + .style = opts.style, + .filename_w_buffer = undefined, + .lpstrFilter_utf16 = undefined, + .thread = undefined, + .status = Platform.FilePickerStatus.in_progress + }; + + const hWnd: HWND = @alignCast(@ptrCast(rl.getWindowHandle())); + assert(hWnd != null); + + var lpstrFilter_utf8: std.ArrayListUnmanaged(u8) = .{}; + defer lpstrFilter_utf8.deinit(allocator); + + for (opts.filters.constSlice()) |filter| { + try lpstrFilter_utf8.appendSlice(allocator, filter.name.slice()); + try lpstrFilter_utf8.append(allocator, 0); + try lpstrFilter_utf8.appendSlice(allocator, filter.format.slice()); + try lpstrFilter_utf8.append(allocator, 0); + } + + self.lpstrFilter_utf16 = try std.unicode.utf8ToUtf16LeWithNull(allocator, lpstrFilter_utf8.items); + errdefer allocator.free(self.lpstrFilter_utf16); + + var flags: c_ulong = 0; + { + flags |= windows_h.OFN_NOCHANGEDIR; + flags |= windows_h.OFN_PATHMUSTEXIST; + flags |= windows_h.OFN_EXPLORER; + flags |= windows_h.OFN_LONGNAMES; + flags |= windows_h.OFN_ENABLESIZING; + + if (opts.file_must_exist) { + flags |= windows_h.OFN_FILEMUSTEXIST; + } + if (opts.prompt_creation) { + flags |= windows_h.OFN_CREATEPROMPT; + } + if (opts.prompt_overwrite) { + flags |= windows_h.OFN_OVERWRITEPROMPT; + } + } + + var nFilterIndex: c_ulong = 0; + if (opts.default_filter) |default_filter| { + assert(default_filter < opts.filters.len); + nFilterIndex = @intCast(default_filter + 1); + } + + @memset(&self.filename_w_buffer, 0); + self.ofn.lStructSize = @sizeOf(@TypeOf(self.ofn)); + self.ofn.hwndOwner = hWnd; + self.ofn.lpstrFile = &self.filename_w_buffer; + self.ofn.nMaxFile = self.filename_w_buffer.len; + self.ofn.lpstrFilter = self.lpstrFilter_utf16; + self.ofn.nFilterIndex = nFilterIndex; + self.ofn.Flags = flags; + + // Creating this thread must be the last thing that this function does. + // Because if an error occurs after a thread is spawn, it will not be joined/stopped + self.thread = try std.Thread.spawn(.{}, threadCallback, .{ self }); + } + + fn deinit(self: *OpenedFilePicker, allocator: std.mem.Allocator) void { + self.thread.join(); + allocator.free(self.lpstrFilter_utf16); + } + + fn showDialog(self: *OpenedFilePicker) !void { + const result = switch (self.style) { + .open => GetOpenFileNameW(&self.ofn), + .save => GetSaveFileNameW(&self.ofn) + }; + + if (result != windows_h.TRUE) { + const err = windows_h.CommDlgExtendedError(); + if (err == 0) { + return error.Canceled; + } + + // From the MSDN docs, these 3 error should only be able to occur + // Source: https://learn.microsoft.com/en-us/windows/win32/api/commdlg/nf-commdlg-commdlgextendederror + if (err == windows_h.FNERR_BUFFERTOOSMALL) { + return error.BufferTooSmall; + + } else if (err == windows_h.FNERR_INVALIDFILENAME) { + return error.InvalidFilename; + + } else if (err == windows_h.FNERR_SUBCLASSFAILURE) { + return error.OutOfMemory; + + } else { + log.err("Unknown file picker error occured: {}", .{ err }); + return error.UnknownFileDialogError; + } + } + } + + fn threadCallback(self: *OpenedFilePicker) void { + self.showDialog() catch |e| { + switch (e) { + error.Canceled => { + self.status = .canceled; + return; + }, + else => { + log.err("Windows file picker error: {}", .{ e }); + self.status = .failure; + return; + } + } + }; + + self.status = .success; + } +}; + +allocator: std.mem.Allocator, +opened_file_pickers: [8]?OpenedFilePicker = [1]?OpenedFilePicker{ null } ** 8, + +fn printLastWindowsError(function_name: []const u8) void { + const err = windows_h.GetLastError(); + if (err == 0) { + return; + } + + var message: [*c]u8 = null; + + // TODO: Use `FormatMessageW` + const size = windows_h.FormatMessageA( + windows_h.FORMAT_MESSAGE_ALLOCATE_BUFFER | windows_h.FORMAT_MESSAGE_FROM_SYSTEM | windows_h.FORMAT_MESSAGE_IGNORE_INSERTS, + null, + err, + windows_h.MAKELANGID(windows_h.LANG_ENGLISH, windows_h.SUBLANG_ENGLISH_US), + @ptrCast(&message), + 0, + null + ); + log.err("{s}() failed ({}): {s}", .{ function_name, err, message[0..size] }); + + _ = windows_h.LocalFree(message); +} + +pub fn toggleConsoleWindow(self: *Self) void { + _ = self; + + var hWnd = windows_h.GetConsoleWindow(); + if (hWnd == null) { + if (windows_h.AllocConsole() == 0) { + printLastWindowsError("AllocConsole"); + return; + } + + hWnd = windows_h.GetConsoleWindow(); + assert(hWnd != null); + } + + if (windows_h.IsWindowVisible(hWnd) != 0) { + _ = windows_h.ShowWindow(hWnd, windows_h.SW_HIDE); + } else { + _ = windows_h.ShowWindow(hWnd, windows_h.SW_SHOWNOACTIVATE); + } +} + +fn getFreeOpenedFilePicker(self: *Self) ?FilePickerId { + for (0.., &self.opened_file_pickers) |i, *opened_file_picker| { + if (opened_file_picker.* == null) { + return i; + } + } + + for (0.., &self.opened_file_pickers) |i, *opened_file_picker| { + if (opened_file_picker.*) |*file_picker| { + if (file_picker.status != .in_progress) { + file_picker.deinit(self.allocator); + opened_file_picker.* = null; + + return i; + } + } + } + + return null; +} + +pub fn spawnFilePicker(self: *Self, opts: *const OpenFileOptions) !FilePickerId { + const file_picker_id = self.getFreeOpenedFilePicker() orelse return error.MaxFilePickersReached; + + const opened_file_picker = &self.opened_file_pickers[file_picker_id]; + opened_file_picker.* = undefined; + + try OpenedFilePicker.spawn(&(opened_file_picker.*.?), self, opts); + errdefer opened_file_picker.* = null; + + return file_picker_id; +} + +pub fn checkFilePicker(self: *Self, id: FilePickerId) ?FilePickerStatus { + const file_picker = &(self.opened_file_pickers[id] orelse return null); + return file_picker.status; +} + +pub fn getFilePickerPath(self: *Self, allocator: std.mem.Allocator, id: FilePickerId) ![]u8 { + const file_picker = &(self.opened_file_pickers[id] orelse return error.FilePickerNotFound); + + const filename_w_buffer = file_picker.filename_w_buffer; + + const filename_len = std.mem.indexOfScalar(u16, &filename_w_buffer, 0).?; + const filename_w = filename_w_buffer[0..filename_len]; + + return try std.fmt.allocPrint(allocator, "{s}", .{std.fs.path.fmtWtf16LeAsUtf8Lossy(filename_w)}); +} + +pub fn isFilePickerOpen(self: *Self) bool { + for (&self.opened_file_pickers) |*maybe_file_picker| { + const file_picker = &(maybe_file_picker.* orelse continue); + + if (file_picker.status == .in_progress) { + return true; + } + } + + return false; +} + +pub fn init(self: *Self, allocator: std.mem.Allocator) void { + _ = windows_h.SetConsoleOutputCP(65001); + + self.* = Self{ + .allocator = allocator + }; +} + +pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { + for (&self.opened_file_pickers) |*maybe_opened_file_picker| { + if (maybe_opened_file_picker.*) |*opened_file_picker| { + opened_file_picker.deinit(allocator); + maybe_opened_file_picker.* = null; + } + } +} \ No newline at end of file diff --git a/src/screens/main_screen.zig b/src/screens/main_screen.zig index 60333c5..fe53da7 100644 --- a/src/screens/main_screen.zig +++ b/src/screens/main_screen.zig @@ -3,7 +3,7 @@ const rl = @import("raylib"); const UI = @import("../ui.zig"); const App = @import("../app.zig"); const srcery = @import("../srcery.zig"); -const Platform = @import("../platform.zig"); +const Platform = @import("../platform/root.zig"); const RangeF64 = @import("../range.zig").RangeF64; const Graph = @import("../graph.zig"); const Assets = @import("../assets.zig"); @@ -35,6 +35,9 @@ preview_samples_y_range: RangeF64 = RangeF64.init(0, 0), sample_rate_input: UI.TextInputStorage, parsed_sample_rate: ?f64 = null, +// View settings +channel_save_file_picker: ?Platform.FilePickerId = null, + pub fn init(app: *App) !MainScreen { const allocator = app.allocator; @@ -239,9 +242,137 @@ fn clearProtocolErrorMessage(self: *MainScreen) void { self.protocol_error_message = null; } -pub fn showSidePanel(self: *MainScreen) !void { +fn showProjectSettings(self: *MainScreen) !void { var ui = &self.app.ui; const frame_allocator = ui.frameAllocator(); + const project = &self.app.project; + + { + const label = ui.label("Project", .{}); + label.borders.bottom = .{ + .color = srcery.bright_white, + .size = 1 + }; + } + + _ = ui.createBox(.{ .size_y = UI.Sizing.initFixedPixels(ui.rem(1)) }); + + { // Sample rate + var placeholder: ?[]const u8 = null; + if (project.getDefaultSampleRate()) |default_sample_rate| { + placeholder = try std.fmt.allocPrint(frame_allocator, "{d}", .{ default_sample_rate }); + } + + var initial: ?[]const u8 = null; + if (project.sample_rate) |selected_sample_rate| { + initial = try std.fmt.allocPrint(frame_allocator, "{d}", .{ selected_sample_rate }); + } + + _ = ui.label("Sample rate", .{}); + self.parsed_sample_rate = try ui.numberInput(f64, .{ + .key = ui.keyFromString("Sample rate input"), + .storage = &self.sample_rate_input, + .placeholder = placeholder, + .initial = initial, + .invalid = self.parsed_sample_rate != project.sample_rate, + .editable = !self.app.isCollectionInProgress() + }); + project.sample_rate = self.parsed_sample_rate; + + if (project.getAllowedSampleRates()) |allowed_sample_rates| { + if (project.sample_rate) |selected_sample_rate| { + if (!allowed_sample_rates.hasInclusive(selected_sample_rate)) { + project.sample_rate = null; + } + } + } + } + + _ = ui.checkbox(.{ + .value = &project.show_rulers, + .label = "Ruler" + }); +} + +fn showViewSettings(self: *MainScreen, view_id: Id) !void { + var ui = &self.app.ui; + + const project = &self.app.project; + const sample_rate = project.getSampleRate(); + const view = project.views.get(view_id) orelse return; + + { + const label = ui.label("Settings", .{}); + label.borders.bottom = .{ + .color = srcery.bright_white, + .size = 1 + }; + + _ = ui.createBox(.{ .size_y = UI.Sizing.initFixedPixels(ui.rem(1)) }); + } + + _ = ui.checkbox(.{ + .value = &view.sync_controls, + .label = "Sync controls" + }); + + var sample_count: ?usize = null; + switch (view.reference) { + .channel => |channel_id| { + const channel = project.channels.get(channel_id).?; + const channel_name = utils.getBoundedStringZ(&channel.name); + const channel_type = NIDaq.getChannelType(channel_name); + const samples = channel.collected_samples.items; + + _ = ui.label("Channel: {s}", .{ channel_name }); + + if (channel_type != null) { + _ = ui.label("Type: {s}", .{ channel_type.?.name() }); + } else { + _ = ui.label("Type: unknown", .{ }); + } + + if (ui.fileInput(.{ + .key = ui.keyFromString("Save location"), + .allocator = self.app.allocator, + .file_picker = &self.channel_save_file_picker, + .path = channel.saved_collected_samples + })) |path| { + if (channel.saved_collected_samples) |current_path| { + self.app.allocator.free(current_path); + } + + channel.saved_collected_samples = path; + } + + sample_count = samples.len; + }, + .file => |file_id| { + const file = project.files.get(file_id).?; + + if (file.samples) |samples| { + sample_count = samples.len; + } + } + + } + + if (sample_count != null) { + _ = ui.label("Samples: {d}", .{ sample_count.? }); + + var duration_str: []const u8 = "-"; + if (sample_rate != null) { + const duration = @as(f64, @floatFromInt(sample_count.?)) / sample_rate.?; + if (utils.formatDuration(ui.frameAllocator(), duration)) |str| { + duration_str = str; + } else |_| {} + } + _ = ui.label("Duration: {s}", .{ duration_str }); + } +} + +pub fn showSidePanel(self: *MainScreen) !void { + var ui = &self.app.ui; const container = ui.createBox(.{ .size_x = UI.Sizing.initGrowUpTo(.{ .parent_percent = 0.2 }), @@ -256,114 +387,10 @@ pub fn showSidePanel(self: *MainScreen) !void { container.beginChildren(); defer container.endChildren(); - const project = &self.app.project; - const sample_rate = project.getSampleRate(); - if (self.view_controls.view_settings) |view_id| { - const view = project.views.get(view_id) orelse return; - - { - const label = ui.label("Settings", .{}); - label.borders.bottom = .{ - .color = srcery.bright_white, - .size = 1 - }; - } - - _ = ui.createBox(.{ .size_y = UI.Sizing.initFixedPixels(ui.rem(1)) }); - - _ = ui.checkbox(.{ - .value = &view.sync_controls, - .label = "Sync controls" - }); - - var sample_count: ?usize = null; - switch (view.reference) { - .channel => |channel_id| { - const channel = project.channels.get(channel_id).?; - const channel_name = utils.getBoundedStringZ(&channel.name); - const channel_type = NIDaq.getChannelType(channel_name); - const samples = channel.collected_samples.items; - - _ = ui.label("Channel: {s}", .{ channel_name }); - - if (channel_type != null) { - _ = ui.label("Type: {s}", .{ channel_type.?.name() }); - } else { - _ = ui.label("Type: unknown", .{ }); - } - - sample_count = samples.len; - }, - .file => |file_id| { - const file = project.files.get(file_id).?; - - if (file.samples) |samples| { - sample_count = samples.len; - } - } - - } - - if (sample_count != null) { - _ = ui.label("Samples: {d}", .{ sample_count.? }); - - var duration_str: []const u8 = "-"; - if (sample_rate != null) { - const duration = @as(f64, @floatFromInt(sample_count.?)) / sample_rate.?; - if (utils.formatDuration(ui.frameAllocator(), duration)) |str| { - duration_str = str; - } else |_| {} - } - _ = ui.label("Duration: {s}", .{ duration_str }); - } - + try self.showViewSettings(view_id); } else { - { - const label = ui.label("Project", .{}); - label.borders.bottom = .{ - .color = srcery.bright_white, - .size = 1 - }; - } - - _ = ui.createBox(.{ .size_y = UI.Sizing.initFixedPixels(ui.rem(1)) }); - - { // Sample rate - var placeholder: ?[]const u8 = null; - if (project.getDefaultSampleRate()) |default_sample_rate| { - placeholder = try std.fmt.allocPrint(frame_allocator, "{d}", .{ default_sample_rate }); - } - - var initial: ?[]const u8 = null; - if (project.sample_rate) |selected_sample_rate| { - initial = try std.fmt.allocPrint(frame_allocator, "{d}", .{ selected_sample_rate }); - } - - _ = ui.label("Sample rate", .{}); - self.parsed_sample_rate = try ui.numberInput(f64, .{ - .key = ui.keyFromString("Sample rate input"), - .storage = &self.sample_rate_input, - .placeholder = placeholder, - .initial = initial, - .invalid = self.parsed_sample_rate != project.sample_rate, - .editable = !self.app.isCollectionInProgress() - }); - project.sample_rate = self.parsed_sample_rate; - - if (project.getAllowedSampleRates()) |allowed_sample_rates| { - if (project.sample_rate) |selected_sample_rate| { - if (!allowed_sample_rates.hasInclusive(selected_sample_rate)) { - project.sample_rate = null; - } - } - } - } - - _ = ui.checkbox(.{ - .value = &project.show_rulers, - .label = "Ruler" - }); + try self.showProjectSettings(); } } diff --git a/src/ui.zig b/src/ui.zig index ecdcefe..77d4d50 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -6,6 +6,7 @@ const rect_utils = @import("./rect-utils.zig"); const utils = @import("./utils.zig"); const builtin = @import("builtin"); const FontFace = @import("./font-face.zig"); +const Platform = @import("./platform/root.zig"); const raylib_h = @cImport({ @cInclude("stdio.h"); @cInclude("raylib.h"); @@ -2272,6 +2273,13 @@ pub const CheckboxOptions = struct { label: ?[]const u8 = null }; +pub const FileInputOptions = struct { + key: Key, + allocator: std.mem.Allocator, + file_picker: *?Platform.FilePickerId, + path: ?[]const u8 = null +}; + pub fn mouseTooltip(self: *UI) *Box { const tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; @@ -2765,4 +2773,64 @@ pub fn checkbox(self: *UI, opts: CheckboxOptions) void { if (container_signal.clicked()) { opts.value.* = !opts.value.*; } +} + +pub fn fileInput(self: *UI, opts: FileInputOptions) ?[]u8 { + var result: ?[]u8 = null; + + const container = self.createBox(.{ + .key = opts.key, + .size_x = Sizing.initGrowUpTo(.{ .pixels = 200 }), + .size_y = Sizing.initFixed(Unit.initPixels(self.rem(1))), + .flags = &.{ .clickable, .clip_view, .draw_hot, .draw_active }, + .background = srcery.bright_white, + .align_y = .center, + .padding = UI.Padding.all(self.rem(0.25)), + .hot_cursor = .mouse_cursor_pointing_hand, + .layout_gap = self.rem(0.5) + }); + container.beginChildren(); + defer container.endChildren(); + + _ = self.createBox(.{ + .texture = Assets.file, + .size_x = UI.Sizing.initFixed(.{ .pixels = 16 }), + .size_y = UI.Sizing.initFixed(.{ .pixels = 16 }) + }); + + const path_box = self.createBox(.{ + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initGrowFull(), + .text_color = srcery.black + }); + + if (opts.path) |path| { + path_box.setText(std.fs.path.basename(path)); + container.tooltip = path; + } else { + path_box.setText(""); + } + + if (opts.file_picker.* != null) { + if (Platform.waitUntilFilePickerDone(opts.allocator, opts.file_picker)) |path| { + result = path; + } + } else { + const container_signal = self.signal(container); + if (container_signal.clicked()) { + var file_open_options: Platform.OpenFileOptions = .{}; + file_open_options.style = .save; + file_open_options.prompt_overwrite = true; + file_open_options.appendFilter("All", "*") catch unreachable; + file_open_options.appendFilter("Binary", "*.bin") catch unreachable; + + if (Platform.spawnFilePicker(&file_open_options)) |file_picker_id| { + opts.file_picker.* = file_picker_id; + } else |e| { + log.err("Failed to open file picker: {}", .{e}); + } + } + } + + return result; } \ No newline at end of file