diff --git a/src/app.zig b/src/app.zig index 639a4a7..cf05ab3 100644 --- a/src/app.zig +++ b/src/app.zig @@ -8,8 +8,11 @@ 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 log = std.log.scoped(.app); const clamp = std.math.clamp; const assert = std.debug.assert; @@ -34,12 +37,13 @@ const DeviceChannel = struct { const Name = std.BoundedArray(u8, NIDaq.max_channel_name_size + 1); // +1 for null byte name: Name = .{}, - mutex: std.Thread.Mutex = .{}, samples: std.ArrayList(f64), units: i32 = NIDaq.c.DAQmx_Val_Volts, min_sample_rate: f64, max_sample_rate: f64, + min_value: f64, + max_value: f64, active_task: ?*TaskPool.Entry = null, @@ -48,50 +52,44 @@ const DeviceChannel = struct { } }; -const ChannelView = struct { +pub const ChannelView = struct { view_cache: Graph.Cache = .{}, view_rect: Graph.ViewOptions, - follow: bool = false, height: f32 = 300, - default_from: f32, - default_to: f32, - default_min_value: f64, - default_max_value: f64, + x_range: RangeF64, + y_range: RangeF64, source: union(enum) { file: usize, device: usize }, - const SourceObject = union(enum) { - file: *FileChannel, - device: *DeviceChannel, + pub fn isFromFile(self: *ChannelView) bool { + return self.source == .file; + } - fn samples(self: SourceObject) []const f64 { - return switch (self) { - .file => |file| file.samples, - .device => |device| device.samples.items, - }; - } + pub fn isFromDevice(self: *ChannelView) bool { + return self.source == .device; + } - fn lockSamples(self: SourceObject) void { - if (self == .device) { - self.device.mutex.lock(); - } - } + pub fn getViewRange(self: *ChannelView, axis: UI.Axis) *RangeF64 { + return switch (axis) { + .X => &self.view_rect.x_range, + .Y => &self.view_rect.y_range, + }; + } - fn unlockSamples(self: SourceObject) void { - if (self == .device) { - self.device.mutex.unlock(); - } - } - }; + pub fn getSampleRange(self: *ChannelView, axis: UI.Axis) *RangeF64 { + return switch (axis) { + .X => &self.x_range, + .Y => &self.y_range, + }; + } }; allocator: std.mem.Allocator, -ui: UI, should_close: bool = false, ni_daq_api: ?NIDaq.Api = null, ni_daq: ?NIDaq = null, @@ -102,23 +100,32 @@ device_channels: [max_channels]?DeviceChannel = .{ null } ** max_channels, started_collecting: bool = false, +channel_mutex: std.Thread.Mutex = .{}, + +// UI Fields +ui: UI, +screen: MainScreen, graph_controls: struct { drag_start: ?struct { index: f64, value: f64 } = null, } = .{}, - fullscreen_channel: ?*ChannelView = null, pub fn init(self: *App, allocator: std.mem.Allocator) !void { self.* = App{ .allocator = allocator, .ui = UI.init(allocator), - .task_pool = undefined + .task_pool = undefined, + .screen = undefined }; errdefer self.deinit(); + self.screen = MainScreen{ + .app = self + }; + if (NIDaq.Api.init()) |ni_daq_api| { self.ni_daq_api = ni_daq_api; @@ -158,7 +165,7 @@ pub fn init(self: *App, allocator: std.mem.Allocator) !void { } } - try TaskPool.init(&self.task_pool, allocator); + try TaskPool.init(&self.task_pool, &self.channel_mutex, allocator); errdefer self.task_pool.deinit(); } @@ -250,19 +257,15 @@ pub fn appendChannelFromFile(self: *App, path: []const u8) !void { }; errdefer self.loaded_files[loaded_file_index] = null; - const from: f32 = 0; - const to: f32 = @floatFromInt(samples.len); + const from: f64 = 0; + const to = @max(@as(f64, @floatFromInt(samples.len)) - 1, 0); self.channel_views.appendAssumeCapacity(ChannelView{ .view_rect = .{ - .from = from, - .to = to, - .min_value = min_value, - .max_value = max_value + .x_range = RangeF64.init(from, to), + .y_range = RangeF64.init(max_value, min_value) }, - .default_from = from, - .default_to = to, - .default_min_value = min_value, - .default_max_value = max_value, + .x_range = RangeF64.init(from, to), + .y_range = RangeF64.init(max_value, min_value), .source = .{ .file = loaded_file_index } }); errdefer _ = self.channel_views.pop(); @@ -294,847 +297,29 @@ pub fn appendChannelFromDevice(self: *App, channel_name: []const u8) !void { .name = 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) }; errdefer self.device_channels[device_channel_index] = null; self.channel_views.appendAssumeCapacity(ChannelView{ .view_rect = .{ - .from = 0, - .to = 0, - .min_value = min_value, - .max_value = max_value + .x_range = RangeF64.init(0, 0), + .y_range = RangeF64.init(max_value, min_value) }, - .default_from = 0, - .default_to = 0, - .default_min_value = min_value, - .default_max_value = max_value, + .x_range = RangeF64.init(0, 0), + .y_range = RangeF64.init(max_value, min_value), .source = .{ .device = device_channel_index } }); errdefer _ = self.channel_views.pop(); } -fn getChannelSource(self: *App, channel_view: *ChannelView) ?ChannelView.SourceObject { - switch (channel_view.source) { - .file => |index| { - if (self.loaded_files[index]) |*loaded_file| { - return ChannelView.SourceObject{ - .file = loaded_file - }; - } - }, - .device => |index| { - if (self.device_channels[index]) |*device_channel| { - return ChannelView.SourceObject{ - .device = device_channel - }; - } - } - } - - return null; -} - -pub fn button(self: *App, label: []const u8) *UI.Box { - return self.ui.createBox(.{ - .key = self.ui.keyFromString(label), - .size_x = UI.Sizing.initFixed(.text), - .size_y = UI.Sizing.initFixed(.text), - .flags = &.{ .draw_hot, .draw_active, .clickable }, - .padding = UI.Padding{ - .bottom = self.ui.rem(0.5), - .top = self.ui.rem(0.5), - .left = self.ui.rem(1), - .right = self.ui.rem(1) - }, - .hot_cursor = .mouse_cursor_pointing_hand, - .text = label - }); -} - -pub fn beginScrollbar(self: *App, key: UI.Key) *UI.Box { - const wrapper = self.ui.createBox(.{ - .key = key, - .layout_direction = .left_to_right, - .size_x = UI.Sizing.initGrowFull(), - .size_y = UI.Sizing.initGrowFull() - }); - wrapper.beginChildren(); - - const content_area = self.ui.createBox(.{ - .key = self.ui.keyFromString("Scrollable content area"), - .flags = &.{ .scrollable, .clip_view }, - .size_x = UI.Sizing.initGrowFull(), - .size_y = UI.Sizing.initFitChildren(), - }); - content_area.beginChildren(); - - const content_size = content_area.persistent.size.y; - const visible_percent = clamp(wrapper.persistent.size.y / content_size, 0, 1); - const sroll_offset = content_area.persistent.sroll_offset; - content_area.view_offset.y = sroll_offset * (1 - visible_percent) * content_size; - - return content_area; -} - -pub fn endScrollbar(self: *App) void { - const content_area = self.ui.parentBox().?; - content_area.endChildren(); - - const wrapper = self.ui.parentBox().?; - - const visible_percent = clamp(wrapper.persistent.size.y / content_area.persistent.size.y, 0, 1); - - { - const scrollbar_area = self.ui.createBox(.{ - .key = self.ui.keyFromString("Scrollbar area"), - .background = srcery.hard_black, - .flags = &.{ .scrollable }, - .size_x = .{ .fixed = .{ .pixels = 24 } }, - .size_y = UI.Sizing.initGrowFull() - }); - scrollbar_area.beginChildren(); - defer scrollbar_area.endChildren(); - - const draggable = self.ui.createBox(.{ - .key = self.ui.keyFromString("Scrollbar button"), - .background = srcery.black, - .flags = &.{ .draw_hot, .draw_active, .clickable, .draggable }, - .borders = UI.Borders.all(.{ .size = 4, .color = srcery.xgray3 }), - .size_x = UI.Sizing.initFixed(.{ .parent_percent = 1 }), - .size_y = UI.Sizing.initFixed(.{ .parent_percent = visible_percent }), - .hot_cursor = .mouse_cursor_pointing_hand - }); - - const sroll_offset = &content_area.persistent.sroll_offset; - const scrollbar_height = scrollbar_area.persistent.size.y; - const max_offset = scrollbar_height * (1 - visible_percent); - draggable.setFloatY(content_area.persistent.position.y + sroll_offset.* * max_offset); - - const draggable_signal = self.ui.signal(draggable); - if (draggable_signal.dragged()) { - sroll_offset.* += draggable_signal.drag.y / max_offset; - } - - const scroll_speed = 16; - const scrollbar_signal = self.ui.signal(scrollbar_area); - if (scrollbar_signal.scrolled()) { - sroll_offset.* -= scrollbar_signal.scroll.y / max_offset * scroll_speed; - } - - const content_area_signal = self.ui.signal(content_area); - if (content_area_signal.scrolled()) { - sroll_offset.* -= content_area_signal.scroll.y / max_offset * scroll_speed; - } - - sroll_offset.* = std.math.clamp(sroll_offset.*, 0, 1); - } - - - wrapper.endChildren(); -} - -fn showChannelViewGraph(self: *App, channel_view: *ChannelView) !void { - const zone2 = P.begin(@src(), "showChannelViewGraph"); - defer zone2.end(); - - var ui = &self.ui; - - const source = self.getChannelSource(channel_view) orelse return; - const samples = source.samples(); - source.lockSamples(); - defer source.unlockSamples(); - - const channel_rect_opts: *Graph.ViewOptions = &channel_view.view_rect; - - const graph_box = ui.createBox(.{ - .key = ui.keyFromString("Graph"), - .size_x = UI.Sizing.initGrowFull(), - .size_y = UI.Sizing.initGrowFull(), - .background = srcery.black, - .flags = &.{ .clickable, .draggable, .scrollable }, - }); - graph_box.beginChildren(); - defer graph_box.endChildren(); - // std.debug.print("{}\n", .{graph_box.persistent.size}); - - const graph_rect = graph_box.rect(); - - const signal = self.ui.signal(graph_box); - - var sample_value_under_mouse: ?f64 = null; - var sample_index_under_mouse: ?f64 = null; - - if (signal.hot) { - sample_index_under_mouse = channel_rect_opts.mapSampleXToIndex(0, graph_rect.width, signal.relative_mouse.x); - sample_value_under_mouse = channel_rect_opts.mapSampleYToValue(0, graph_rect.height, signal.relative_mouse.y); - } - - if (signal.dragged()) { - var x_offset: f64 = 0; - var y_offset: f64 = 0; - - y_offset = remap( - f64, - 0, graph_rect.height, - 0, channel_rect_opts.max_value - channel_rect_opts.min_value, - signal.drag.y - ); - - x_offset = remap( - f64, - 0, graph_rect.width, - 0, channel_rect_opts.to - channel_rect_opts.from, - signal.drag.x - ); - - channel_rect_opts.from -= @floatCast(x_offset); - channel_rect_opts.to -= @floatCast(x_offset); - channel_rect_opts.max_value += @floatCast(y_offset); - channel_rect_opts.min_value += @floatCast(y_offset); - } - - if (signal.scrolled() and sample_index_under_mouse != null and sample_value_under_mouse != null) { - var scale_factor: f32 = 1; - if (signal.scroll.y > 0) { - scale_factor -= 0.1; - } else { - scale_factor += 0.1; - } - - const center_index: f32 = @floatCast(sample_index_under_mouse.?); - channel_rect_opts.from = (channel_rect_opts.from - center_index) * scale_factor + center_index; - channel_rect_opts.to = (channel_rect_opts.to - center_index) * scale_factor + center_index; - - const center_value: f32 = @floatCast(sample_value_under_mouse.?); - channel_rect_opts.min_value = (channel_rect_opts.min_value - center_value) * scale_factor + center_value; - channel_rect_opts.max_value = (channel_rect_opts.max_value - center_value) * scale_factor + center_value; - } - - // var axis = UI.Axis.X; - // var zooming: bool = false; - // var start_sample: ?f64 = null; - // var stop_sample: ?f64 = null; - - // var controls = &self.graph_controls; - - // if (signal.hot) { - // if (signal.shift_modifier) { - // axis = UI.Axis.Y; - // } else { - // axis = UI.Axis.X; - // } - - // if (controls.graph_start_sample) |graph_start_sample| { - // axis = graph_start_sample.axis; - // zooming = true; - // } - - // // TODO: Don't use relative mouse movement, after a lock of sliding the point where you grabbed by drifts - // var mouse_sample: f64 = undefined; - // if (axis == .X) { - // const mouse_sample_index = channel_rect_opts.mapSampleXToIndex(0, graph_rect.width, signal.relative_mouse.x); - // mouse_sample = mouse_sample_index; - // } else if (axis == .Y) { - // const mouse_sample_value = channel_rect_opts.mapSampleYToValue(0, graph_rect.height, signal.relative_mouse.y); - // mouse_sample = mouse_sample_value; - // } - - // start_sample = mouse_sample; - - // if (signal.flags.contains(.right_pressed)) { - // controls.graph_start_sample = .{ - // .value = mouse_sample, - // .axis = axis - // }; - // } - - // if (controls.graph_start_sample) |graph_start_sample| { - // start_sample = graph_start_sample.value; - // stop_sample = mouse_sample; - // zooming = true; - // } - // } - - // if (zooming) { - // if (axis == .X) { - // graph_box.active_cursor = .mouse_cursor_resize_ew; - // } else { - // graph_box.active_cursor = .mouse_cursor_resize_ns; - // } - - // if (signal.flags.contains(.right_released)) { - // controls.graph_start_sample = null; - - // if (start_sample != null and stop_sample != null) { - // const lower_sample: f64 = @min(start_sample.?, stop_sample.?); - // const higher_sample: f64 = @max(start_sample.?, stop_sample.?); - - // if (axis == .X) { - // if (higher_sample - lower_sample > 1) { - // channel_rect_opts.from = @floatCast(lower_sample); - // channel_rect_opts.to = @floatCast(higher_sample); - // } else { - // // TODO: Show error message that selected range is too small - // } - // } else if (axis == .Y) { - // if (higher_sample - lower_sample > 0.001) { - // channel_rect_opts.min_value = lower_sample; - // channel_rect_opts.max_value = higher_sample; - // } else { - // // TODO: Show error message that selected range is too small - // } - // } - // } - - // start_sample = null; - // stop_sample = null; - // } - - // if (start_sample != null and stop_sample != null) { - - // const fill = ui.createBox(.{ - // .background = srcery.green.alpha(0.5), - // }); - - // if (axis == .X) { - // const start_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, start_sample.?); - // const stop_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, stop_sample.?); - - // fill.setFloatRect(.{ - // .x = @floatCast(@min(start_x, stop_x)), - // .y = graph_rect.y, - // .width = @floatCast(@abs(start_x - stop_x)), - // .height = graph_rect.height - // }); - // } else if (axis == .Y) { - // const start_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, start_sample.?); - // const stop_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, stop_sample.?); - - // fill.setFloatRect(.{ - // .x = graph_rect.x, - // .y = @floatCast(@min(start_y, stop_y)), - // .width = graph_rect.width, - // .height = @floatCast(@abs(start_y - stop_y)), - // }); - // } - // } - - // if (start_sample) |sample| { - // const marker = ui.createBox(.{ - // .background = srcery.green, - // }); - - // if (axis == .X) { - // const value = samples[@intFromFloat(sample)]; - // marker.setFmtText("{d:0.2} | {d:0.6}", .{sample, value}); - // marker.setFloatRect(UI.Rect{ - // .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), - // .y = graph_rect.y, - // .width = 1, - // .height = graph_rect.height - // }); - - // } else if (axis == .Y) { - // marker.setFmtText("{d:0.2}", .{sample}); - // marker.setFloatRect(UI.Rect{ - // .x = graph_rect.x, - // .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), - // .width = graph_rect.width, - // .height = 1 - // }); - // } - // } - - // if (stop_sample) |sample| { - // const marker = ui.createBox(.{ - // .background = srcery.green, - // }); - - // marker.setFmtText("{d:0.2}", .{sample}); - // if (axis == .X) { - // marker.setFloatRect(.{ - // .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), - // .y = graph_rect.y, - // .width = 1, - // .height = graph_rect.height - // }); - // } else if (axis == .Y) { - // marker.setFloatRect(.{ - // .x = graph_rect.x, - // .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), - // .width = graph_rect.width, - // .height = 1 - // }); - // } - // } - // } else { - // if (signal.dragged()) { - // const middle_mouse_drag = signal.flags.contains(.middle_dragging); - // var x_offset: f64 = 0; - // var y_offset: f64 = 0; - - // if (signal.shift_modifier or middle_mouse_drag) { - // y_offset = remap( - // f64, - // 0, graph_rect.height, - // 0, channel_rect_opts.max_value - channel_rect_opts.min_value, - // signal.drag.y - // ); - // } - - // if (!signal.shift_modifier or middle_mouse_drag) { - // x_offset = remap( - // f64, - // 0, graph_rect.width, - // 0, channel_rect_opts.to - channel_rect_opts.from, - // signal.drag.x - // ); - // } - - // channel_rect_opts.from -= @floatCast(x_offset); - // channel_rect_opts.to -= @floatCast(x_offset); - // channel_rect_opts.max_value += @floatCast(y_offset); - // channel_rect_opts.min_value += @floatCast(y_offset); - // } - // } - - Graph.drawCached(&channel_view.view_cache, graph_box.persistent.size, channel_rect_opts.*, samples); - if (channel_view.view_cache.texture) |texture| { - graph_box.texture = texture.texture; - } -} - -fn showChannelViewRulerMarker(self: *App, channel_view: *ChannelView, rect: UI.Rect, axis: UI.Axis, index: f64, size: f32) void { - assert(axis == .X); - assert(0 <= size and size <= 1); - - const view_with_rect = Graph.ViewOptionsWithRect{ - .view = channel_view.view_rect, - .rect = rect, - }; - - _ = self.ui.createBox(.{ - .background = srcery.yellow, - .float_rect = .{ - .width = 1, - .height = rect.height * size, - .x = @floatCast(view_with_rect.mapSampleIndexToX(index)), - .y = rect.y, - }, - }); -} - -fn showChannelViewRuler(self: *App, channel_view: *ChannelView, axis: UI.Axis) void { - assert(axis == .X); - - const channel_rect_opts: *Graph.ViewOptions = &channel_view.view_rect; - - const source = self.getChannelSource(channel_view) orelse return; - const samples = source.samples(); - source.lockSamples(); - defer source.unlockSamples(); - - const sample_count: f32 = @floatFromInt(samples.len); - - var ui = &self.ui; - const container = ui.parentBox().?; - - const ruler_rect = container.rect(); - - const range_size = channel_view.default_to - channel_view.default_from; - var subdivisions: f32 = 20; - while (true) { - assert(subdivisions > 0); - const pixels_per_division = remap( - f64, - 0, channel_rect_opts.to - channel_rect_opts.from, - 0, ruler_rect.width, - range_size / subdivisions - ); - - if (pixels_per_division < 50) { - subdivisions /= 2; - } else if (pixels_per_division > 200) { - subdivisions *= 2; - } else { - break; - } - } - - const step = range_size / subdivisions; - - const view_with_rect = Graph.ViewOptionsWithRect{ - .view = channel_view.view_rect, - .rect = ruler_rect, - }; - - const number_range = channel_rect_opts.to - channel_rect_opts.from; - var precision: u32 = 1; - if (number_range < 1_000) { - precision = 5; - } else if (number_range < 10_000) { - precision = 5; - } else if (number_range < 100_000) { - precision = 3; - } else if (number_range < 1_000_000) { - precision = 2; - } - - { - self.showChannelViewRulerMarker( - channel_view, - ruler_rect, - axis, - 0, - 1 - ); - - _ = ui.createBox(.{ - .float_rect = .{ - .width = 1, - .height = ruler_rect.height/2, - .x = @floatCast(view_with_rect.mapSampleIndexToX(0) - 4), - .y = ruler_rect.y + ruler_rect.height/2, - }, - .text = "0", - .align_x = .end - }); - } - - { - self.showChannelViewRulerMarker( - channel_view, - ruler_rect, - axis, - sample_count, - 1 - ); - - const text = ui.createBox(.{ - .float_rect = .{ - .width = 1, - .height = ruler_rect.height/2, - .x = @floatCast(view_with_rect.mapSampleIndexToX(sample_count) + 4), - .y = ruler_rect.y + ruler_rect.height/2, - }, - .align_x = .start - }); - text.setFmtText("{d:.0}", .{sample_count}); - } - - { - var marker = utils.roundNearestDown(f64, @max(channel_rect_opts.from, channel_view.default_from + step), step); - while (marker < @min(channel_rect_opts.to, channel_view.default_to - step/2)) : (marker += step) { - self.showChannelViewRulerMarker( - channel_view, - ruler_rect, - axis, - marker, - 0.5 - ); - - _ = ui.createBox(.{ - .float_rect = .{ - .width = 1, - .height = ruler_rect.height/2, - .x = @floatCast(view_with_rect.mapSampleIndexToX(marker)), - .y = ruler_rect.y + ruler_rect.height/2, - }, - .align_x = .center, - .scientific_number = marker, - .scientific_precision = precision - }); - // if (marker >= 1_000_000) { - // text.setFmtText("{d:.1}M", .{ marker / 1_000_000 }); - // } else { - // text.setFmtText("{d:.0}K", .{ marker / 1_000 }); - // } - } - } - - { - var marker = utils.roundNearestDown(f64, @max(channel_rect_opts.from, channel_view.default_from + step), step) - step/2; - while (marker < @min(channel_rect_opts.to, channel_view.default_to)) : (marker += step) { - self.showChannelViewRulerMarker( - channel_view, - ruler_rect, - axis, - marker, - 0.25 - ); - } - } -} - -fn showChannelView(self: *App, channel_view: *ChannelView, height: UI.Sizing) !void { - const zone2 = P.begin(@src(), "showChannelView"); - defer zone2.end(); - - var ui = &self.ui; - - const channel_view_box = ui.createBox(.{ - .key = UI.Key.initPtr(channel_view), - .layout_direction = .top_to_bottom, - .size_x = UI.Sizing.initGrowFull(), - .size_y = height - }); - channel_view_box.beginChildren(); - defer channel_view_box.endChildren(); - - const ruler_size = UI.Sizing.initFixed(.{ .pixels = 32 }); - - var y_markers: *UI.Box = undefined; - var x_markers: *UI.Box = undefined; - - { - const container = ui.createBox(.{ - .layout_direction = .left_to_right, - .size_x = UI.Sizing.initGrowFull(), - .size_y = UI.Sizing.initGrowFull(), - }); - container.beginChildren(); - defer container.endChildren(); - - y_markers = ui.createBox(.{ - .key = ui.keyFromString("Y markers"), - .size_x = ruler_size, - .size_y = UI.Sizing.initGrowFull(), - .background = srcery.hard_black, - .flags = &.{ .clickable }, - .hot_cursor = .mouse_cursor_pointing_hand - }); - - try self.showChannelViewGraph(channel_view); - } - - { - const container = ui.createBox(.{ - .layout_direction = .left_to_right, - .size_x = UI.Sizing.initGrowFull(), - .size_y = ruler_size, - }); - container.beginChildren(); - defer container.endChildren(); - - const fullscreen = ui.createBox(.{ - .key = ui.keyFromString("Fullscreen toggle"), - .size_y = ruler_size, - .size_x = ruler_size, - .background = srcery.hard_black, - .hot_cursor = .mouse_cursor_pointing_hand, - .flags = &.{ .draw_hot, .draw_active, .clickable }, - .texture = Assets.fullscreen, - .texture_size = .{ .x = 28, .y = 28 } - }); - if (ui.signal(fullscreen).clicked()) { - if (self.fullscreen_channel != null and self.fullscreen_channel.? == channel_view) { - self.fullscreen_channel = null; - } else { - self.fullscreen_channel = channel_view; - } - } - - x_markers = ui.createBox(.{ - .key = ui.keyFromString("X markers"), - .size_y = ruler_size, - .size_x = UI.Sizing.initGrowFull(), - .background = srcery.hard_black, - .flags = &.{ .clickable, .clip_view }, - .hot_cursor = .mouse_cursor_pointing_hand - }); - } - - // { - // const zone = P.begin(@src(), "Y markers"); - // defer zone.end(); - - // y_markers.beginChildren(); - // defer y_markers.endChildren(); - - // const y_axis_rect = y_markers.rect(); - - // const min_gap_between_markers = 8; - - // const y_range = channel_rect_opts.max_value - channel_rect_opts.min_value; - - // var axis_marker_size = min_gap_between_markers / y_axis_rect.height * y_range; - // axis_marker_size = @ceil(axis_marker_size); - - // var marker = utils.roundNearestUp(f64, @max(channel_rect_opts.min_value, channel_view.default_min_value), axis_marker_size); - // while (marker < @min(channel_rect_opts.max_value, channel_view.default_max_value)) : (marker += axis_marker_size) { - // const marker_box = ui.createBox(.{ - // .background = rl.Color.yellow, - // }); - // marker_box.setFloatRect(.{ - // .width = y_axis_rect.width/5, - // .height = 1, - // .x = y_axis_rect.x + y_axis_rect.width/5*4, - // .y = @floatCast(remap( - // f64, - // channel_rect_opts.max_value, channel_rect_opts.min_value, - // y_axis_rect.y, y_axis_rect.y + y_axis_rect.height, - // marker - // )) - // }); - // } - // } - - { - const zone = P.begin(@src(), "X markers"); - defer zone.end(); - - x_markers.beginChildren(); - defer x_markers.endChildren(); - - self.showChannelViewRuler(channel_view, .X); - } -} - -pub fn showWindowChannels(self: *App) !void { - const zone = P.begin(@src(), "showWindowChannels"); - defer zone.end(); - - var ui = &self.ui; - const root = ui.parentBox().?; - root.layout_direction = .top_to_bottom; - - { - const toolbar = ui.createBox(.{ - .background = srcery.black, - .layout_direction = .left_to_right, - .size_x = .{ .fixed = .{ .parent_percent = 1 } }, - .size_y = .{ .fixed = .{ .font_size = 2 } } - }); - toolbar.beginChildren(); - defer toolbar.endChildren(); - - var start_all = self.button("Start/Stop button"); - start_all.borders = UI.Borders.all(.{ .size = 4, .color = srcery.red }); - start_all.background = srcery.black; - start_all.size.y = UI.Sizing.initFixed(.{ .parent_percent = 1 }); - start_all.padding.top = 0; - start_all.padding.bottom = 0; - if (ui.signal(start_all).clicked()) { - self.started_collecting = !self.started_collecting; - - - if (self.ni_daq) |*ni_daq| { - for (self.channel_views.slice()) |*channel_view| { - const source = self.getChannelSource(channel_view) orelse continue; - - if (source == .device) { - const device_channel = source.device; - if (device_channel.active_task) |task| { - try task.stop(); - device_channel.active_task = null; - } else { - const channel_name = device_channel.name.buffer[0..device_channel.name.len :0]; - device_channel.active_task = try self.task_pool.launchAIVoltageChannel( - ni_daq, - &device_channel.mutex, - &device_channel.samples, - .{ - .continous = .{ .sample_rate = device_channel.max_sample_rate } - }, - .{ - .min_value = channel_view.default_min_value, - .max_value = channel_view.default_max_value, - .units = device_channel.units, - .channel = channel_name - } - ); - - channel_view.follow = true; - } - } - } - } - } - if (self.started_collecting) { - start_all.setText("Stop"); - } else { - start_all.setText("Start"); - } - } - - if (self.started_collecting) { - for (self.channel_views.slice()) |*_channel_view| { - const channel_view: *ChannelView = _channel_view; - const source = self.getChannelSource(channel_view) orelse continue; - - if (source == .device) { - source.lockSamples(); - defer source.unlockSamples(); - - const sample_rate = source.device.active_task.?.sampling.continous.sample_rate; - const sample_count: f32 = @floatFromInt(source.samples().len); - - channel_view.view_rect.from = 0; - if (sample_count > channel_view.view_rect.to) { - channel_view.view_rect.to = 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()); - - } else { - const scroll_area = self.beginScrollbar(ui.keyFromString("Channels")); - defer self.endScrollbar(); - scroll_area.layout_direction = .top_to_bottom; - - for (self.channel_views.slice()) |*channel_view| { - try self.showChannelView(channel_view, UI.Sizing.initFixed(.{ .pixels = channel_view.height })); - } - - { - const add_channel_view = ui.createBox(.{ - .size_x = UI.Sizing.initGrowFull(), - .size_y = UI.Sizing.initFixed(.{ .pixels = 200 }), - .align_x = .center, - .align_y = .center, - .layout_gap = 32 - }); - add_channel_view.beginChildren(); - defer add_channel_view.endChildren(); - - const add_from_file = self.button("Add from file"); - add_from_file.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); - if (ui.signal(add_from_file).clicked()) { - if (Platform.openFilePicker(self.allocator)) |filename| { - defer self.allocator.free(filename); - - // TODO: Handle error - self.appendChannelFromFile(filename) catch @panic("Failed to append channel from file"); - } else |err| { - // TODO: Show error message to user; - log.err("Failed to pick file: {}", .{ err }); - } - } - - const add_from_device = self.button("Add from device"); - add_from_device.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); - if (ui.signal(add_from_device).clicked()) { - - } - } - } +pub fn listChannelViews(self: *App) []ChannelView { + return self.channel_views.slice(); } pub fn tick(self: *App) !void { - if (rl.isKeyPressed(.key_escape)) { - if (self.fullscreen_channel != null) { - self.fullscreen_channel = null; - } else { - self.should_close = true; - } - } - rl.clearBackground(srcery.black); var ui = &self.ui; @@ -1142,8 +327,90 @@ pub fn tick(self: *App) !void { ui.begin(); defer ui.end(); - try self.showWindowChannels(); + self.channel_mutex.lock(); + defer self.channel_mutex.unlock(); + + try self.screen.tick(); } ui.draw(); +} + +// ------------------------ Channel management -------------------------------- // + +pub fn getChannelSamples(self: *App, channel_view: *ChannelView) []const f64 { + return switch (channel_view.source) { + .file => |index| { + const loaded_file = self.loaded_files[index].?; + return loaded_file.samples; + }, + .device => |index| { + const device_channel = self.device_channels[index].?; + return device_channel.samples.items; + } + }; +} + +pub fn getChannelSourceDevice(self: *App, channel_view: *ChannelView) ?*DeviceChannel { + if (channel_view.source == .device) { + const device_channel_index = channel_view.source.device; + return &self.device_channels[device_channel_index].?; + } + + 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 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 { + const ni_daq = &(self.ni_daq orelse return); + + const device_channel = self.getChannelSourceDevice(channel_view) orelse return; + if (device_channel.active_task != null) { + // Device channel is already reading + return; + } + + const channel_name = device_channel.name.buffer[0..device_channel.name.len :0]; + + const task = self.task_pool.launchAIVoltageChannel( + ni_daq, + &device_channel.samples, + .{ + .continous = .{ .sample_rate = device_channel.max_sample_rate } + }, + .{ + .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.*); + } + + return; + }; + + device_channel.active_task = task; } \ No newline at end of file diff --git a/src/graph.zig b/src/graph.zig index 92690b2..74c5673 100644 --- a/src/graph.zig +++ b/src/graph.zig @@ -2,6 +2,7 @@ const builtin = @import("builtin"); const std = @import("std"); const rl = @import("raylib"); const srcery = @import("./srcery.zig"); +const RangeF64 = @import("./range.zig").RangeF64; const remap = @import("./utils.zig").remap; const assert = std.debug.assert; @@ -19,64 +20,10 @@ comptime { } pub const ViewOptions = struct { - from: f32, // inclusive - to: f32, // inclusive - min_value: f64, - max_value: f64, - left_aligned: bool = true, + x_range: RangeF64, + y_range: RangeF64, + color: rl.Color = srcery.red, - - pub fn mapSampleIndexToX(self: ViewOptions, to_x: f64, to_width: f64, index: f64) f64 { - return remap( - f64, - self.from, self.to, - to_x, to_x + to_width, - index - ); - } - - pub fn mapSampleXToIndex(self: ViewOptions, from_x: f64, from_width: f64, x: f64) f64 { - return remap( - f64, - from_x, from_x + from_width, - self.from, self.to, - x - ); - } - - pub fn mapSampleValueToY(self: ViewOptions, to_y: f64, to_height: f64, sample: f64) f64 { - return remap( - f64, - self.min_value, self.max_value, - to_y + to_height, to_y, - sample - ); - } - - pub fn mapSampleYToValue(self: ViewOptions, to_y: f64, to_height: f64, y: f64) f64 { - return remap( - f64, - to_y + to_height, to_y, - self.min_value, self.max_value, - y - ); - } - - pub fn mapSampleVec2(self: ViewOptions, draw_rect: rl.Rectangle, index: f64, sample: f64) Vec2 { - return .{ - .x = @floatCast(self.mapSampleIndexToX(draw_rect.x, draw_rect.width, index)), - .y = @floatCast(self.mapSampleValueToY(draw_rect.y, draw_rect.height, sample)) - }; - } -}; - -pub const ViewOptionsWithRect = struct { - view: ViewOptions, - rect: Rect, - - pub fn mapSampleIndexToX(self: ViewOptionsWithRect, index: f64) f64 { - return self.view.mapSampleIndexToX(self.rect.x, self.rect.width, index); - } }; pub const Cache = struct { @@ -114,81 +61,106 @@ pub const Cache = struct { } }; -fn clampIndex(value: f32, size: usize) f32 { - const size_f32: f32 = @floatFromInt(size); - return clamp(value, 0, size_f32); +fn drawSamplesExact(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { + rl.beginScissorMode( + @intFromFloat(draw_rect.x), + @intFromFloat(draw_rect.y), + @intFromFloat(draw_rect.width), + @intFromFloat(draw_rect.height), + ); + defer rl.endScissorMode(); + + const draw_x_range, const draw_y_range = RangeF64.initRect(draw_rect); + + const i_range = options.x_range.intersectPositive( + RangeF64.init(0, @max(@as(f64, @floatFromInt(samples.len)) - 1, 0)) + ); + if (i_range.lower > i_range.upper) { + return; + } + + const from_i: usize = @intFromFloat(i_range.lower); + const to_i: usize = @intFromFloat(i_range.upper); + if (to_i == 0 or from_i == to_i) { + return; + } + + for (from_i..(to_i-1)) |i| { + const i_f64: f64 = @floatFromInt(i); + rl.drawLineV( + .{ + .x = @floatCast(options.x_range.remapTo(draw_x_range, i_f64)), + .y = @floatCast(options.y_range.remapTo(draw_y_range, samples[i])), + }, + .{ + .x = @floatCast(options.x_range.remapTo(draw_x_range, i_f64 + 1)), + .y = @floatCast(options.y_range.remapTo(draw_y_range, samples[i + 1])), + }, + options.color + ); + } } -fn clampIndexUsize(value: f32, size: usize) usize { - const size_f32: f32 = @floatFromInt(size); - return @intFromFloat(clamp(value, 0, size_f32)); +fn drawSamplesApproximate(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { + const draw_x_range, const draw_y_range = RangeF64.initRect(draw_rect); + + const i_range = options.x_range.intersectPositive( + RangeF64.init(0, @max(@as(f64, @floatFromInt(samples.len)) - 1, 0)) + ); + if (i_range.lower > i_range.upper) { + return; + } + + const samples_per_column = options.x_range.size() / draw_x_range.size(); + assert(samples_per_column >= 1); + + var i = i_range.lower; + while (i < i_range.upper - samples_per_column) : (i += samples_per_column) { + const column_start: usize = @intFromFloat(i); + const column_end: usize = @intFromFloat(i + samples_per_column); + const column_samples = samples[column_start..column_end]; + if (column_samples.len == 0) continue; + + var column_min = column_samples[0]; + var column_max = column_samples[0]; + + for (column_samples) |sample| { + column_min = @min(column_min, sample); + column_max = @max(column_max, sample); + } + + const x = options.x_range.remapTo(draw_x_range, i); + const y_min = options.y_range.remapTo(draw_y_range, column_min); + const y_max = options.y_range.remapTo(draw_y_range, column_max); + + if (@abs(y_max - y_min) < 1) { + const avg = (y_min + y_max) / 2; + rl.drawLineV( + .{ .x = @floatCast(x), .y = @floatCast(avg) }, + .{ .x = @floatCast(x), .y = @floatCast(avg+1) }, + options.color + ); + } else { + rl.drawLineV( + .{ .x = @floatCast(x), .y = @floatCast(y_min) }, + .{ .x = @floatCast(x), .y = @floatCast(y_max) }, + options.color + ); + } + } } fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { - assert(options.left_aligned); // TODO: - assert(options.to >= options.from); + const x_range = options.x_range; - if (options.from > @as(f32, @floatFromInt(samples.len))) return; - if (options.to < 0) return; + if (x_range.lower >= @as(f64, @floatFromInt(samples.len))) return; + if (x_range.upper < 0) return; - const sample_count = options.to - options.from; - const samples_per_column = sample_count / draw_rect.width; - - const samples_threshold = 2; - if (samples_per_column >= samples_threshold) { - var i = clampIndex(options.from, samples.len); - while (i < clampIndex(options.to, samples.len)) : (i += samples_per_column) { - const from_index = clampIndexUsize(i, samples.len); - const to_index = clampIndexUsize(i+samples_per_column, samples.len); - const column_samples = samples[from_index..to_index]; - if (column_samples.len == 0) continue; - - var column_min = column_samples[0]; - var column_max = column_samples[0]; - - for (column_samples) |sample| { - column_min = @min(column_min, sample); - column_max = @max(column_max, sample); - } - - const x = options.mapSampleIndexToX(draw_rect.x, draw_rect.width, @floatFromInt(from_index)); - const y_min = options.mapSampleValueToY(draw_rect.y, draw_rect.height, column_min); - const y_max = options.mapSampleValueToY(draw_rect.y, draw_rect.height, column_max); - - if (@abs(y_max - y_min) < 1) { - const avg = (y_min + y_max) / 2; - rl.drawLineV( - .{ .x = @floatCast(x), .y = @floatCast(avg) }, - .{ .x = @floatCast(x), .y = @floatCast(avg+1) }, - options.color - ); - } else { - rl.drawLineV( - .{ .x = @floatCast(x), .y = @floatCast(y_min) }, - .{ .x = @floatCast(x), .y = @floatCast(y_max) }, - options.color - ); - } - } + const samples_per_column = x_range.size() / draw_rect.width; + if (samples_per_column >= 2) { + drawSamplesApproximate(draw_rect, options, samples); } else { - rl.beginScissorMode( - @intFromFloat(draw_rect.x), - @intFromFloat(draw_rect.y), - @intFromFloat(draw_rect.width), - @intFromFloat(draw_rect.height), - ); - defer rl.endScissorMode(); - - const from_index = clampIndexUsize(@floor(options.from), samples.len); - const to_index = clampIndexUsize(@ceil(options.to) + 1, samples.len); - - if (to_index - from_index > 0) { - for (from_index..(to_index-1)) |i| { - const from_point = options.mapSampleVec2(draw_rect, @floatFromInt(i), samples[i]); - const to_point = options.mapSampleVec2(draw_rect, @floatFromInt(i + 1), samples[i + 1]); - rl.drawLineV(from_point, to_point, options.color); - } - } + drawSamplesExact(draw_rect, options, samples); } } diff --git a/src/main.zig b/src/main.zig index d2a7418..1e332e4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -163,11 +163,11 @@ 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.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); + // try app.appendChannelFromDevice("Dev1/ai0"); + 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"); } var profiler: ?Profiler = null; @@ -227,4 +227,5 @@ pub fn main() !void { test { _ = @import("./ni-daq/root.zig"); + _ = @import("./range.zig"); } \ No newline at end of file diff --git a/src/ni-daq/task-pool.zig b/src/ni-daq/task-pool.zig index fc5c7f6..686fa91 100644 --- a/src/ni-daq/task-pool.zig +++ b/src/ni-daq/task-pool.zig @@ -27,7 +27,6 @@ pub const Entry = struct { sampling: Sampling, - mutex: *std.Thread.Mutex, samples: *std.ArrayList(f64), pub fn stop(self: *Entry) !void { @@ -44,10 +43,12 @@ pub const Entry = struct { running: bool = false, read_thread: std.Thread, entries: [max_tasks]Entry = undefined, +mutex: *std.Thread.Mutex, -pub fn init(self: *TaskPool, allocator: std.mem.Allocator) !void { +pub fn init(self: *TaskPool, mutex: *std.Thread.Mutex, allocator: std.mem.Allocator) !void { self.* = TaskPool{ - .read_thread = undefined + .read_thread = undefined, + .mutex = mutex }; self.running = true; @@ -71,12 +72,13 @@ pub fn deinit(self: *TaskPool) void { self.read_thread.join(); } -fn readAnalog(entry: *Entry, timeout: f64) !void { +fn readAnalog(task_pool: *TaskPool, entry: *Entry, timeout: f64) !void { if (!entry.in_use) return; if (!entry.running) return; - entry.mutex.lock(); - defer entry.mutex.unlock(); + task_pool.mutex.lock(); + defer task_pool.mutex.unlock(); + switch (entry.sampling) { .finite => |args| { @@ -105,7 +107,7 @@ fn readThreadCallback(task_pool: *TaskPool) void { while (task_pool.running) { for (&task_pool.entries) |*entry| { - readAnalog(entry, timeout) catch |e| { + readAnalog(task_pool, entry, timeout) catch |e| { log.err("readAnalog() failed in thread: {}", .{e}); if (@errorReturnTrace()) |trace| { std.debug.dumpStackTrace(trace.*); @@ -115,7 +117,7 @@ fn readThreadCallback(task_pool: *TaskPool) void { }; } - std.time.sleep(0.05 * std.time.ns_per_s); + std.time.sleep(timeout * std.time.ns_per_s); } } @@ -131,7 +133,6 @@ fn findFreeEntry(self: *TaskPool) ?*Entry { pub fn launchAIVoltageChannel( self: *TaskPool, ni_daq: *NIDaq, - mutex: *std.Thread.Mutex, samples: *std.ArrayList(f64), sampling: Sampling, options: NIDaq.Task.AIVoltageChannelOptions @@ -162,7 +163,6 @@ pub fn launchAIVoltageChannel( .started_sampling_ns = started_at, .in_use = true, .running = true, - .mutex = mutex, .samples = samples, .sampling = sampling, }; diff --git a/src/range.zig b/src/range.zig new file mode 100644 index 0000000..b5afb3f --- /dev/null +++ b/src/range.zig @@ -0,0 +1,196 @@ +const std = @import("std"); +const rl = @import("raylib"); +const remap_number = @import("./utils.zig").remap; +const assert = std.debug.assert; + +pub fn Range(Number: type) type { + return struct { + const Self = @This(); + + lower: Number, + upper: Number, + + pub fn init(lower: Number, upper: Number) Self { + return Self{ + .lower = lower, + .upper = upper + }; + } + + pub fn initRect(rect: rl.Rectangle) [2]Self { + return .{ + initRectX(rect), + initRectY(rect) + }; + } + + pub fn initRectX(rect: rl.Rectangle) Self { + return init(rect.x, rect.x + rect.width); + } + + pub fn initRectY(rect: rl.Rectangle) Self { + return init(rect.y, rect.y + rect.height); + } + + pub fn flip(self: Self) Self { + return init(self.upper, self.lower); + } + + pub fn size(self: Self) Number { + return @abs(self.upper - self.lower); + } + + pub fn hasExclusive(self: Self, number: Number) bool { + var upper = self.upper; + var lower = self.lower; + if (self.lower > self.upper) { + lower = self.upper; + upper = self.lower; + } + assert(lower <= upper); + + return lower < number and number < upper; + + } + + pub fn hasInclusive(self: Self, number: Number) bool { + var upper = self.upper; + var lower = self.lower; + if (self.lower > self.upper) { + lower = self.upper; + upper = self.lower; + } + assert(lower <= upper); + + return lower <= number and number <= upper; + } + + pub fn remapTo(from: Self, to: Self, value: Number) Number { + return remap_number(Number, from.lower, from.upper, to.lower, to.upper, value); + } + + pub fn add(self: Self, amount: Number) Self { + return init( + self.lower + amount, + self.upper + amount + ); + } + + pub fn sub(self: Self, amount: Number) Self { + return init( + self.lower - amount, + self.upper - amount + ); + } + + pub fn mul(self: Self, factor: Number) Self { + return init( + self.lower * factor, + self.upper * factor + ); + } + + pub fn zoom(self: Self, center: Number, factor: Number) Self { + return init( + (self.lower - center) * factor + center, + (self.upper - center) * factor + center + ); + } + + pub fn intersectPositive(self: Self, other: Self) Self { + // TODO: Figure out how would an intersection of "negative" ranges should look + // For now just coerce the negative ranges to positive ones. + const self_positive = self.toPositive(); + const other_positive = other.toPositive(); + + return init( + @max(self_positive.lower, other_positive.lower), + @min(self_positive.upper, other_positive.upper) + ); + } + + pub fn toPositive(self: Self) Self { + if (self.isPositive()) { + return self; + } else { + return self.flip(); + } + } + + pub fn isPositive(self: Self) bool { + return self.upper >= self.lower; + } + + pub fn isNegative(self: Self) bool { + return self.lower >= self.upper; + } + + pub fn grow(self: Self, amount: Number) Self { + if (self.isPositive()) { + return init( + self.lower - amount, + self.upper + amount + ); + } else { + return init( + self.lower + amount, + self.upper - amount + ); + } + } + }; +} + +pub const RangeF32 = Range(f32); +pub const RangeF64 = Range(f64); + +test "math operations" { + try std.testing.expectEqual( + RangeF32.init(0, 10).mul(5), + RangeF32.init(0, 50) + ); + + try std.testing.expectEqual( + RangeF32.init(0, 10).add(5), + RangeF32.init(5, 15) + ); + + try std.testing.expectEqual( + RangeF32.init(0, 10).sub(5), + RangeF32.init(-5, 5) + ); +} + +test "size" { + try std.testing.expectEqual( + RangeF32.init(0, 10).size(), + 10 + ); + + try std.testing.expectEqual( + RangeF32.init(-10, 0).size(), + 10 + ); +} + +test "intersection" { + try std.testing.expectEqual( + RangeF32.init(0, 10).intersectPositive(RangeF32.init(5, 8)), + RangeF32.init(5, 8) + ); + + try std.testing.expectEqual( + RangeF32.init(0, 10).intersectPositive(RangeF32.init(-5, 8)), + RangeF32.init(0, 8) + ); + + try std.testing.expectEqual( + RangeF32.init(0, 10).intersectPositive(RangeF32.init(5, 15)), + RangeF32.init(5, 10) + ); + + try std.testing.expectEqual( + RangeF32.init(0, 10).intersectPositive(RangeF32.init(20, 30)), + RangeF32.init(20, 10) + ); +} \ No newline at end of file diff --git a/src/screens/main_screen.zig b/src/screens/main_screen.zig new file mode 100644 index 0000000..c43046d --- /dev/null +++ b/src/screens/main_screen.zig @@ -0,0 +1,587 @@ +const std = @import("std"); +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 RangeF64 = @import("../range.zig").RangeF64; +const Graph = @import("../graph.zig"); +const Assets = @import("../assets.zig"); +const utils = @import("../utils.zig"); + +const MainScreen = @This(); + +const log = std.log.scoped(.main_screen); +const ChannelView = App.ChannelView; +const assert = std.debug.assert; +const remap = utils.remap; + +const zoom_speed = 0.1; + +app: *App, +fullscreen_channel: ?*App.ChannelView = null, + +axis_zoom: ?struct { + channel: *App.ChannelView, + axis: UI.Axis, + start: f64 +} = null, + +fn showChannelViewGraph(self: *MainScreen, channel_view: *ChannelView) !void { + var ui = &self.app.ui; + + const samples = self.app.getChannelSamples(channel_view); + const view_rect: *Graph.ViewOptions = &channel_view.view_rect; + + const graph_box = ui.createBox(.{ + .key = ui.keyFromString("Graph"), + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initGrowFull(), + .background = srcery.black, + .flags = &.{ .clickable, .draggable, .scrollable }, + }); + graph_box.beginChildren(); + defer graph_box.endChildren(); + + const graph_rect = graph_box.rect(); + + const signal = ui.signal(graph_box); + + var sample_value_under_mouse: ?f64 = null; + var sample_index_under_mouse: ?f64 = null; + + const mouse_x_range = RangeF64.init(0, graph_rect.width); + const mouse_y_range = RangeF64.init(0, graph_rect.height); + + if (signal.hot) { + sample_index_under_mouse = mouse_x_range.remapTo(view_rect.x_range, signal.relative_mouse.x); + sample_value_under_mouse = mouse_y_range.remapTo(view_rect.y_range, signal.relative_mouse.y); + } + + if (signal.dragged()) { + const x_offset = mouse_x_range.remapTo(RangeF64.init(0, view_rect.x_range.size()), signal.drag.x); + const y_offset = mouse_y_range.remapTo(RangeF64.init(0, view_rect.y_range.size()), signal.drag.y); + + view_rect.x_range = view_rect.x_range.sub(x_offset); + view_rect.y_range = view_rect.y_range.add(y_offset); + } + + if (signal.scrolled() and sample_index_under_mouse != null and sample_value_under_mouse != null) { + var scale_factor: f64 = 1; + if (signal.scroll.y > 0) { + scale_factor -= zoom_speed; + } else { + scale_factor += zoom_speed; + } + + view_rect.x_range = view_rect.x_range.zoom(sample_index_under_mouse.?, scale_factor); + view_rect.y_range = view_rect.y_range.zoom(sample_value_under_mouse.?, scale_factor); + } + + Graph.drawCached(&channel_view.view_cache, graph_box.persistent.size, view_rect.*, samples); + if (channel_view.view_cache.texture) |texture| { + graph_box.texture = texture.texture; + } +} + +fn getLineOnRuler( + channel_view: *ChannelView, + ruler: *UI.Box, + axis: UI.Axis, + + along_axis_pos: f64, + cross_axis_pos: f64, + cross_axis_size: f64 +) rl.Rectangle { + + const view_rect = channel_view.view_rect; + + const along_axis_size = switch (axis) { + .X => view_rect.x_range.size()/ruler.persistent.size.x, + .Y => view_rect.y_range.size()/ruler.persistent.size.y, + }; + + return getRectOnRuler( + channel_view, + ruler, + axis, + + along_axis_pos, + along_axis_size, + cross_axis_pos, + cross_axis_size + ); +} + +fn getRectOnRuler( + channel_view: *ChannelView, + ruler: *UI.Box, + axis: UI.Axis, + + along_axis_pos: f64, + along_axis_size: f64, + cross_axis_pos: f64, + cross_axis_size: f64 +) rl.Rectangle { + assert(0 <= cross_axis_size and cross_axis_size <= 1); + + const rect = ruler.rect(); + const rect_height: f64 = @floatCast(rect.height); + const rect_width: f64 = @floatCast(rect.width); + const view_range = channel_view.getViewRange(axis); + + if (axis == .X) { + const width_range = RangeF64.init(0, rect.width); + var result = rl.Rectangle{ + .width = @floatCast(along_axis_size / view_range.size() * rect_width), + .height = @floatCast(rect_height * cross_axis_size), + .x = @floatCast(view_range.remapTo(width_range, along_axis_pos)), + .y = @floatCast(rect_height * cross_axis_pos), + }; + + if (result.width < 0) { + result.x += result.width; + result.width *= -1; + } + + return result; + } else { + const height_range = RangeF64.init(0, rect.height); + var result = rl.Rectangle{ + .width = @floatCast(rect_width * cross_axis_size), + .height = @floatCast(along_axis_size / view_range.size() * rect_height), + .x = @floatCast(rect_width * (1 - cross_axis_pos - cross_axis_size)), + .y = @floatCast(view_range.remapTo(height_range, along_axis_pos + along_axis_size)), + }; + + if (result.height < 0) { + result.y += result.height; + result.height *= -1; + } + + return result; + } +} + +fn showRulerTicksRange( + self: *MainScreen, + channel_view: *ChannelView, + ruler: *UI.Box, + axis: UI.Axis, + + from: f64, + to: f64, + step: f64, + + marker_size: f64 +) void { + var marker = from; + while (marker < to) : (marker += step) { + _ = self.app.ui.createBox(.{ + .background = srcery.yellow, + .float_rect = getLineOnRuler(channel_view, ruler, axis, marker, 0, marker_size), + .float_relative_to = ruler + }); + } +} + +fn showRulerTicks(self: *MainScreen, channel_view: *ChannelView, axis: UI.Axis) void { + const view_range = channel_view.getViewRange(axis); + const full_range = channel_view.getSampleRange(axis); + + var ui = &self.app.ui; + const ruler = ui.parentBox().?; + + const ruler_rect = ruler.rect(); + const ruler_rect_size_along_axis = switch (axis) { + .X => ruler_rect.width, + .Y => ruler_rect.height + }; + + if (ruler_rect_size_along_axis == 0) { + return; + } + + const ideal_pixels_per_division = 150; + var subdivisions: f32 = 20; + subdivisions = 20; + while (true) { + assert(subdivisions > 0); + const step = full_range.size() / subdivisions; + const pixels_per_division = step / view_range.size() * ruler_rect_size_along_axis; + assert(pixels_per_division > 0); + + + + if (pixels_per_division > ideal_pixels_per_division*2) { + subdivisions *= 2; + } else if (pixels_per_division < ideal_pixels_per_division/2) { + subdivisions /= 2; + } else { + break; + } + } + + const step = full_range.size() / subdivisions; + + { + _ = self.app.ui.createBox(.{ + .background = srcery.yellow, + .float_rect = getLineOnRuler(channel_view, ruler, axis, full_range.lower, 0, 0.75), + .float_relative_to = ruler + }); + } + + { + _ = self.app.ui.createBox(.{ + .background = srcery.yellow, + .float_rect = getLineOnRuler(channel_view, ruler, axis, full_range.upper, 0, 0.75), + .float_relative_to = ruler + }); + } + + if (full_range.hasExclusive(0)) { + _ = ui.createBox(.{ + .background = srcery.yellow, + .float_rect = getLineOnRuler(channel_view, ruler, axis, 0, 0, 0.75), + .float_relative_to = ruler + }); + } + + const ticks_range = view_range.grow(step).intersectPositive(full_range.*); + + self.showRulerTicksRange( + channel_view, + ruler, + axis, + utils.roundNearestTowardZero(f64, ticks_range.lower, step) + step/2, + ticks_range.upper, + step, + 0.5 + ); + + self.showRulerTicksRange( + channel_view, + ruler, + axis, + utils.roundNearestTowardZero(f64, ticks_range.lower, step), + ticks_range.upper, + step, + 0.25 + ); +} + +fn showChannelView(self: *MainScreen, channel_view: *ChannelView, height: UI.Sizing) !void { + var ui = &self.app.ui; + + const channel_view_box = ui.createBox(.{ + .key = UI.Key.initPtr(channel_view), + .layout_direction = .top_to_bottom, + .size_x = UI.Sizing.initGrowFull(), + .size_y = height + }); + channel_view_box.beginChildren(); + defer channel_view_box.endChildren(); + + const show_ruler = true; + + if (!show_ruler) { + try self.showChannelViewGraph(channel_view); + + } else { + const x_ruler_size = UI.Sizing.initFixed(.{ .pixels = 32 }); + const y_ruler_size = UI.Sizing.initFixed(.{ .pixels = 32 }); + + var y_ruler: *UI.Box = undefined; + var x_ruler: *UI.Box = undefined; + + { + const container = ui.createBox(.{ + .layout_direction = .left_to_right, + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initGrowFull(), + }); + container.beginChildren(); + defer container.endChildren(); + + y_ruler = ui.createBox(.{ + .key = ui.keyFromString("Y ruler"), + .size_x = y_ruler_size, + .size_y = UI.Sizing.initGrowFull(), + .background = srcery.hard_black, + .flags = &.{ .clickable, .clip_view, .scrollable }, + .hot_cursor = .mouse_cursor_pointing_hand + }); + + try self.showChannelViewGraph(channel_view); + } + + { + const container = ui.createBox(.{ + .layout_direction = .left_to_right, + .size_x = UI.Sizing.initGrowFull(), + .size_y = x_ruler_size, + }); + container.beginChildren(); + defer container.endChildren(); + + const fullscreen = ui.createBox(.{ + .key = ui.keyFromString("Fullscreen toggle"), + .size_y = x_ruler_size, + .size_x = y_ruler_size, + .background = srcery.hard_black, + .hot_cursor = .mouse_cursor_pointing_hand, + .flags = &.{ .draw_hot, .draw_active, .clickable }, + .texture = Assets.fullscreen, + .texture_size = .{ .x = 28, .y = 28 } + }); + if (ui.signal(fullscreen).clicked()) { + if (self.fullscreen_channel != null and self.fullscreen_channel.? == channel_view) { + self.fullscreen_channel = null; + } else { + self.fullscreen_channel = channel_view; + } + } + + x_ruler = ui.createBox(.{ + .key = ui.keyFromString("X ruler"), + .size_y = x_ruler_size, + .size_x = UI.Sizing.initGrowFull(), + .background = srcery.hard_black, + .flags = &.{ .clip_view, .clickable, .scrollable }, + .hot_cursor = .mouse_cursor_pointing_hand + }); + } + + const ruler_desciptions = .{ + .{ x_ruler, .X }, + .{ y_ruler, .Y } + }; + + inline for (ruler_desciptions) |ruler_desc| { + const ruler = ruler_desc[0]; + const axis: UI.Axis = ruler_desc[1]; + + ruler.beginChildren(); + defer ruler.endChildren(); + + self.showRulerTicks(channel_view, axis); + + const signal = ui.signal(ruler); + const mouse_position = switch (axis) { + .X => signal.relative_mouse.x, + .Y => signal.relative_mouse.y + }; + const mouse_range = switch (axis) { + .X => RangeF64.init(0, ruler.persistent.size.x), + .Y => RangeF64.init(0, ruler.persistent.size.y) + }; + const view_range = channel_view.getViewRange(axis); + const mouse_position_on_graph = mouse_range.remapTo(view_range.*, mouse_position); + + var zoom_start: ?f64 = null; + var zoom_end: ?f64 = null; + + var is_zooming: bool = false; + if (self.axis_zoom) |axis_zoom| { + is_zooming = axis_zoom.channel == channel_view and axis_zoom.axis == axis; + } + + if (signal.hot) { + const mouse_tooltip = ui.mouseTooltip(); + mouse_tooltip.beginChildren(); + defer mouse_tooltip.endChildren(); + + if (channel_view.getSampleRange(axis).hasInclusive(mouse_position_on_graph)) { + _ = ui.label("{d:.3}", .{mouse_position_on_graph}); + } + + zoom_start = mouse_position_on_graph; + } + + if (signal.flags.contains(.left_pressed)) { + self.axis_zoom = .{ + .axis = axis, + .start = mouse_position_on_graph, + .channel = channel_view + }; + } + + if (is_zooming) { + zoom_start = self.axis_zoom.?.start; + zoom_end = mouse_position_on_graph; + } + + if (zoom_start != null) { + _ = ui.createBox(.{ + .background = srcery.green, + .float_rect = getLineOnRuler(channel_view, ruler, axis, zoom_start.?, 0, 1), + .float_relative_to = ruler, + }); + } + + if (zoom_end != null) { + _ = ui.createBox(.{ + .background = srcery.green, + .float_rect = getLineOnRuler(channel_view, ruler, axis, zoom_end.?, 0, 1), + .float_relative_to = ruler, + }); + } + + if (zoom_start != null and zoom_end != null) { + _ = ui.createBox(.{ + .background = srcery.green.alpha(0.5), + .float_relative_to = ruler, + .float_rect = getRectOnRuler( + channel_view, + ruler, + axis, + zoom_start.?, + zoom_end.? - zoom_start.?, + 0, + 1 + ) + }); + } + + if (signal.scrolled()) { + var scale_factor: f64 = 1; + if (signal.scroll.y > 0) { + scale_factor -= zoom_speed; + } else { + scale_factor += zoom_speed; + } + view_range.* = view_range.zoom(mouse_position_on_graph, scale_factor); + } + + if (is_zooming and signal.flags.contains(.left_released)) { + if (zoom_start != null and zoom_end != null) { + const zoom_start_mouse = view_range.remapTo(mouse_range, zoom_start.?); + const zoom_end_mouse = view_range.remapTo(mouse_range, zoom_end.?); + const mouse_move_distance = @abs(zoom_end_mouse - zoom_start_mouse); + if (mouse_move_distance > 5) { + view_range.lower = @min(zoom_start.?, zoom_end.?); + view_range.upper = @max(zoom_start.?, zoom_end.?); + + if (axis == .Y) { + view_range.* = view_range.flip(); + } + } + } + self.axis_zoom = null; + } + } + } +} + +pub fn tick(self: *MainScreen) !void { + var ui = &self.app.ui; + + if (rl.isKeyPressed(.key_escape)) { + if (self.fullscreen_channel != null) { + self.fullscreen_channel = null; + } else { + self.app.should_close = true; + } + } + + const root = ui.parentBox().?; + root.layout_direction = .top_to_bottom; + + { + const toolbar = ui.createBox(.{ + .background = srcery.black, + .layout_direction = .left_to_right, + .size_x = .{ .fixed = .{ .parent_percent = 1 } }, + .size_y = .{ .fixed = .{ .font_size = 2 } } + }); + toolbar.beginChildren(); + defer toolbar.endChildren(); + + var start_all = ui.button("Start/Stop button"); + start_all.borders = UI.Borders.all(.{ .size = 4, .color = srcery.red }); + start_all.background = srcery.black; + start_all.size.y = UI.Sizing.initFixed(.{ .parent_percent = 1 }); + 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); + } + } + } + if (self.app.started_collecting) { + 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; + + const sample_rate = device_channel.active_task.?.sampling.continous.sample_rate; + const samples = device_channel.samples.items; + const sample_count: f32 = @floatFromInt(samples.len); + + 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()); + + } else { + const scroll_area = ui.beginScrollbar(ui.keyFromString("Channels")); + defer ui.endScrollbar(); + scroll_area.layout_direction = .top_to_bottom; + + for (self.app.listChannelViews()) |*channel_view| { + try self.showChannelView(channel_view, UI.Sizing.initFixed(.{ .pixels = channel_view.height })); + } + + { + const add_channel_view = ui.createBox(.{ + .size_x = UI.Sizing.initGrowFull(), + .size_y = UI.Sizing.initFixed(.{ .pixels = 200 }), + .align_x = .center, + .align_y = .center, + .layout_gap = 32 + }); + add_channel_view.beginChildren(); + defer add_channel_view.endChildren(); + + const add_from_file = ui.button("Add from file"); + add_from_file.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); + if (ui.signal(add_from_file).clicked()) { + if (Platform.openFilePicker(self.app.allocator)) |filename| { + defer self.app.allocator.free(filename); + + // TODO: Handle error + self.app.appendChannelFromFile(filename) catch @panic("Failed to append channel from file"); + } else |err| { + // TODO: Show error message to user; + log.err("Failed to pick file: {}", .{ err }); + } + } + + const add_from_device = ui.button("Add from device"); + add_from_device.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); + if (ui.signal(add_from_device).clicked()) { + + } + } + } +} \ No newline at end of file diff --git a/src/ui.zig b/src/ui.zig index 602143e..0ba4b12 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -106,6 +106,7 @@ pub const Signal = struct { drag: Vec2 = Vec2Zero, scroll: Vec2 = Vec2Zero, relative_mouse: Vec2 = Vec2Zero, + mouse: Vec2 = Vec2Zero, hot: bool = false, active: bool = false, shift_modifier: bool = false, @@ -470,6 +471,7 @@ pub const Box = struct { texture_size: ?Vec2 = null, scientific_number: ?f64 = null, scientific_precision: u32 = 1, + float_relative_to: ?*Box = null, // Variables that you probably shouldn't be touching last_used_frame: u64 = 0, @@ -633,10 +635,12 @@ pub const BoxOptions = struct { texture_size: ?Vec2 = null, float_rect: ?Rect = null, scientific_number: ?f64 = null, - scientific_precision: ?u32 = null + scientific_precision: ?u32 = null, + float_relative_to: ?*Box = null }; pub const root_box_key = Key.initString(0, "$root$"); +pub const mouse_tooltip_box_key = Key.initString(0, "$mouse_tooltip$"); const BoxChildIterator = struct { current_child: ?BoxIndex, @@ -669,22 +673,25 @@ const BoxParentIterator = struct { }; arenas: [2]std.heap.ArenaAllocator, -frame_index: u64 = 0, // Retained structures. Used for tracking changes between frames hot_box_key: ?Key = null, active_box_keys: std.EnumMap(rl.MouseButton, Key) = .{}, +frame_index: u64 = 0, -// Per frames structures -font_stack: std.BoundedArray(Assets.FontId, 16) = .{}, -boxes: std.BoundedArray(Box, max_boxes) = .{}, -parent_stack: std.BoundedArray(BoxIndex, max_boxes) = .{}, -events: std.BoundedArray(Event, max_events) = .{}, +// Per frame fields +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) = .{}, dt: f32 = 0, +// Per layout pass fields +font_stack: std.BoundedArray(Assets.FontId, 16) = .{}, +boxes: std.BoundedArray(Box, max_boxes) = .{}, +parent_stack: std.BoundedArray(BoxIndex, max_boxes) = .{}, + pub fn init(allocator: std.mem.Allocator) UI { return UI{ .arenas = .{ @@ -781,18 +788,28 @@ pub fn begin(self: *UI) void { self.pushFont(default_font); + _ = self.createBox(.{ + .key = mouse_tooltip_box_key, + .size_x = Sizing.initFitChildren(), + .size_y = Sizing.initFitChildren(), + .padding = Padding.all(8), + .background = srcery.black, + .borders = Borders.all(.{ + .color = srcery.hard_black, + .size = 4 + }) + }); + const root_box = self.createBox(.{ .key = root_box_key, .size_x = .{ .fixed = .{ .pixels = @floatFromInt(rl.getScreenWidth()) } }, - .size_y = .{ .fixed = .{ .pixels = @floatFromInt(rl.getScreenHeight()) } } + .size_y = .{ .fixed = .{ .pixels = @floatFromInt(rl.getScreenHeight()) } }, }); root_box.beginChildren(); } pub fn end(self: *UI) void { - const zone2 = P.begin(@src(), "UI end()"); - defer zone2.end(); - + const mouse_tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; const root_box = self.parentBox().?; root_box.endChildren(); @@ -853,25 +870,64 @@ pub fn end(self: *UI) void { box.persistent.position = position; } + self.layoutPass(root_box); + + self.layoutSizesPass(mouse_tooltip); + // Position mouse tooltip so it does not go off screen { - const zone = P.begin(@src(), "UI layout"); - defer zone.end(); + const window_rect = rect_utils.shrink(Rect{ + .x = 0, + .y = 0, + .width = @floatFromInt(rl.getScreenWidth()), + .height = @floatFromInt(rl.getScreenHeight()) + }, 16); - self.layoutSizesInitial(root_box, .X); - self.layoutSizesShrink(root_box, .X); - self.layoutSizesGrow(root_box, .X); - self.layoutSizesFitChildren(root_box, .X); + const cursor_width = 12; + var tooltip_rect = Rect{ + .x = self.mouse.x + cursor_width, + .y = self.mouse.y, + .width = mouse_tooltip.persistent.size.x, + .height = mouse_tooltip.persistent.size.y + }; - self.layoutWrapText(root_box); + tooltip_rect.x += @max(0, rect_utils.left(window_rect) - rect_utils.left(tooltip_rect)); + tooltip_rect.x -= @max(0, rect_utils.right(tooltip_rect) - rect_utils.right(window_rect)); - self.layoutSizesInitial(root_box, .Y); - self.layoutSizesShrink(root_box, .Y); - self.layoutSizesGrow(root_box, .Y); - self.layoutSizesFitChildren(root_box, .Y); + tooltip_rect.y += @max(0, rect_utils.top(window_rect) - rect_utils.top(tooltip_rect)); + tooltip_rect.y -= @max(0, rect_utils.bottom(tooltip_rect) - rect_utils.bottom(window_rect)); - self.layoutPositions(root_box, .X); - self.layoutPositions(root_box, .Y); + mouse_tooltip.persistent.position = .{ + .x = tooltip_rect.x, + .y = tooltip_rect.y + }; } + self.layoutPositionsPass(mouse_tooltip); +} + +fn layoutSizesPass(self: *UI, root_box: *Box) void { + self.layoutSizesInitial(root_box, .X); + self.layoutSizesShrink(root_box, .X); + self.layoutSizesGrow(root_box, .X); + self.layoutSizesFitChildren(root_box, .X); + + self.layoutWrapText(root_box); + + self.layoutSizesInitial(root_box, .Y); + self.layoutSizesShrink(root_box, .Y); + self.layoutSizesGrow(root_box, .Y); + self.layoutSizesFitChildren(root_box, .Y); +} + +fn layoutPositionsPass(self: *UI, root_box: *Box) void { + self.layoutPositions(root_box, .X); + self.layoutPositions(root_box, .Y); + self.layoutFloatingPositions(root_box, .X); + self.layoutFloatingPositions(root_box, .Y); +} + +fn layoutPass(self: *UI, root_box: *Box) void { + self.layoutSizesPass(root_box); + self.layoutPositionsPass(root_box); } pub fn pushFont(self: *UI, font_id: Assets.FontId) void { @@ -1249,6 +1305,22 @@ fn layoutPositions(self: *UI, box: *Box, axis: Axis) void { } } +fn layoutFloatingPositions(self: *UI, box: *Box, axis: Axis) void { + if (box.float_relative_to != null and box.isFloating(axis)) { + const target = box.float_relative_to.?; + + const axis_position = vec2ByAxis(&box.persistent.position, axis); + const axis_position_target = vec2ByAxis(&target.persistent.position, axis); + + axis_position.* += axis_position_target.*; + } + + var child_iter = box.iterChildren(); + while (child_iter.next()) |child| { + self.layoutFloatingPositions(child, axis); + } +} + pub fn createBox(self: *UI, opts: BoxOptions) *Box { var box: *Box = undefined; var box_index: ?BoxIndex = null; @@ -1325,6 +1397,7 @@ pub fn createBox(self: *UI, opts: BoxOptions) *Box { .texture_size = opts.texture_size, .scientific_number = opts.scientific_number, .scientific_precision = opts.scientific_precision orelse 1, + .float_relative_to = opts.float_relative_to, .last_used_frame = self.frame_index, .key = key, @@ -1412,28 +1485,24 @@ pub fn getBoxByKey(self: *UI, key: Key) ?*Box { } pub fn draw(self: *UI) void { - const root_box_index = self.getBoxIndexByKey(root_box_key).?; - const root_box = self.getBoxByIndex(root_box_index); + defer assert(self.scissor_stack.len == 0); - const zone = P.begin(@src(), "UI Draw"); - defer zone.end(); + const root_box = self.getBoxByKey(root_box_key).?; + const mouse_tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; self.drawBox(root_box); + + if (mouse_tooltip.tree.first_child_index != null) { + self.drawBox(mouse_tooltip); + } } fn drawBox(self: *UI, box: *Box) void { const box_rect = box.rect(); const do_scissor = box.flags.contains(.clip_view); - if (do_scissor) { - rl.beginScissorMode( - @intFromFloat(box_rect.x), - @intFromFloat(box_rect.y), - @intFromFloat(box_rect.width), - @intFromFloat(box_rect.height) - ); - } - defer if (do_scissor) rl.endScissorMode(); + if (do_scissor) self.beginScissor(box_rect); + defer if (do_scissor) self.endScissor(); var value_shift: f32 = 0; if (box.flags.contains(.draw_active) and self.isKeyActive(box.key)) { @@ -1600,6 +1669,40 @@ fn drawBox(self: *UI, box: *Box) void { } } +fn beginScissor(self: *UI, rect: Rect) void { + var intersected_rect = rect; + if (self.scissor_stack.len > 0) { + const top_scissor_rect = self.scissor_stack.buffer[self.scissor_stack.len - 1]; + intersected_rect = rect_utils.intersect(top_scissor_rect, rect); + } + + self.scissor_stack.appendAssumeCapacity(intersected_rect); + + rl.beginScissorMode( + @intFromFloat(intersected_rect.x), + @intFromFloat(intersected_rect.y), + @intFromFloat(intersected_rect.width), + @intFromFloat(intersected_rect.height) + ); +} + +fn endScissor(self: *UI) void { + rl.endScissorMode(); + _ = self.scissor_stack.pop(); + + if (self.scissor_stack.len > 0) { + const top_scissor_rect = self.scissor_stack.buffer[self.scissor_stack.len - 1]; + + rl.endScissorMode(); + rl.beginScissorMode( + @intFromFloat(top_scissor_rect.x), + @intFromFloat(top_scissor_rect.y), + @intFromFloat(top_scissor_rect.width), + @intFromFloat(top_scissor_rect.height) + ); + } +} + fn getKeySeed(self: *UI) u64 { var maybe_current = self.parentBox(); while (maybe_current) |current| { @@ -1627,12 +1730,13 @@ pub fn signal(self: *UI, box: *Box) Signal { return result; } - var rect = box.rect(); + const rect = box.rect(); + var clipped_rect = rect; { var parent_iter = box.iterParents(); while (parent_iter.next()) |parent| { if (parent.flags.contains(.clip_view)) { - rect = rect_utils.intersect(rect, parent.rect()); + clipped_rect = rect_utils.intersect(clipped_rect, parent.rect()); } } } @@ -1641,7 +1745,7 @@ pub fn signal(self: *UI, box: *Box) Signal { const clickable = box.flags.contains(.clickable); const draggable = box.flags.contains(.draggable); const scrollable = box.flags.contains(.scrollable); - const is_mouse_inside = rect_utils.isInsideVec2(rect, self.mouse); + const is_mouse_inside = rect_utils.isInsideVec2(clipped_rect, self.mouse); var event_index: usize = 0; while (event_index < self.events.len) { @@ -1710,6 +1814,7 @@ pub fn signal(self: *UI, box: *Box) Signal { result.hot = self.isKeyHot(box.key); result.active = self.isKeyActive(box.key); result.relative_mouse = self.mouse.subtract(rect_utils.position(rect)); + result.mouse = self.mouse; result.shift_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); return result; @@ -1735,4 +1840,122 @@ pub fn isKeyActive(self: *UI, key: Key) bool { } return false; +} + +// --------------------------------- Widgets ----------------------------------------- // + +pub fn mouseTooltip(self: *UI) *Box { + const tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; + + return tooltip; +} + +pub fn button(self: *UI, text: []const u8) *Box { + return self.createBox(.{ + .key = self.keyFromString(text), + .size_x = Sizing.initFixed(.text), + .size_y = Sizing.initFixed(.text), + .flags = &.{ .draw_hot, .draw_active, .clickable }, + .padding = Padding{ + .bottom = self.rem(0.5), + .top = self.rem(0.5), + .left = self.rem(1), + .right = self.rem(1) + }, + .hot_cursor = .mouse_cursor_pointing_hand, + .text = text + }); +} + +pub fn label(self: *UI, comptime fmt: []const u8, args: anytype) *Box { + const box = self.createBox(.{ + .size_x = Sizing.initFixed(.text), + .size_y = Sizing.initFixed(.text) + }); + + box.setFmtText(fmt, args); + + return box; +} + +pub fn beginScrollbar(self: *UI, key: Key) *Box { + const wrapper = self.createBox(.{ + .key = key, + .layout_direction = .left_to_right, + .flags = &.{ .clip_view }, + .size_x = Sizing.initGrowFull(), + .size_y = Sizing.initGrowFull() + }); + wrapper.beginChildren(); + + const content_area = self.createBox(.{ + .key = self.keyFromString("Scrollable content area"), + .flags = &.{ .scrollable, .clip_view }, + .size_x = Sizing.initGrowFull(), + .size_y = Sizing.initFitChildren(), + }); + content_area.beginChildren(); + + const content_size = content_area.persistent.size.y; + const visible_percent = clamp(wrapper.persistent.size.y / content_size, 0, 1); + const sroll_offset = content_area.persistent.sroll_offset; + content_area.view_offset.y = sroll_offset * (1 - visible_percent) * content_size; + + return content_area; +} + +pub fn endScrollbar(self: *UI) void { + const content_area = self.parentBox().?; + content_area.endChildren(); + + const wrapper = self.parentBox().?; + + const visible_percent = clamp(wrapper.persistent.size.y / content_area.persistent.size.y, 0, 1); + + { + const scrollbar_area = self.createBox(.{ + .key = self.keyFromString("Scrollbar area"), + .background = srcery.hard_black, + .flags = &.{ .scrollable }, + .size_x = .{ .fixed = .{ .pixels = 24 } }, + .size_y = Sizing.initGrowFull() + }); + scrollbar_area.beginChildren(); + defer scrollbar_area.endChildren(); + + const draggable = self.createBox(.{ + .key = self.keyFromString("Scrollbar button"), + .background = srcery.black, + .flags = &.{ .draw_hot, .draw_active, .clickable, .draggable }, + .borders = Borders.all(.{ .size = 4, .color = srcery.xgray3 }), + .size_x = Sizing.initFixed(.{ .parent_percent = 1 }), + .size_y = Sizing.initFixed(.{ .parent_percent = visible_percent }), + .hot_cursor = .mouse_cursor_pointing_hand + }); + + const sroll_offset = &content_area.persistent.sroll_offset; + const scrollbar_height = scrollbar_area.persistent.size.y; + const max_offset = scrollbar_height * (1 - visible_percent); + draggable.setFloatY(content_area.persistent.position.y + sroll_offset.* * max_offset); + + const draggable_signal = self.signal(draggable); + if (draggable_signal.dragged()) { + sroll_offset.* += draggable_signal.drag.y / max_offset; + } + + const scroll_speed = 16; + const scrollbar_signal = self.signal(scrollbar_area); + if (scrollbar_signal.scrolled()) { + sroll_offset.* -= scrollbar_signal.scroll.y / max_offset * scroll_speed; + } + + const content_area_signal = self.signal(content_area); + if (content_area_signal.scrolled()) { + sroll_offset.* -= content_area_signal.scroll.y / max_offset * scroll_speed; + } + + sroll_offset.* = std.math.clamp(sroll_offset.*, 0, 1); + } + + wrapper.endChildren(); } \ No newline at end of file diff --git a/src/utils.zig b/src/utils.zig index a950951..7afaa48 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -60,4 +60,8 @@ pub fn roundNearestUp(comptime T: type, value: T, multiple: T) T { pub fn roundNearestDown(comptime T: type, value: T, multiple: T) T { return @floor(value / multiple) * multiple; +} + +pub fn roundNearestTowardZero(comptime T: type, value: T, multiple: T) T { + return @trunc(value / multiple) * multiple; } \ No newline at end of file