diff --git a/src/app.zig b/src/app.zig index a85c74e..acf697e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -5,11 +5,9 @@ const rl = @import("raylib"); const srcery = @import("./srcery.zig"); const NIDaq = @import("ni-daq/root.zig"); const Graph = @import("./graph.zig"); -const TaskPool = @import("ni-daq/task-pool.zig"); const utils = @import("./utils.zig"); const Assets = @import("./assets.zig"); const RangeF64 = @import("./range.zig").RangeF64; -const P = @import("profiler"); const MainScreen = @import("./screens/main_screen.zig"); const ChannelFromDeviceScreen = @import("./screens/channel_from_device.zig"); @@ -24,6 +22,16 @@ const max_files = 32; const App = @This(); +const ChannelTask = struct { + task: NIDaq.Task, + sample_rate: f64, + read: bool, + + fn deinit(self: ChannelTask) void { + self.task.clear(); + } +}; + const FileChannel = struct { path: []u8, samples: []f64, @@ -35,21 +43,38 @@ const FileChannel = struct { }; const DeviceChannel = struct { - const Name = std.BoundedArray(u8, NIDaq.max_channel_name_size + 1); // +1 for null byte - name: Name = .{}, + const ChannelName = std.BoundedArray(u8, NIDaq.max_channel_name_size + 1); // +1 for null byte + const Direction = enum { input, output }; + + device_name: NIDaq.BoundedDeviceName = .{}, + channel_name: ChannelName = .{}, samples: std.ArrayList(f64), - units: i32 = NIDaq.c.DAQmx_Val_Volts, + write_pattern: std.ArrayList(f64), + + units: NIDaq.Unit = .Voltage, min_sample_rate: f64, max_sample_rate: f64, min_value: f64, max_value: f64, - active_task: ?*TaskPool.Entry = null, + task: ?ChannelTask = null, fn deinit(self: DeviceChannel) void { self.samples.deinit(); + self.write_pattern.deinit(); + if (self.task) |task| { + task.deinit(); + } + } + + pub fn getDeviceName(self: *DeviceChannel) [:0]const u8 { + return utils.getBoundedStringZ(&self.device_name); + } + + pub fn getChannelName(self: *DeviceChannel) [:0]const u8 { + return utils.getBoundedStringZ(&self.channel_name); } }; @@ -93,21 +118,30 @@ pub const ChannelView = struct { } }; +pub const DeferredChannelAction = union(enum) { + activate: *ChannelView, + deactivate: *ChannelView, + toggle_input_channels +}; + allocator: std.mem.Allocator, should_close: bool = false, ni_daq_api: ?NIDaq.Api = null, ni_daq: ?NIDaq = null, -task_pool: TaskPool, channel_views: std.BoundedArray(ChannelView, max_channels) = .{}, loaded_files: [max_channels]?FileChannel = .{ null } ** max_channels, device_channels: [max_channels]?DeviceChannel = .{ null } ** max_channels, -started_collecting: bool = false, - -channel_mutex: std.Thread.Mutex = .{}, +// Reading & writing tasks +samples_mutex: std.Thread.Mutex = .{}, +device_channels_mutex: std.Thread.Mutex = .{}, +thread_wake_condition: std.Thread.Condition = .{}, +task_read_thread: ?std.Thread = null, +task_read_active: bool = false, // UI Fields ui: UI, +deferred_actions: std.BoundedArray(DeferredChannelAction, max_channels) = .{}, current_screen: enum { main_menu, channel_from_device @@ -119,17 +153,16 @@ pub fn init(self: *App, allocator: std.mem.Allocator) !void { self.* = App{ .allocator = allocator, .ui = UI.init(allocator), - .main_screen = MainScreen{ - .app = self - }, + .main_screen = undefined, .channel_from_device = ChannelFromDeviceScreen{ .app = self, .channel_names = std.heap.ArenaAllocator.init(allocator) }, - .task_pool = undefined, }; errdefer self.deinit(); + self.main_screen = try MainScreen.init(self); + if (NIDaq.Api.init()) |ni_daq_api| { self.ni_daq_api = ni_daq_api; @@ -169,12 +202,22 @@ pub fn init(self: *App, allocator: std.mem.Allocator) !void { } } - try TaskPool.init(&self.task_pool, &self.channel_mutex, allocator); - errdefer self.task_pool.deinit(); + self.task_read_thread = try std.Thread.spawn( + .{ .allocator = allocator }, + readThreadCallback, + .{ self } + ); } pub fn deinit(self: *App) void { - self.task_pool.deinit(); + self.main_screen.deinit(); + + if (self.task_read_thread) |thread| { + self.should_close = true; + self.thread_wake_condition.signal(); + thread.join(); + self.task_read_thread = null; + } for (self.channel_views.slice()) |*channel| { channel.view_cache.deinit(); @@ -241,10 +284,12 @@ pub fn tick(self: *App) !void { var ui = &self.ui; rl.clearBackground(srcery.black); + self.deferred_actions.len = 0; + ui.pullOsEvents(); { - self.channel_mutex.lock(); - defer self.channel_mutex.unlock(); + self.samples_mutex.lock(); + defer self.samples_mutex.unlock(); for (self.listChannelViews()) |*channel_view| { const device_channel = self.getChannelSourceDevice(channel_view) orelse continue; @@ -264,15 +309,89 @@ pub fn tick(self: *App) !void { } } + for (self.deferred_actions.constSlice()) |action| { + switch (action) { + .activate => |channel_view| { + try self.activateDeviceChannel(channel_view); + }, + .deactivate => |channel_view| { + try self.deactivateDeviceChannel(channel_view); + }, + .toggle_input_channels => { + try self.toggleInputDeviceChannels(); + } + } + } + ui.draw(); } +// ---------------------- Reading & Writing tasks ----------------------------- // + +fn readThreadCallback(self: *App) void { + while (!self.should_close) { + var has_tasks_configured = false; + + { + self.device_channels_mutex.lock(); + defer self.device_channels_mutex.unlock(); + + for (&self.device_channels) |*maybe_device_channel| { + const device_channel = &(maybe_device_channel.* orelse continue); + + const channel_task = &(device_channel.task orelse continue); + if (!channel_task.read) { + continue; + } + + has_tasks_configured = true; + + self.readThreadDevice(device_channel) catch |e| { + log.err("Failed to read samples in thread: {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + + if (device_channel.task) |task| { + task.deinit(); + device_channel.task = null; + } + continue; + }; + } + + if (!has_tasks_configured) { + self.thread_wake_condition.wait(&self.device_channels_mutex); + } + } + + std.time.sleep(5 * std.time.ns_per_ms); + } +} + +fn readThreadDevice(self: *App, device_channel: *DeviceChannel) !void { + self.samples_mutex.lock(); + defer self.samples_mutex.unlock(); + + const channel_task = &device_channel.task.?; + assert(channel_task.read); + + try device_channel.samples.ensureUnusedCapacity(@intFromFloat(@ceil(channel_task.sample_rate))); + + const read_amount = try channel_task.task.readAnalog(.{ + .timeout = 0, + .read_array = device_channel.samples.unusedCapacitySlice() + }); + + device_channel.samples.items.len += read_amount; +} + // ------------------------ Channel management -------------------------------- // pub fn getChannelDeviceByName(self: *App, name: []const u8) ?*DeviceChannel { for (&self.device_channels) |*maybe_channel| { var channel: *DeviceChannel = &(maybe_channel.* orelse continue); - if (std.mem.eql(u8, channel.name.slice(), name)) { + if (std.mem.eql(u8, channel.channel_name.slice(), name)) { return channel; } } @@ -301,64 +420,128 @@ pub fn getChannelSourceDevice(self: *App, channel_view: *ChannelView) ?*DeviceCh return null; } -pub fn isDeviceChannelReading(self: *App, channel_view: *ChannelView) bool { - const device_channel = self.getChannelSourceDevice(channel_view) orelse return; - return device_channel.active_task != null; +pub fn isDeviceChannelActive(self: *App, channel_view: *ChannelView) bool { + const device_channel = self.getChannelSourceDevice(channel_view) orelse return false; + return device_channel.task != null; } -pub fn stopDeviceChannelReading(self: *App, channel_view: *ChannelView) void { - const device_channel = self.getChannelSourceDevice(channel_view) orelse return; - const task = device_channel.active_task orelse return; - - task.stop() catch |e| { - log.err("Failed to stop collection task {}", .{e}); - if (@errorReturnTrace()) |trace| { - std.debug.dumpStackTrace(trace.*); - } - - return; - }; - device_channel.active_task = null; -} - -pub fn startDeviceChannelReading(self: *App, channel_view: *ChannelView) void { +pub fn activateDeviceChannel(self: *App, channel_view: *ChannelView) !void { const ni_daq = &(self.ni_daq orelse return); + self.device_channels_mutex.lock(); + defer self.device_channels_mutex.unlock(); + const device_channel = self.getChannelSourceDevice(channel_view) orelse return; - if (device_channel.active_task != null) { - // Device channel is already reading + if (device_channel.task != null) { return; } - const channel_name = device_channel.name.buffer[0..device_channel.name.len :0]; - const sample_rate = device_channel.max_sample_rate; + defer self.thread_wake_condition.signal(); - const task = self.task_pool.launchAIVoltageChannel( - ni_daq, - &device_channel.samples, - .{ - .sample_rate = sample_rate - }, - .{ + assert(device_channel.units == .Voltage); + const channel_type = NIDaq.getChannelType(device_channel.getChannelName()) orelse return; + + if (channel_type == .analog_input) { + const task = try ni_daq.createTask(null); + errdefer task.clear(); + + const sample_rate = device_channel.max_sample_rate; + try task.createAIVoltageChannel(.{ + .channel = device_channel.getChannelName(), .min_value = device_channel.min_value, .max_value = device_channel.max_value, - .units = device_channel.units, - .channel = channel_name - } - ) catch |e| { - log.err("Failed to start collection task {}", .{e}); - if (@errorReturnTrace()) |trace| { - std.debug.dumpStackTrace(trace.*); + }); + try task.setContinousSampleRate(.{ + .sample_rate = sample_rate + }); + + try task.start(); + + device_channel.samples.clearRetainingCapacity(); + device_channel.task = ChannelTask{ + .task = task, + .sample_rate = sample_rate, + .read = true + }; + + } else if (channel_type == .analog_output) { + const task = try ni_daq.createTask(null); + errdefer task.clear(); + + device_channel.write_pattern.clearAndFree(); + try device_channel.write_pattern.appendNTimes(2, 1000); + + const write_pattern = device_channel.write_pattern.items; + const samples_per_channel: u32 = @intCast(write_pattern.len); + + const sample_rate = 5000; // device_channel.max_sample_rate; + try task.createAOVoltageChannel(.{ + .channel = device_channel.getChannelName(), + .min_value = device_channel.min_value, + .max_value = device_channel.max_value, + }); + try task.setContinousSampleRate(.{ + .sample_rate = sample_rate, + }); + + const write_amount = try task.writeAnalog(.{ + .write_array = write_pattern, + .samples_per_channel = samples_per_channel, + .timeout = -1 + }); + if (write_amount != samples_per_channel) { + return error.WriteAnalog; } + try task.start(); + + device_channel.task = ChannelTask{ + .task = task, + .sample_rate = sample_rate, + .read = false + }; + } else { return; - }; + } - device_channel.active_task = task; channel_view.follow = true; } +pub fn deactivateDeviceChannel(self: *App, channel_view: *ChannelView) !void { + self.device_channels_mutex.lock(); + defer self.device_channels_mutex.unlock(); + + const device_channel = self.getChannelSourceDevice(channel_view) orelse return; + if (device_channel.task == null) { + return; + } + + device_channel.task.?.deinit(); + device_channel.task = null; +} + +pub fn toggleInputDeviceChannels(self: *App) !void { + self.task_read_active = !self.task_read_active; + + for (self.listChannelViews()) |*channel_view| { + const device_channel = self.getChannelSourceDevice(channel_view) orelse continue; + const channel_name = device_channel.getChannelName(); + const channel_type = NIDaq.getChannelType(channel_name) orelse continue; + + if (channel_type == .analog_input) { + if (self.task_read_active) { + try self.activateDeviceChannel(channel_view); + } else { + try self.deactivateDeviceChannel(channel_view); + } + } + } +} + pub fn appendChannelFromFile(self: *App, path: []const u8) !void { + self.device_channels_mutex.lock(); + defer self.device_channels_mutex.unlock(); + const path_dupe = try self.allocator.dupe(u8, path); errdefer self.allocator.free(path_dupe); @@ -405,10 +588,16 @@ pub fn appendChannelFromFile(self: *App, path: []const u8) !void { pub fn appendChannelFromDevice(self: *App, channel_name: []const u8) !void { const ni_daq = &(self.ni_daq orelse return); + self.device_channels_mutex.lock(); + defer self.device_channels_mutex.unlock(); + const device_channel_index = findFreeSlot(DeviceChannel, &self.device_channels) orelse return error.DeviceChannelLimitReached; - const name_buff = try DeviceChannel.Name.fromSlice(channel_name); - const channel_name_z = name_buff.buffer[0..name_buff.len :0]; + const device_name = NIDaq.getDeviceNameFromChannel(channel_name) orelse return; + const device_name_buff = try utils.initBoundedStringZ(NIDaq.BoundedDeviceName, device_name); + + const channel_name_buff = try utils.initBoundedStringZ(DeviceChannel.ChannelName, channel_name); + const channel_name_z = utils.getBoundedStringZ(&channel_name_buff); const device = NIDaq.getDeviceNameFromChannel(channel_name) orelse return error.InvalidChannelName; const device_buff = try NIDaq.BoundedDeviceName.fromSlice(device); @@ -419,21 +608,35 @@ pub fn appendChannelFromDevice(self: *App, channel_name: []const u8) !void { var min_value: f64 = 0; var max_value: f64 = 1; - const voltage_ranges = try ni_daq.listDeviceAOVoltageRanges(device_z); - if (voltage_ranges.len > 0) { - min_value = voltage_ranges[0].low; - max_value = voltage_ranges[0].high; + + const channel_type = NIDaq.getChannelType(channel_name_z) orelse return error.UnknownChannelType; + if (channel_type == .analog_output) { + const voltage_ranges = try ni_daq.listDeviceAOVoltageRanges(device_z); + if (voltage_ranges.len > 0) { + min_value = voltage_ranges[0].low; + max_value = voltage_ranges[0].high; + } + } else if (channel_type == .analog_input) { + const voltage_ranges = try ni_daq.listDeviceAIVoltageRanges(device_z); + if (voltage_ranges.len > 0) { + min_value = voltage_ranges[0].low; + max_value = voltage_ranges[0].high; + } + } else { + return error.UnknownChannelType; } const max_sample_rate = try ni_daq.getMaxSampleRate(channel_name_z); self.device_channels[device_channel_index] = DeviceChannel{ - .name = name_buff, + .channel_name = channel_name_buff, + .device_name = device_name_buff, .min_sample_rate = ni_daq.getMinSampleRate(channel_name_z) catch max_sample_rate, .max_sample_rate = max_sample_rate, .min_value = min_value, .max_value = max_value, - .samples = std.ArrayList(f64).init(self.allocator) + .samples = std.ArrayList(f64).init(self.allocator), + .write_pattern = std.ArrayList(f64).init(self.allocator) }; errdefer self.device_channels[device_channel_index] = null; diff --git a/src/assets.zig b/src/assets.zig index 6f65401..43f31ca 100644 --- a/src/assets.zig +++ b/src/assets.zig @@ -48,6 +48,8 @@ pub var dropdown_arrow: rl.Texture2D = undefined; pub var fullscreen: rl.Texture2D = undefined; +pub var output_generation: rl.Texture2D = undefined; + pub fn font(font_id: FontId) FontFace { var found_font: ?LoadedFont = null; for (loaded_fonts.slice()) |*loaded_font| { @@ -139,6 +141,17 @@ pub fn init(allocator: std.mem.Allocator) !void { fullscreen = rl.loadTextureFromImage(fullscreen_image); assert(rl.isTextureReady(fullscreen)); } + + { + const output_generation_ase = try Aseprite.init(allocator, @embedFile("./assets/output-generation-icon.ase")); + defer output_generation_ase.deinit(); + + const output_generation_image = output_generation_ase.getFrameImage(0); + defer output_generation_image.unload(); + + output_generation = rl.loadTextureFromImage(output_generation_image); + assert(rl.isTextureReady(output_generation)); + } } fn loadFont(ttf_data: []const u8, font_size: u32) !rl.Font { diff --git a/src/assets/output-generation-icon.ase b/src/assets/output-generation-icon.ase new file mode 100644 index 0000000..be33210 Binary files /dev/null and b/src/assets/output-generation-icon.ase differ diff --git a/src/font-face.zig b/src/font-face.zig index dc797c5..6abcf0f 100644 --- a/src/font-face.zig +++ b/src/font-face.zig @@ -134,20 +134,26 @@ pub fn drawTextAlloc(self: @This(), allocator: Allocator, comptime fmt: []const self.drawText(text, position, tint); } -pub fn measureText(self: @This(), text: []const u8) rl.Vector2 { - var text_size = rl.Vector2.zero(); +pub const MeasureOptions = struct { + up_to_width: ?f32 = null, + last_codepoint_index: usize = 0 +}; - if (self.font.texture.id == 0) return text_size; // Security check - if (text.len == 0) return text_size; +pub fn measureTextEx(self: @This(), text: []const u8, opts: *MeasureOptions) rl.Vector2 { + var result = rl.Vector2.zero(); + + if (self.font.texture.id == 0) return result; // Security check + if (text.len == 0) return result; const font_size = self.getSize(); const spacing = self.getSpacing(); var line_width: f32 = 0; - text_size.y = font_size; + result.y = font_size; var iter = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; while (iter.nextCodepoint()) |codepoint| { + var text_size = result; if (codepoint == '\n') { text_size.y += font_size * self.line_height; @@ -158,18 +164,34 @@ pub fn measureText(self: @This(), text: []const u8) rl.Vector2 { } const index: u32 = @intCast(rl.getGlyphIndex(self.font, codepoint)); - if (self.font.glyphs[index].advanceX != 0) { - line_width += @floatFromInt(self.font.glyphs[index].advanceX); + const glyph = self.font.glyphs[index]; + if (glyph.advanceX != 0) { + line_width += @floatFromInt(glyph.advanceX); } else { line_width += self.font.recs[index].width; - line_width += @floatFromInt(self.font.glyphs[index].offsetX); + line_width += @floatFromInt(glyph.offsetX); } text_size.x = @max(text_size.x, line_width); } + + if (opts.up_to_width) |up_to_width| { + if (text_size.x > up_to_width) { + break; + } + } + + opts.last_codepoint_index = iter.i; + + result = text_size; } - return text_size; + return result; +} + +pub fn measureText(self: @This(), text: []const u8) rl.Vector2 { + var opts = MeasureOptions{}; + return self.measureTextEx(text, &opts); } pub fn measureWidth(self: @This(), text: []const u8) f32 { diff --git a/src/graph.zig b/src/graph.zig index 2194ac1..255884b 100644 --- a/src/graph.zig +++ b/src/graph.zig @@ -77,7 +77,11 @@ fn drawSamplesExact(draw_rect: rl.Rectangle, options: ViewOptions, samples: []co const draw_x_range, const draw_y_range = RangeF64.initRect(draw_rect); - const i_range = options.x_range.intersectPositive( + var view_x_range = options.x_range; + view_x_range.upper += 2; + view_x_range.lower -= 1; + + const i_range = view_x_range.intersectPositive( RangeF64.init(0, @max(@as(f64, @floatFromInt(samples.len)) - 1, 0)) ); if (i_range.lower > i_range.upper) { diff --git a/src/main.zig b/src/main.zig index 8843f71..8d3d64d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,7 +9,6 @@ const raylib_h = @cImport({ @cInclude("stdio.h"); @cInclude("raylib.h"); }); -const P = @import("profiler"); const log = std.log; const profiler_enabled = builtin.mode == .Debug; @@ -66,12 +65,6 @@ fn raylibTraceLogCallback(logType: c_int, text: [*c]const u8, args: raylib_h.va_ } pub fn main() !void { - try P.init(.{}); - defer { - P.dump("profile.json") catch |err| std.log.err("profile dump failed: {}", .{err}); - P.deinit(); - } - Platform.init(); // TODO: Setup logging to a file @@ -107,11 +100,12 @@ pub fn main() !void { defer app.deinit(); if (builtin.mode == .Debug) { - // try app.appendChannelFromDevice("Dev1/ai0"); - try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_I.bin"); + try app.appendChannelFromDevice("Dev1/ai0"); + try app.appendChannelFromDevice("Dev3/ao0"); + // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_I.bin"); + // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); - try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); } var profiler: ?Profiler = null; @@ -134,9 +128,6 @@ pub fn main() !void { rl.beginDrawing(); { - const zone = P.begin(@src(), "tick"); - defer zone.end(); - if (profiler) |*p| { p.start(); } @@ -161,12 +152,9 @@ pub fn main() !void { } } - { - const zone = P.begin(@src(), "end draw"); - defer zone.end(); - rl.endDrawing(); - } + rl.endDrawing(); } + } test { diff --git a/src/ni-daq/api.zig b/src/ni-daq/api.zig index c5b656d..f994558 100644 --- a/src/ni-daq/api.zig +++ b/src/ni-daq/api.zig @@ -46,6 +46,13 @@ DAQmxGetDevProductCategory: *const @TypeOf(c.DAQmxGetDevProductCategory), DAQmxGetDevAIPhysicalChans: *const @TypeOf(c.DAQmxGetDevAIPhysicalChans), DAQmxGetDevAOPhysicalChans: *const @TypeOf(c.DAQmxGetDevAOPhysicalChans), DAQmxReadAnalogF64: *const @TypeOf(c.DAQmxReadAnalogF64), +DAQmxWriteAnalogF64: *const @TypeOf(c.DAQmxWriteAnalogF64), +DAQmxGetWriteCurrWritePos: *const @TypeOf(c.DAQmxGetWriteCurrWritePos), +DAQmxGetWriteSpaceAvail: *const @TypeOf(c.DAQmxGetWriteSpaceAvail), +DAQmxCreateAOVoltageChan: *const @TypeOf(c.DAQmxCreateAOVoltageChan), +DAQmxGetWriteTotalSampPerChanGenerated: *const @TypeOf(c.DAQmxGetWriteTotalSampPerChanGenerated), +DAQmxCfgOutputBuffer: *const @TypeOf(c.DAQmxCfgOutputBuffer), +DAQmxCreateAOFuncGenChan: *const @TypeOf(c.DAQmxCreateAOFuncGenChan), pub fn init() Error!Api { var api: Api = undefined; diff --git a/src/ni-daq/root.zig b/src/ni-daq/root.zig index 2d4a7de..67d0df2 100644 --- a/src/ni-daq/root.zig +++ b/src/ni-daq/root.zig @@ -86,10 +86,28 @@ pub const Task = struct { ); } - pub fn setContinousSampleRate(self: Task, sample_rate: f64) !void { + pub const ContinousSamplingOptions = struct { + pub const Edge = enum(i32) { + rising = c.DAQmx_Val_Rising, + falling = c.DAQmx_Val_Falling + }; + + sample_rate: f64, + active_edge: Edge = .rising, + buffer_size: u64 = 0 + }; + + pub fn setContinousSampleRate(self: Task, opts: ContinousSamplingOptions) !void { const api = self.ni_daq.api; try self.ni_daq.checkDAQmxError( - api.DAQmxCfgSampClkTiming(self.handle, null, sample_rate, c.DAQmx_Val_Rising, c.DAQmx_Val_ContSamps, 0), + api.DAQmxCfgSampClkTiming( + self.handle, + null, + opts.sample_rate, + @intFromEnum(opts.active_edge), + c.DAQmx_Val_ContSamps, + opts.buffer_size + ), error.DAQmxCfgSampClkTiming ); } @@ -97,33 +115,63 @@ pub const Task = struct { pub fn setFiniteSampleRate(self: Task, sample_rate: f64, samples_per_channel: u64) !void { const api = self.ni_daq.api; try self.ni_daq.checkDAQmxError( - api.DAQmxCfgSampClkTiming(self.handle, null, sample_rate, c.DAQmx_Val_Rising, c.DAQmx_Val_FiniteSamps, samples_per_channel), + api.DAQmxCfgSampClkTiming( + self.handle, + null, + sample_rate, + c.DAQmx_Val_Rising, + c.DAQmx_Val_FiniteSamps, + samples_per_channel + ), error.DAQmxCfgSampClkTiming ); } pub const AIVoltageChannelOptions = struct { + pub const TerminalConfig = enum(i32) { + default = c.DAQmx_Val_Cfg_Default, + rse = c.DAQmx_Val_RSE, + nrse = c.DAQmx_Val_NRSE, + diff = c.DAQmx_Val_Diff, + pseudo_diff = c.DAQmx_Val_PseudoDiff, + }; + channel: [:0]const u8, assigned_name: [*c]const u8 = null, - terminal_config: i32 = c.DAQmx_Val_Cfg_Default, + terminal_config: TerminalConfig = .default, min_value: f64, max_value: f64, - units: i32 = c.DAQmx_Val_Volts, - custom_scale_name: [*c]const u8 = null, + units: union(enum) { + volts, + custom_scale: [*]const u8 + } = .volts }; pub fn createAIVoltageChannel(self: Task, options: AIVoltageChannelOptions) !void { const api = self.ni_daq.api; + + var custom_scale_name: [*c]const u8 = null; + var units: i32 = undefined; + switch (options.units) { + .volts => { + units = c.DAQmx_Val_Volts; + }, + .custom_scale => |_custom_scale_name| { + units = c.DAQmx_Val_FromCustomScale; + custom_scale_name = _custom_scale_name; + } + } + try self.ni_daq.checkDAQmxError( api.DAQmxCreateAIVoltageChan( self.handle, options.channel, options.assigned_name, - options.terminal_config, + @intFromEnum(options.terminal_config), options.min_value, options.max_value, - options.units, - options.custom_scale_name + units, + custom_scale_name ), error.DAQmxCreateAIVoltageChan ); @@ -134,25 +182,74 @@ pub const Task = struct { assigned_name: [*c]const u8 = null, min_value: f64, max_value: f64, - units: i32 = c.DAQmx_Val_Volts, - custom_scale_name: [*c]const u8 = null, + units: union(enum) { + volts, + custom_scale: [*]const u8 + } = .volts }; pub fn createAOVoltageChannel(self: Task, options: AOVoltageChannelOptions) !void { + + var custom_scale_name: [*c]const u8 = null; + var units: i32 = undefined; + switch (options.units) { + .volts => { + units = c.DAQmx_Val_Volts; + }, + .custom_scale => |_custom_scale_name| { + units = c.DAQmx_Val_FromCustomScale; + custom_scale_name = _custom_scale_name; + } + } + + const api = self.ni_daq.api; try self.ni_daq.checkDAQmxError( - c.DAQmxCreateAOVoltageChan( + api.DAQmxCreateAOVoltageChan( self.handle, options.channel, options.assigned_name, options.min_value, options.max_value, - options.units, - options.custom_scale_name + units, + custom_scale_name ), error.DAQmxCreateAOVoltageChan ); } + pub const AOGenerationChannelOptions = struct { + pub const Function = enum(i32) { + sine = c.DAQmx_Val_Sine, + triangle = c.DAQmx_Val_Triangle, + square = c.DAQmx_Val_Square, + Sawtooth = c.DAQmx_Val_Sawtooth, + _ + }; + + channel: [:0]const u8, + assigned_name: [*c]const u8 = null, + function: Function, + frequency: f64, + amplitude: f64, + offset: f64 = 0 + }; + + pub fn createAOGenerationChannel(self: Task, options: AOGenerationChannelOptions) !void { + const api = self.ni_daq.api; + try self.ni_daq.checkDAQmxError( + api.DAQmxCreateAOFuncGenChan( + self.handle, + options.channel, + options.assigned_name, + @intFromEnum(options.function), + options.frequency, + options.amplitude, + options.offset + ), + error.DAQmxCreateAOFuncGenChan + ); + } + pub const ReadAnalogOptions = struct { read_array: []f64, timeout: f64, @@ -177,7 +274,7 @@ pub const Task = struct { if (err == c.DAQmxErrorSamplesNoLongerAvailable) { self.dropped_samples += 1; - log.err("Dropped samples, not reading samples fast enough.", .{}); + log.warn("Dropped samples, not reading samples fast enough.", .{}); } else if (err < 0) { try self.ni_daq.checkDAQmxError(err, error.DAQmxReadAnalogF64); } @@ -185,10 +282,77 @@ pub const Task = struct { return @intCast(samples_per_channel); } + pub const WriteAnalogOptions = struct { + write_array: []f64, + samples_per_channel: u32, + timeout: f64, + + data_layout: u32 = c.DAQmx_Val_GroupByChannel, + auto_start: bool = false + }; + + pub fn writeAnalog(self: Task, options: WriteAnalogOptions) !u32 { + const api = self.ni_daq.api; + var samples_per_channel_written: i32 = 0; + const err = api.DAQmxWriteAnalogF64( + self.handle, + @intCast(options.samples_per_channel), + @intFromBool(options.auto_start), + options.timeout, + options.data_layout, + options.write_array.ptr, + &samples_per_channel_written, + null + ); + + try self.ni_daq.checkDAQmxError(err, error.DAQmxWriteAnalogF64); + + return @intCast(samples_per_channel_written); + } + + pub fn writeSpaceAvailable(self: Task) !u32 { + const api = self.ni_daq.api; + var amount: u32 = 0; + try self.ni_daq.checkDAQmxError( + api.DAQmxGetWriteSpaceAvail(self.handle, &amount), + error.DAQmxGetWriteSpaceAvail + ); + return amount; + } + + pub fn writeCurrentPosition(self: Task) !u64 { + const api = self.ni_daq.api; + var amount: u64 = 0; + try self.ni_daq.checkDAQmxError( + api.DAQmxGetWriteCurrWritePos(self.handle, &amount), + error.DAQmxGetWriteCurrWritePos + ); + return amount; + } + + pub fn writeTotalSamplesPerChannelGenerated(self: Task) !u64 { + const api = self.ni_daq.api; + var amount: u64 = 0; + try self.ni_daq.checkDAQmxError( + api.DAQmxGetWriteTotalSampPerChanGenerated(self.handle, &amount), + error.DAQmxGetWriteTotalSampPerChanGenerated + ); + return amount; + } + + pub fn configureOutputBuffer(self: Task, samples_per_channel: u32) !void { + const api = self.ni_daq.api; + try self.ni_daq.checkDAQmxError( + api.DAQmxCfgOutputBuffer(self.handle, samples_per_channel), + error.DAQmxCfgOutputBuffer + ); + } + pub fn isDone(self: Task) !bool { + const api = self.ni_daq.api; var result: c.bool32 = 0; try self.ni_daq.checkDAQmxError( - c.DAQmxIsTaskDone(self.handle, &result), + api.DAQmxIsTaskDone(self.handle, &result), error.DAQmxIsTaskDone ); return result != 0; diff --git a/src/ni-daq/task-pool.zig b/src/ni-daq/task-pool.zig deleted file mode 100644 index 7517163..0000000 --- a/src/ni-daq/task-pool.zig +++ /dev/null @@ -1,159 +0,0 @@ -const std = @import("std"); -const NIDaq = @import("./root.zig"); - -const assert = std.debug.assert; -const log = std.log.scoped(.task_pool); - -const TaskPool = @This(); -const max_tasks = 32; - -pub const Sampling = struct { - sample_rate: f64, - sample_count: ?u64 = null -}; - -pub const Entry = struct { - task: NIDaq.Task, - in_use: bool = false, - running: bool = false, - started_sampling_ns: i128, - stopped_sampling_ns: ?i128 = null, - dropped_samples: u32 = 0, - - sampling: Sampling, - - samples: *std.ArrayList(f64), - - pub fn stop(self: *Entry) !void { - self.running = false; - if (self.in_use) { - try self.task.stop(); - self.task.clear(); - } - - self.in_use = false; - } -}; - -running: bool = false, -read_thread: std.Thread, -entries: [max_tasks]Entry = undefined, -mutex: *std.Thread.Mutex, - -pub fn init(self: *TaskPool, mutex: *std.Thread.Mutex, allocator: std.mem.Allocator) !void { - self.* = TaskPool{ - .read_thread = undefined, - .mutex = mutex - }; - - self.running = true; - self.read_thread = try std.Thread.spawn( - .{ .allocator = allocator }, - readThreadCallback, - .{ self } - ); - - for (&self.entries) |*entry| { - entry.in_use = false; - } -} - -pub fn deinit(self: *TaskPool) void { - for (&self.entries) |*entry| { - entry.stop() catch log.err("Failed to stop entry", .{}); - } - - self.running = false; - self.read_thread.join(); -} - -fn readAnalog(task_pool: *TaskPool, entry: *Entry, timeout: f64) !void { - if (!entry.in_use) return; - if (!entry.running) return; - - task_pool.mutex.lock(); - defer task_pool.mutex.unlock(); - - if (entry.sampling.sample_count) |sample_count| { - try entry.samples.ensureTotalCapacity(sample_count); - } else { - try entry.samples.ensureUnusedCapacity(@intFromFloat(@ceil(entry.sampling.sample_rate))); - } - - const unused_capacity = entry.samples.unusedCapacitySlice(); - if (unused_capacity.len == 0) return; - - const read_amount = try entry.task.readAnalog(.{ - .timeout = timeout, - .read_array = unused_capacity, - }); - - if (read_amount == 0) return; - - entry.samples.items.len += read_amount; -} - -fn readThreadCallback(task_pool: *TaskPool) void { - const timeout = 0.05; - - while (task_pool.running) { - for (&task_pool.entries) |*entry| { - readAnalog(task_pool, entry, timeout) catch |e| { - log.err("readAnalog() failed in thread: {}", .{e}); - if (@errorReturnTrace()) |trace| { - std.debug.dumpStackTrace(trace.*); - } - - entry.stop() catch log.err("failed to stop collecting", .{}); - }; - } - - std.time.sleep(timeout * std.time.ns_per_s); - } -} - -fn findFreeEntry(self: *TaskPool) ?*Entry { - for (&self.entries) |*entry| { - if (!entry.in_use) { - return entry; - } - } - return null; -} - -pub fn launchAIVoltageChannel( - self: *TaskPool, - ni_daq: *NIDaq, - samples: *std.ArrayList(f64), - sampling: Sampling, - options: NIDaq.Task.AIVoltageChannelOptions -) !*Entry { - const task = try ni_daq.createTask(null); - errdefer task.clear(); - - const entry = self.findFreeEntry() orelse return error.NotEnoughSpace; - errdefer entry.in_use = false; - - try task.createAIVoltageChannel(options); - if (sampling.sample_count) |sample_count| { - try task.setFiniteSampleRate(sampling.sample_rate, sample_count); - } else { - try task.setContinousSampleRate(sampling.sample_rate); - } - - samples.clearRetainingCapacity(); - - try task.start(); - const started_at = std.time.nanoTimestamp(); - - entry.* = Entry{ - .task = task, - .started_sampling_ns = started_at, - .in_use = true, - .running = true, - .samples = samples, - .sampling = sampling, - }; - - return entry; -} \ No newline at end of file diff --git a/src/screens/main_screen.zig b/src/screens/main_screen.zig index 14cf901..47b2d19 100644 --- a/src/screens/main_screen.zig +++ b/src/screens/main_screen.zig @@ -8,6 +8,7 @@ const RangeF64 = @import("../range.zig").RangeF64; const Graph = @import("../graph.zig"); const Assets = @import("../assets.zig"); const utils = @import("../utils.zig"); +const NIDaq = @import("../ni-daq/root.zig"); const MainScreen = @This(); @@ -42,6 +43,33 @@ axis_zoom: ?struct { // TODO: Redo channel_undo_stack: std.BoundedArray(ChannelCommand, 100) = .{}, +protocol_modal: ?*ChannelView = null, +frequency_input: UI.TextInputStorage, +amplitude_input: UI.TextInputStorage, +protocol_error_message: ?[]const u8 = null, + +pub fn init(app: *App) !MainScreen { + const allocator = app.allocator; + + var self = MainScreen{ + .app = app, + .frequency_input = UI.TextInputStorage.init(allocator), + .amplitude_input = UI.TextInputStorage.init(allocator) + }; + + try self.frequency_input.setText("1000"); + try self.amplitude_input.setText("10"); + + return self; +} + +pub fn deinit(self: *MainScreen) void { + self.frequency_input.deinit(); + self.amplitude_input.deinit(); + + self.clearProtocolErrorMessage(); +} + fn pushChannelMoveCommand(self: *MainScreen, channel_view: *ChannelView, x_range: RangeF64, y_range: RangeF64) void { const now_ns = std.time.nanoTimestamp(); var undo_stack = &self.channel_undo_stack; @@ -548,23 +576,67 @@ fn showChannelView(self: *MainScreen, channel_view: *ChannelView, height: UI.Siz defer toolbar.endChildren(); if (self.app.getChannelSourceDevice(channel_view)) |device_channel| { - _ = device_channel; + const channel_name = device_channel.getChannelName(); + const channel_type = NIDaq.getChannelType(channel_name).?; - const follow = ui.textButton("Follow"); - follow.background = srcery.hard_black; - if (channel_view.follow) { - follow.borders = UI.Borders.bottom(.{ - .color = srcery.green, - .size = 4 - }); + { + const follow = ui.textButton("Follow"); + follow.background = srcery.hard_black; + if (channel_view.follow) { + follow.borders = UI.Borders.bottom(.{ + .color = srcery.green, + .size = 4 + }); + } + if (ui.signal(follow).clicked()) { + channel_view.follow = !channel_view.follow; + } } - if (ui.signal(follow).clicked()) { - channel_view.follow = !channel_view.follow; + + if (channel_type == .analog_output) { + const button = ui.button(ui.keyFromString("Output generation")); + button.texture = Assets.output_generation; + button.size.y = UI.Sizing.initGrowFull(); + + const signal = ui.signal(button); + if (signal.clicked()) { + if (self.app.isDeviceChannelActive(channel_view)) { + self.app.deferred_actions.appendAssumeCapacity(.{ + .deactivate = channel_view + }); + } else { + try self.openProtocolModal(channel_view); + } + } + + var color = rl.Color.white; + if (self.app.isDeviceChannelActive(channel_view)) { + color = srcery.red; + } + + if (signal.active) { + button.texture_color = color.alpha(0.6); + } else if (signal.hot) { + button.texture_color = color.alpha(0.8); + } else { + button.texture_color = color; + } + } + + _ = ui.createBox(.{ + .size_x = UI.Sizing.initGrowFull() + }); + + { + const label = ui.label("{s}", .{channel_name}); + label.size.y = UI.Sizing.initGrowFull(); + label.alignment.x = .center; + label.alignment.y = .center; + label.padding = UI.Padding.horizontal(ui.rem(1)); } } } - if (!show_ruler) { _ = self.showChannelViewGraph(channel_view); @@ -623,11 +695,157 @@ fn showChannelView(self: *MainScreen, channel_view: *ChannelView, height: UI.Siz } } +fn openProtocolModal(self: *MainScreen, channel_view: *ChannelView) !void { + self.protocol_modal = channel_view; +} + +fn closeModal(self: *MainScreen) void { + self.protocol_modal = null; +} + +pub fn showProtocolModal(self: *MainScreen, channel_view: *ChannelView) !void { + var ui = &self.app.ui; + + const container = ui.createBox(.{ + .key = ui.keyFromString("Protocol modal"), + .background = srcery.black, + .size_x = UI.Sizing.initGrowUpTo(.{ .pixels = 300 }), + .size_y = UI.Sizing.initGrowUpTo(.{ .pixels = 300 }), + .layout_direction = .top_to_bottom, + .padding = UI.Padding.all(ui.rem(1.5)), + .flags = &.{ .clickable }, + .layout_gap = ui.rem(0.5) + }); + container.beginChildren(); + defer container.endChildren(); + + const FormInput = struct { + name: []const u8, + storage: *UI.TextInputStorage, + value: *f32 + }; + + var frequency: f32 = 0; + var amplitude: f32 = 0; + + const form_inputs = &[_]FormInput{ + .{ + .name = "Frequency", + .storage = &self.frequency_input, + .value = &frequency + }, + .{ + .name = "Amplitude", + .storage = &self.amplitude_input, + .value = &litude + }, + }; + + for (form_inputs) |form_input| { + const label = form_input.name; + const text_input_storage = form_input.storage; + + const row = ui.createBox(.{ + .key = ui.keyFromString(label), + .layout_direction = .left_to_right, + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initFixedPixels(ui.rem(1.5)) + }); + row.beginChildren(); + defer row.endChildren(); + + const label_box = ui.label("{s}", .{ label }); + label_box.size.x = UI.Sizing.initFixed(.{ .font_size = 5 }); + label_box.size.y = UI.Sizing.initGrowFull(); + label_box.alignment.y = .center; + + try ui.textInput(ui.keyFromString("Text input"), text_input_storage); + } + + if (self.protocol_error_message) |message| { + _ = ui.createBox(.{ + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initFixedText(), + .text_color = srcery.red, + .align_x = .start, + .align_y = .start, + .flags = &.{ .wrap_text }, + .text = message + }); + } + + _ = ui.createBox(.{ + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initGrowFull(), + }); + + { + const row = ui.createBox(.{ + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initFitChildren(), + .align_x = .end + }); + row.beginChildren(); + defer row.endChildren(); + + const btn = ui.textButton("Confirm"); + btn.borders = UI.Borders.all(.{ .color = srcery.green, .size = 4 }); + + if (ui.signal(btn).clicked() or ui.isKeyboardPressed(.key_enter)) { + self.clearProtocolErrorMessage(); + + for (form_inputs) |form_input| { + const label = form_input.name; + const text_input_storage: *UI.TextInputStorage = form_input.storage; + + const number = std.fmt.parseFloat(f32, text_input_storage.buffer.items) catch { + try self.setProtocolErrorMessage("ERROR: {s} must be a number", .{ label }); + continue; + }; + + if (number <= 0) { + try self.setProtocolErrorMessage("ERROR: {s} must be positive", .{ label }); + continue; + } + + form_input.value.* = number; + } + + if (self.protocol_error_message == null) { + self.app.deferred_actions.appendAssumeCapacity(.{ + .activate = channel_view + }); + self.protocol_modal = null; + } + } + } + + _ = ui.signal(container); +} + +fn setProtocolErrorMessage(self: *MainScreen, comptime fmt: []const u8, args: anytype) !void { + self.clearProtocolErrorMessage(); + + const allocator = self.app.allocator; + self.protocol_error_message = try std.fmt.allocPrint(allocator, fmt, args); +} + +fn clearProtocolErrorMessage(self: *MainScreen) void { + const allocator = self.app.allocator; + + if (self.protocol_error_message) |msg| { + allocator.free(msg); + } + self.protocol_error_message = null; +} + pub fn tick(self: *MainScreen) !void { var ui = &self.app.ui; if (ui.isKeyboardPressed(.key_escape)) { - if (self.fullscreen_channel != null) { + if (self.protocol_modal != null) { + self.closeModal(); + } else if (self.fullscreen_channel != null) { self.fullscreen_channel = null; } else { self.app.should_close = true; @@ -649,12 +867,45 @@ pub fn tick(self: *MainScreen) !void { const root = ui.parentBox().?; root.layout_direction = .top_to_bottom; + var maybe_modal_overlay: ?*UI.Box = null; + if (self.protocol_modal) |channel_view| { + const padding = UI.Padding.all(ui.rem(2)); + const modal_overlay = ui.createBox(.{ + .key = ui.keyFromString("Overlay"), + .float_rect = .{ + .x = 0, + .y = 0, + // TODO: This is a hack, UI core should handle this + .width = root.persistent.size.x - padding.byAxis(.X), + .height = root.persistent.size.y - padding.byAxis(.Y) + }, + .background = rl.Color.black.alpha(0.6), + .flags = &.{ .clickable, .scrollable }, + .padding = padding, + .align_x = .center, + .align_y = .center, + }); + modal_overlay.beginChildren(); + defer modal_overlay.endChildren(); + + try self.showProtocolModal(channel_view); + + if (ui.signal(modal_overlay).clicked()) { + self.closeModal(); + } + + maybe_modal_overlay = modal_overlay; + } + { const toolbar = ui.createBox(.{ .background = srcery.black, .layout_direction = .left_to_right, .size_x = .{ .fixed = .{ .parent_percent = 1 } }, - .size_y = .{ .fixed = .{ .font_size = 2 } } + .size_y = .{ .fixed = .{ .font_size = 2 } }, + .borders = .{ + .bottom = .{ .color = srcery.hard_black, .size = 4 } + } }); toolbar.beginChildren(); defer toolbar.endChildren(); @@ -666,41 +917,17 @@ pub fn tick(self: *MainScreen) !void { start_all.padding.top = 0; start_all.padding.bottom = 0; if (ui.signal(start_all).clicked()) { - self.app.started_collecting = !self.app.started_collecting; - - for (self.app.listChannelViews()) |*channel_view| { - if (self.app.started_collecting) { - self.app.startDeviceChannelReading(channel_view); - } else { - self.app.stopDeviceChannelReading(channel_view); - } - } + self.app.deferred_actions.appendAssumeCapacity(.{ + .toggle_input_channels = {} + }); } - if (self.app.started_collecting) { + if (self.app.task_read_active) { start_all.setText("Stop"); } else { start_all.setText("Start"); } } - if (self.app.started_collecting) { - for (self.app.listChannelViews()) |*channel_view| { - const device_channel = self.app.getChannelSourceDevice(channel_view) orelse continue; - if (!channel_view.follow) continue; - - const sample_rate = device_channel.active_task.?.sampling.sample_rate; - const sample_count: f32 = @floatFromInt(device_channel.samples.items.len); - - channel_view.view_rect.y_range = channel_view.y_range; - - channel_view.view_rect.x_range.lower = 0; - if (sample_count > channel_view.view_rect.x_range.upper) { - channel_view.view_rect.x_range.upper = sample_count + @as(f32, @floatCast(sample_rate)) * 10; - } - // channel_view.view_cache.invalidate(); - } - } - if (self.fullscreen_channel) |channel| { try self.showChannelView(channel, UI.Sizing.initGrowFull()); @@ -728,8 +955,8 @@ pub fn tick(self: *MainScreen) !void { const add_from_file = ui.textButton("Add from file"); add_from_file.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); if (ui.signal(add_from_file).clicked()) { - self.app.channel_mutex.unlock(); - defer self.app.channel_mutex.lock(); + self.app.samples_mutex.unlock(); + defer self.app.samples_mutex.lock(); if (Platform.openFilePicker(self.app.allocator)) |filename| { defer self.app.allocator.free(filename); @@ -749,4 +976,27 @@ pub fn tick(self: *MainScreen) !void { } } } + + if (maybe_modal_overlay) |modal_overlay| { + root.bringChildToTop(modal_overlay); + } + + if (self.app.task_read_active) { + for (self.app.listChannelViews()) |*channel_view| { + const device_channel = self.app.getChannelSourceDevice(channel_view) orelse continue; + if (!channel_view.follow) continue; + + const channel_task = device_channel.task orelse continue; + + const sample_rate = channel_task.sample_rate; + const sample_count: f32 = @floatFromInt(device_channel.samples.items.len); + + channel_view.view_rect.y_range = channel_view.y_range; + + channel_view.view_rect.x_range.lower = 0; + if (sample_count > channel_view.view_rect.x_range.upper) { + channel_view.view_rect.x_range.upper = sample_count + @as(f32, @floatCast(sample_rate)) * 10; + } + } + } } \ No newline at end of file diff --git a/src/ui.zig b/src/ui.zig index ec2fe35..ecca840 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -5,7 +5,6 @@ const Assets = @import("./assets.zig"); const rect_utils = @import("./rect-utils.zig"); const utils = @import("./utils.zig"); const builtin = @import("builtin"); -const P = @import("profiler"); const FontFace = @import("./font-face.zig"); const raylib_h = @cImport({ @cInclude("stdio.h"); @@ -16,6 +15,7 @@ const assert = std.debug.assert; pub const Vec2 = rl.Vector2; pub const Rect = rl.Rectangle; const clamp = std.math.clamp; +const log = std.log.scoped(.ui); const Vec2Zero = Vec2{ .x = 0, .y = 0 }; @@ -76,7 +76,8 @@ pub const Event = union(enum) { mouse_move: Vec2, mouse_scroll: Vec2, key_pressed: u32, - key_released: u32 + key_released: u32, + char_pressed: u32 }; pub const Signal = struct { @@ -107,9 +108,11 @@ pub const Signal = struct { scroll: Vec2 = Vec2Zero, relative_mouse: Vec2 = Vec2Zero, mouse: Vec2 = Vec2Zero, + is_mouse_inside: bool = false, hot: bool = false, active: bool = false, shift_modifier: bool = false, + ctrl_modifier: bool = false, pub fn clicked(self: Signal) bool { return self.flags.contains(.left_clicked) or self.flags.contains(.right_clicked) or self.flags.contains(.middle_clicked); @@ -205,7 +208,15 @@ pub const Unit = union(enum) { .text => { const text = box.text orelse return 0; const font_face = Assets.font(box.font); - var text_size = font_face.measureText(text); + + const lines: []const []u8 = box.text_lines.constSlice(); + var text_size: Vec2 = undefined; + if (lines.len == 0) { + text_size = font_face.measureText(text); + } else { + text_size = font_face.measureTextLines(lines); + } + return vec2ByAxis(&text_size, axis).*; }, .font_size => |count| { @@ -233,6 +244,18 @@ pub const Sizing = union(enum) { }; } + pub fn initFixedPixels(size: f32) Sizing { + return Sizing{ + .fixed = Unit.initPixels(size) + }; + } + + pub fn initFixedText() Sizing { + return Sizing{ + .fixed = .text + }; + } + pub fn initGrowFull() Sizing { return initGrow( .{ .pixels = 0 }, @@ -465,6 +488,8 @@ pub const Box = struct { persistent: Persistent, alignment: Alignment2, size: Sizing2, + // TODO: Add option for changing how padding interacts with size. Does it remove from the available size, or is it added on top + // Currently it is added on top padding: Padding, font: Assets.FontId, text_color: rl.Color, @@ -475,6 +500,7 @@ pub const Box = struct { view_offset: Vec2 = Vec2Zero, texture: ?rl.Texture2D = null, texture_size: ?Vec2 = null, + texture_color: ?rl.Color = null, scientific_number: ?f64 = null, scientific_precision: u32 = 1, float_relative_to: ?*Box = null, @@ -496,6 +522,8 @@ pub const Box = struct { parent_index: ?BoxIndex = null, // Go the next node on the same level next_sibling_index: ?BoxIndex = null, + // Go to previous node on the same level + prev_sibling_index: ?BoxIndex = null, }, pub fn beginChildren(self: *Box) void { @@ -615,6 +643,55 @@ pub const Box = struct { const size = vec2ByAxis(&self.persistent.size, axis).*; return @max(size - self.padding.byAxis(axis), 0); } + + pub fn appendChild(self: *Box, child: *Box) void { + child.tree.parent_index = self.tree.index; + + if (self.tree.last_child_index) |last_child_index| { + const last_child = self.ui.getBoxByIndex(last_child_index); + + last_child.tree.next_sibling_index = child.tree.index; + self.tree.last_child_index = child.tree.index; + self.tree.prev_sibling_index = last_child.tree.index; + } else { + self.tree.first_child_index = child.tree.index; + self.tree.last_child_index = child.tree.index; + } + } + + pub fn removeChild(self: *Box, child: *Box) void { + assert(child.tree.parent_index == self.tree.index); + + child.tree.parent_index = null; + defer child.tree.next_sibling_index = null; + defer child.tree.prev_sibling_index = null; + + var next_sibling: ?*Box = null; + if (child.tree.next_sibling_index) |next_sibling_index| { + next_sibling = self.ui.getBoxByIndex(next_sibling_index); + } + + var prev_sibling: ?*Box = null; + if (child.tree.prev_sibling_index) |prev_sibling_index| { + prev_sibling = self.ui.getBoxByIndex(prev_sibling_index); + } + + if (next_sibling != null and prev_sibling != null) { + next_sibling.?.tree.prev_sibling_index = prev_sibling.?.tree.index; + prev_sibling.?.tree.next_sibling_index = next_sibling.?.tree.index; + } else if (next_sibling != null) { + next_sibling.?.tree.prev_sibling_index = null; + self.tree.first_child_index = next_sibling.?.tree.index; + } else if (prev_sibling != null) { + prev_sibling.?.tree.next_sibling_index = null; + self.tree.last_child_index = prev_sibling.?.tree.index; + } + } + + pub fn bringChildToTop(self: *Box, child: *Box) void { + self.removeChild(child); + self.appendChild(child); + } }; pub const BoxOptions = struct { @@ -643,7 +720,8 @@ pub const BoxOptions = struct { scientific_number: ?f64 = null, scientific_precision: ?u32 = null, float_relative_to: ?*Box = null, - parent: ?*UI.Box = null + parent: ?*UI.Box = null, + texture_color: ?rl.Color = null, }; pub const root_box_key = Key.initString(0, "$root$"); @@ -679,6 +757,31 @@ const BoxParentIterator = struct { } }; +const CharPressIterator = struct { + events: *Events, + index: usize = 0, + + pub fn next(self: *CharPressIterator) ?u32 { + while (self.index < self.events.len) { + const event = self.events.get(self.index); + self.index += 1; + + if (event == .char_pressed) { + return event.char_pressed; + } + } + + return null; + } + + pub fn consume(self: *CharPressIterator) void { + self.index -= 1; + _ = self.events.swapRemove(self.index); + } +}; + +const Events = std.BoundedArray(Event, max_events); + arenas: [2]std.heap.ArenaAllocator, // Retained structures. Used for tracking changes between frames @@ -691,7 +794,7 @@ scissor_stack: std.BoundedArray(Rect, 16) = .{}, mouse: Vec2 = .{ .x = 0, .y = 0 }, mouse_delta: Vec2 = .{ .x = 0, .y = 0 }, mouse_buttons: std.EnumSet(rl.MouseButton) = .{}, -events: std.BoundedArray(Event, max_events) = .{}, +events: Events = .{}, dt: f32 = 0, // Per layout pass fields @@ -717,41 +820,13 @@ pub fn frame_arena(self: *UI) *std.heap.ArenaAllocator { return &self.arenas[@mod(self.frame_index, 2)]; } -pub fn frame_allocator(self: *UI) std.mem.Allocator { +pub fn frameAllocator(self: *UI) std.mem.Allocator { return self.frame_arena().allocator(); } -pub fn begin(self: *UI) void { - const zone = P.begin(@src(), "UI begin()"); - defer zone.end(); - - self.font_stack.len = 0; - self.parent_stack.len = 0; +pub fn pullOsEvents(self: *UI) void { self.events.len = 0; - // Remove boxes which haven't been used in the last frame - { - const zone2 = P.begin(@src(), "UI remove boxes"); - defer zone2.end(); - - var i: usize = 0; - while (i < self.boxes.len) { - const box = &self.boxes.buffer[i]; - if (box.last_used_frame != self.frame_index) { - if (self.boxes.len > 0) { - self.boxes.buffer[i] = self.boxes.buffer[self.boxes.len - 1]; - } - self.boxes.len -= 1; - } else { - i += 1; - } - } - } - - if (self.active_box_keys.count() == 0) { - self.hot_box_key = null; - } - const mouse = rl.getMousePosition(); self.mouse_delta = mouse.subtract(self.mouse); self.dt = rl.getFrameTime(); @@ -785,10 +860,46 @@ pub fn begin(self: *UI) void { } } + while (true) { + const char = rl.getCharPressed(); + if (char == 0) { + break; + } + + self.events.appendAssumeCapacity(Event{ + .char_pressed = @intCast(char) + }); + } + const mouse_wheel = rl.getMouseWheelMoveV(); if (mouse_wheel.x != 0 or mouse_wheel.y != 0) { self.events.appendAssumeCapacity(Event{ .mouse_scroll = mouse_wheel }); } +} + +pub fn begin(self: *UI) void { + self.font_stack.len = 0; + self.parent_stack.len = 0; + + // Remove boxes which haven't been used in the last frame + { + var i: usize = 0; + while (i < self.boxes.len) { + const box = &self.boxes.buffer[i]; + if (box.last_used_frame != self.frame_index) { + if (self.boxes.len > 0) { + self.boxes.buffer[i] = self.boxes.buffer[self.boxes.len - 1]; + } + self.boxes.len -= 1; + } else { + i += 1; + } + } + } + + if (!self.isKeyActiveAny()) { + self.hot_box_key = null; + } self.frame_index += 1; _ = self.frame_arena().reset(.retain_capacity); @@ -912,8 +1023,11 @@ pub fn end(self: *UI) void { } fn layoutSizesPass(self: *UI, root_box: *Box) void { + // TODO: Figure out a way for fit children to work without calling it twice. + self.layoutSizesInitial(root_box, .X); self.layoutSizesShrink(root_box, .X); + self.layoutSizesFitChildren(root_box, .X); self.layoutSizesGrow(root_box, .X); self.layoutSizesFitChildren(root_box, .X); @@ -921,6 +1035,7 @@ fn layoutSizesPass(self: *UI, root_box: *Box) void { self.layoutSizesInitial(root_box, .Y); self.layoutSizesShrink(root_box, .Y); + self.layoutSizesFitChildren(root_box, .Y); self.layoutSizesGrow(root_box, .Y); self.layoutSizesFitChildren(root_box, .Y); } @@ -1092,11 +1207,12 @@ fn layoutSizesShrink(self: *UI, box: *Box, axis: Axis) void { break; } - var largest_size: f32 = vec2ByAxis(&shrinkable_children.buffer[0].box.persistent.size, axis).*; + var largest_child = shrinkable_children.buffer[0]; + var largest_size: f32 = vec2ByAxis(&largest_child.box.persistent.size, axis).*; var second_largest_size: ?f32 = null; var largest_children: std.BoundedArray(*Box, max_boxes) = .{}; - largest_children.appendAssumeCapacity(shrinkable_children.buffer[0].box); + largest_children.appendAssumeCapacity(largest_child.box); for (shrinkable_children.slice()[1..]) |shrinkable_child| { const child = shrinkable_child.box; @@ -1105,6 +1221,7 @@ fn layoutSizesShrink(self: *UI, box: *Box, axis: Axis) void { if (child_persistent_size.* > largest_size) { second_largest_size = largest_size; largest_size = child_persistent_size.*; + largest_child = shrinkable_child; largest_children.len = 0; largest_children.appendAssumeCapacity(child); } else if (child_persistent_size.* < largest_size) { @@ -1114,7 +1231,7 @@ fn layoutSizesShrink(self: *UI, box: *Box, axis: Axis) void { } } - var shrink_largest_by = overflow_size; + var shrink_largest_by = @max(overflow_size, largest_child.min_size); if (second_largest_size != null) { shrink_largest_by = @min(shrink_largest_by, largest_size - second_largest_size.?); } @@ -1122,8 +1239,8 @@ fn layoutSizesShrink(self: *UI, box: *Box, axis: Axis) void { const largest_count_f32: f32 = @floatFromInt(largest_children.len); shrink_largest_by = @min(shrink_largest_by * largest_count_f32, largest_size) / largest_count_f32; - for (largest_children.slice()) |largest_child| { - const child_persistent_size = vec2ByAxis(&largest_child.persistent.size, axis); + for (largest_children.slice()) |child| { + const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); child_persistent_size.* -= shrink_largest_by; overflow_size -= shrink_largest_by; } @@ -1143,7 +1260,7 @@ fn layoutSizesFitChildren(self: *UI, box: *Box, axis: Axis) void { if (box.size.getAxis(axis) == .fit_children) { const children_size = sumChildrenSize(box, axis); const persistent_size = vec2ByAxis(&box.persistent.size, axis); - persistent_size.* += children_size; + persistent_size.* = children_size + box.padding.byAxis(axis); } } @@ -1207,11 +1324,12 @@ fn layoutSizesGrow(self: *UI, box: *Box, axis: Axis) void { break; } - var smallest_size: f32 = vec2ByAxis(&growable_children.buffer[0].box.persistent.size, axis).*; + var smallest_child = growable_children.buffer[0]; + var smallest_size: f32 = vec2ByAxis(&smallest_child.box.persistent.size, axis).*; var second_smallest_size: ?f32 = null; var smallest_children: std.BoundedArray(*Box, max_boxes) = .{}; - smallest_children.appendAssumeCapacity(growable_children.buffer[0].box); + smallest_children.appendAssumeCapacity(smallest_child.box); for (growable_children.slice()[1..]) |growable_child| { const child = growable_child.box; @@ -1220,6 +1338,7 @@ fn layoutSizesGrow(self: *UI, box: *Box, axis: Axis) void { if (child_persistent_size.* < smallest_size) { second_smallest_size = smallest_size; smallest_size = child_persistent_size.*; + smallest_child = growable_child; smallest_children.len = 0; smallest_children.appendAssumeCapacity(child); } else if (child_persistent_size.* > smallest_size) { @@ -1229,7 +1348,7 @@ fn layoutSizesGrow(self: *UI, box: *Box, axis: Axis) void { } } - var grow_smallest_by = unused_size; + var grow_smallest_by = @min(unused_size, smallest_child.max_size); if (second_smallest_size != null) { grow_smallest_by = @min(grow_smallest_by, second_smallest_size.? - smallest_size); } @@ -1237,8 +1356,8 @@ fn layoutSizesGrow(self: *UI, box: *Box, axis: Axis) void { const smallest_count_f32: f32 = @floatFromInt(smallest_children.len); grow_smallest_by = @min(grow_smallest_by * smallest_count_f32, unused_size) / smallest_count_f32; - for (smallest_children.slice()) |smallest_child| { - const child_persistent_size = vec2ByAxis(&smallest_child.persistent.size, axis); + for (smallest_children.slice()) |child| { + const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); child_persistent_size.* += grow_smallest_by; unused_size -= grow_smallest_by; } @@ -1383,7 +1502,7 @@ pub fn createBox(self: *UI, opts: BoxOptions) *Box { box.* = Box{ .ui = self, - .allocator = self.frame_allocator(), + .allocator = self.frameAllocator(), .persistent = persistent, .flags = flags, @@ -1405,6 +1524,7 @@ pub fn createBox(self: *UI, opts: BoxOptions) *Box { .scientific_number = opts.scientific_number, .scientific_precision = opts.scientific_precision orelse 1, .float_relative_to = opts.float_relative_to, + .texture_color = opts.texture_color, .last_used_frame = self.frame_index, .key = key, @@ -1438,17 +1558,7 @@ pub fn createBox(self: *UI, opts: BoxOptions) *Box { } if (opts.parent orelse self.parentBox()) |parent| { - box.tree.parent_index = parent.tree.index; - - if (parent.tree.last_child_index) |last_child_index| { - const last_child = self.getBoxByIndex(last_child_index); - - last_child.tree.next_sibling_index = box.tree.index; - parent.tree.last_child_index = box.tree.index; - } else { - parent.tree.first_child_index = box.tree.index; - parent.tree.last_child_index = box.tree.index; - } + parent.appendChild(box); } return box; @@ -1563,7 +1673,7 @@ fn drawBox(self: *UI, box: *Box) void { destination, rl.Vector2.zero(), 0, - rl.Color.white + box.texture_color orelse rl.Color.white ); } @@ -1734,6 +1844,7 @@ pub fn signal(self: *UI, box: *Box) Signal { var result = Signal{}; if (box.key.isNil()) { + log.warn("Called signal() with nil key", .{}); return result; } @@ -1822,7 +1933,9 @@ pub fn signal(self: *UI, box: *Box) Signal { result.active = self.isKeyActive(box.key); result.relative_mouse = self.mouse.subtract(rect_utils.position(rect)); result.mouse = self.mouse; + result.is_mouse_inside = is_mouse_inside; result.shift_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); + result.ctrl_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_control) or rl.isKeyDown(rl.KeyboardKey.key_right_control); return result; } @@ -1849,6 +1962,10 @@ pub fn isKeyActive(self: *UI, key: Key) bool { return false; } +pub fn isKeyActiveAny(self: *UI) bool { + return self.active_box_keys.count() != 0; +} + pub fn isKeyboardPressed(self: *UI, key: rl.KeyboardKey) bool { const key_u32: u32 = @intCast(@intFromEnum(key)); @@ -1862,12 +1979,31 @@ pub fn isKeyboardPressed(self: *UI, key: rl.KeyboardKey) bool { return false; } +pub fn isKeyboardPressedOrHeld(self: *UI, key: rl.KeyboardKey) bool { + return self.isKeyboardPressed(key) or rl.isKeyPressedRepeat(key); +} + +pub fn hasKeyboardPressess(self: *UI) bool { + for (self.events.slice()) |_event| { + const event: Event = _event; + if (event == .key_pressed) { + return true; + } + } + return false; +} + +pub fn iterCharacterPressess(self: *UI) CharPressIterator { + return CharPressIterator{ + .events = &self.events, + }; +} + pub fn isShiftDown(self: *UI) bool { _ = self; return rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); } - pub fn isCtrlDown(self: *UI) bool { _ = self; return rl.isKeyDown(rl.KeyboardKey.key_left_control) or rl.isKeyDown(rl.KeyboardKey.key_right_control); @@ -1875,6 +2011,179 @@ pub fn isCtrlDown(self: *UI) bool { // --------------------------------- Widgets ----------------------------------------- // +pub const TextInputStorage = struct { + buffer: std.ArrayList(u8), + + editing: bool = false, + last_pressed_at_ns: i128 = 0, + cursor_start: usize = 0, + cursor_stop: usize = 0, + shown_slice_start: f32 = 0, + shown_slice_end: f32 = 0, + + pub fn init(allocator: std.mem.Allocator) TextInputStorage { + return TextInputStorage{ + .buffer = std.ArrayList(u8).init(allocator) + }; + } + + pub fn deinit(self: TextInputStorage) void { + self.buffer.deinit(); + } + + pub fn setText(self: *TextInputStorage, text: []const u8) !void { + self.buffer.clearAndFree(); + try self.buffer.appendSlice(text); + } + + pub fn insertSingle(self: *TextInputStorage, index: usize, symbol: u8) !void { + try self.buffer.insert(index, symbol); + + if (self.cursor_start >= index) { + self.cursor_start += 1; + } + + if (self.cursor_stop >= index) { + self.cursor_stop += 1; + } + } + + fn deleteSingle(self: *TextInputStorage, index: usize) void { + self.deleteMany(index, index+1); + } + + /// If `from` and `to` are the same number, nothing will be deleted + fn deleteMany(self: *TextInputStorage, from: usize, to: usize) void { + const from_clamped = std.math.clamp(from, 0, self.buffer.items.len); + const to_clamped = std.math.clamp(to , 0, self.buffer.items.len); + if (from_clamped == to_clamped) return; + + const lower = @min(from_clamped, to_clamped); + const upper = @max(from_clamped, to_clamped); + assert(lower < upper); + + const deleted_count = (upper - lower); + + std.mem.copyForwards(u8, self.buffer.items[lower..], self.buffer.items[upper..]); + self.buffer.items.len -= deleted_count; + + if (self.cursor_start > upper) { + self.cursor_start -= deleted_count; + } else if (self.cursor_start > lower) { + self.cursor_start = lower; + } + + if (self.cursor_stop > upper) { + self.cursor_stop -= deleted_count; + } else if (self.cursor_stop > lower) { + self.cursor_stop = lower; + } + } + + fn getCharOffsetX(self: *const TextInputStorage, font: FontFace, index: usize) f32 { + const text = self.buffer.items; + return font.measureWidth(text[0..index]); + } + + fn getCharIndex(self: *const TextInputStorage, font: FontFace, x: f32) usize { + var measure_opts = FontFace.MeasureOptions{ + .up_to_width = x + self.shown_slice_start + }; + const before_size = font.measureTextEx(self.buffer.items, &measure_opts).x; + + const index = measure_opts.last_codepoint_index; + + if (index+1 < self.buffer.items.len) { + const after_size = self.getCharOffsetX(font, index + 1); + if (@abs(before_size - x) > @abs(after_size - x)) { + return index + 1; + } + } + + return index; + } + + fn nextJumpPoint(self: *const TextInputStorage, index: usize, step: isize) usize { + assert(step == 1 or step == -1); // Only tested for these cases + var i = index; + const text = self.buffer.items; + + if (step > 0) { + var prev_whitespace = std.ascii.isWhitespace(text[i]); + while (true) { + const cur_whitespace = std.ascii.isWhitespace(text[i]); + if (!prev_whitespace and cur_whitespace) { + break; + } + + prev_whitespace = cur_whitespace; + + const next_i = @as(isize, @intCast(i)) + step; + if (next_i > text.len) { + break; + } + + i = @intCast(next_i); + + if (i == text.len) { + break; + } + } + } else { + if (index == 0) return index; + + i = @intCast(@as(isize, @intCast(i)) + step); + + var cur_whitespace = std.ascii.isWhitespace(text[i]); + while (true) { + const next_i = @as(isize, @intCast(i)) + step; + if (next_i < 0) { + break; + } + + const next_whitespace = std.ascii.isWhitespace(text[@intCast(next_i)]); + if (!cur_whitespace and next_whitespace) { + break; + } + + cur_whitespace = next_whitespace; + + i = @intCast(next_i); + } + } + + return i; + } + + pub fn textLength(self: *TextInputStorage) []const u8 { + return self.buffer.items; + } + + fn cursorSlice(self: *const TextInputStorage) []const u8 { + if (self.cursor_start == self.cursor_stop) { + return self.buffer.items[self.cursor_start..(self.cursor_start+1)]; + } else { + const lower = @min(self.cursor_start, self.cursor_stop); + const upper = @max(self.cursor_start, self.cursor_stop); + return self.buffer.items[lower..upper]; + } + } + + fn insertMany(self: *TextInputStorage, index: usize, text: []const u8) !void { + if (index > self.buffer.items.len) return; + + try self.buffer.insertSlice(index, text); + + if (self.cursor_start >= index) { + self.cursor_start += text.len; + } + + if (self.cursor_stop >= index) { + self.cursor_stop += text.len; + } + } +}; + pub fn mouseTooltip(self: *UI) *Box { const tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; @@ -1996,4 +2305,280 @@ pub fn endScrollbar(self: *UI) void { } wrapper.endChildren(); +} + +pub fn textInput(self: *UI, key: Key, storage: *TextInputStorage) !void { + const now = std.time.nanoTimestamp(); + + const container = self.createBox(.{ + .key = key, + .size_x = Sizing.initGrowUpTo(.{ .pixels = 200 }), + .size_y = Sizing.initFixed(Unit.initPixels(self.rem(1))), + .flags = &.{ .clickable, .clip_view, .draggable }, + .background = srcery.bright_white, + .align_y = .center, + .padding = UI.Padding.all(self.rem(0.25)), + }); + container.beginChildren(); + defer container.endChildren(); + + const font = Assets.font(container.font); + const text = &storage.buffer; + + const cursor_start_x = storage.getCharOffsetX(font, storage.cursor_start); + const cursor_stop_x = storage.getCharOffsetX(font, storage.cursor_stop); + + { // Text visuals + const text_size = font.measureText(text.items); + const visible_text_width = container.persistent.size.x - container.padding.byAxis(.X); + + const shown_window_size = @min(visible_text_width, text_size.x); + if (storage.shown_slice_end - storage.shown_slice_start != shown_window_size) { + const shown_slice_middle = (storage.shown_slice_end + storage.shown_slice_start) / 2; + storage.shown_slice_start = shown_slice_middle - shown_window_size/2; + storage.shown_slice_end = shown_slice_middle + shown_window_size/2; + } + if (storage.shown_slice_end > text_size.x) { + storage.shown_slice_end = shown_window_size; + storage.shown_slice_start = storage.shown_slice_end - shown_window_size; + } else if (storage.shown_slice_start < 0) { + storage.shown_slice_start = 0; + storage.shown_slice_end = shown_window_size; + } + + if (cursor_stop_x > storage.shown_slice_end) { + storage.shown_slice_start = cursor_stop_x - shown_window_size; + storage.shown_slice_end = cursor_stop_x; + } + if (cursor_stop_x < storage.shown_slice_start) { + storage.shown_slice_start = cursor_stop_x; + storage.shown_slice_end = cursor_stop_x + shown_window_size; + } + + _ = self.createBox(.{ + .text_color = srcery.black, + .text = text.items, + .float_relative_to = container, + .float_rect = Rect{ + .x = container.padding.left - storage.shown_slice_start, + .y = 0, + .width = visible_text_width, + .height = container.persistent.size.y + }, + .align_y = .center, + .align_x = .start + }); + } + + const container_signal = self.signal(container); + if (container_signal.hot) { + container.borders = UI.Borders.all(.{ + .color = srcery.red, + .size = 2 + }); + } + + // Text editing visuals + if (storage.editing) { + const blink_period = std.time.ns_per_s; + const blink = @mod(now - storage.last_pressed_at_ns, blink_period) < 0.5 * blink_period; + + const cursor_color = srcery.hard_black; + + if (storage.cursor_start == storage.cursor_stop and blink) { + _ = self.createBox(.{ + .background = cursor_color, + .float_relative_to = container, + .float_rect = Rect{ + .x = container.padding.left + cursor_start_x - storage.shown_slice_start, + .y = container.padding.top, + .width = 2, + .height = container.persistent.size.y - container.padding.byAxis(.Y) + }, + }); + } + + if (storage.cursor_start != storage.cursor_stop) { + const lower_cursor_x = @min(cursor_start_x, cursor_stop_x); + const upper_cursor_x = @max(cursor_start_x, cursor_stop_x); + + _ = self.createBox(.{ + .background = cursor_color.alpha(0.25), + .float_relative_to = container, + .float_rect = Rect{ + .x = container.padding.left + lower_cursor_x - storage.shown_slice_start, + .y = container.padding.top, + .width = upper_cursor_x - lower_cursor_x, + .height = container.persistent.size.y - container.padding.byAxis(.Y) + }, + }); + } + } + + if (container_signal.active) { + storage.editing = true; + } + + if (self.isKeyActiveAny() and !container_signal.active) { + storage.editing = false; + } + + // Text input controls + if (storage.editing) { + const shift = container_signal.shift_modifier; + const ctrl = container_signal.ctrl_modifier; + + var no_blinking = false; + + { // Cursor movement controls + var move_cursor_dir: i32 = 0; + if (self.isKeyboardPressedOrHeld(.key_left)) { + move_cursor_dir -= 1; + } + if (self.isKeyboardPressedOrHeld(.key_right)) { + move_cursor_dir += 1; + } + + if (move_cursor_dir != 0) { + if (shift) { + if (ctrl) { + storage.cursor_stop = storage.nextJumpPoint(storage.cursor_stop, move_cursor_dir); + } else { + const cursor_stop: isize = @intCast(storage.cursor_stop); + storage.cursor_stop = @intCast(std.math.clamp( + cursor_stop + move_cursor_dir, + 0, + @as(isize, @intCast(text.items.len)) + )); + } + + } else { + if (storage.cursor_start != storage.cursor_stop) { + const lower = @min(storage.cursor_start, storage.cursor_stop); + const upper = @max(storage.cursor_start, storage.cursor_stop); + + if (move_cursor_dir < 0) { + if (ctrl) { + storage.cursor_start = storage.nextJumpPoint(lower, -1); + } else { + storage.cursor_start = lower; + } + } else { + if (ctrl) { + storage.cursor_start = storage.nextJumpPoint(upper, 1); + } else { + storage.cursor_start = upper; + } + } + storage.cursor_stop = storage.cursor_start; + + } else { + var cursor = storage.cursor_start; + + if (ctrl) { + cursor = storage.nextJumpPoint(cursor, move_cursor_dir); + } else { + cursor = @intCast(std.math.clamp( + @as(isize, @intCast(cursor)) + move_cursor_dir, + 0, + @as(isize, @intCast(text.items.len)) + )); + } + + storage.cursor_start = cursor; + storage.cursor_stop = cursor; + } + } + + no_blinking = true; + } + } + + { // Cursor movement with mouse + var mouse = container_signal.relative_mouse; + mouse.x -= container.padding.left; + mouse.y -= container.padding.top; + + const mouse_index = storage.getCharIndex(font, mouse.x); + + if (container_signal.flags.contains(.left_pressed)) { + storage.cursor_start = mouse_index; + storage.cursor_stop = mouse_index; + no_blinking = true; + } + if (container_signal.flags.contains(.left_dragging)) { + storage.cursor_stop = mouse_index; + no_blinking = true; + } + } + + // Deletion + if (self.isKeyboardPressedOrHeld(.key_backspace) and text.items.len > 0) { + if (storage.cursor_start == storage.cursor_stop) { + if (storage.cursor_start > 0) { + storage.deleteMany(storage.cursor_start-1, storage.cursor_start); + } + } else { + storage.deleteMany(storage.cursor_start, storage.cursor_stop); + } + + no_blinking = true; + } + + // Common commands (E.g. Ctrl+A, Ctrl+C, etc.) + if (ctrl) { + if (self.isKeyboardPressedOrHeld(.key_x)) { + if (storage.cursor_start != storage.cursor_stop) { + const allocator = storage.buffer.allocator; + const text_z = try allocator.dupeZ(u8, storage.cursorSlice()); + defer allocator.free(text_z); + + rl.setClipboardText(text_z); + + storage.deleteMany(storage.cursor_start, storage.cursor_stop); + } + } else if (self.isKeyboardPressedOrHeld(.key_a)) { + storage.cursor_start = 0; + storage.cursor_stop = text.items.len; + + } else if (self.isKeyboardPressedOrHeld(.key_c)) { + if (storage.cursor_start != storage.cursor_stop) { + const allocator = storage.buffer.allocator; + const text_z = try allocator.dupeZ(u8, storage.cursorSlice()); + defer allocator.free(text_z); + + rl.setClipboardText(text_z); + } + } else if (self.isKeyboardPressedOrHeld(.key_v)) { + if (storage.cursor_start != storage.cursor_stop) { + storage.deleteMany(storage.cursor_start, storage.cursor_stop); + } + + const clipboard = rl.getClipboardText(); + try storage.insertMany(storage.cursor_start, clipboard); + } + } + + { // Insertion + // TODO: Handle UTF8 characters + var char_iter = self.iterCharacterPressess(); + while (char_iter.next()) |char| { + if (char <= 255 and std.ascii.isPrint(@intCast(char))) { + char_iter.consume(); + + if (storage.cursor_start != storage.cursor_stop) { + storage.deleteMany(storage.cursor_start, storage.cursor_stop); + } + try storage.insertSingle(storage.cursor_start, @intCast(char)); + + + no_blinking = true; + } + } + } + + if (no_blinking) { + storage.last_pressed_at_ns = now; + } + } } \ No newline at end of file diff --git a/src/utils.zig b/src/utils.zig index 7afaa48..3037b4c 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -1,6 +1,8 @@ const std = @import("std"); const rl = @import("raylib"); +const assert = std.debug.assert; + pub fn vec2Round(vec2: rl.Vector2) rl.Vector2 { return rl.Vector2{ .x = @round(vec2.x), @@ -64,4 +66,14 @@ pub fn roundNearestDown(comptime T: type, value: T, multiple: T) T { pub fn roundNearestTowardZero(comptime T: type, value: T, multiple: T) T { return @trunc(value / multiple) * multiple; +} + +pub fn initBoundedStringZ(comptime BoundedString: type, text: []const u8) !BoundedString { + var bounded_string = try BoundedString.fromSlice(text); + try bounded_string.append(0); + return bounded_string; +} + +pub fn getBoundedStringZ(bounded_array: anytype) [:0]const u8 { + return bounded_array.buffer[0..(bounded_array.len-1) :0]; } \ No newline at end of file