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; const ruler_size = UI.Sizing.initFixed(.{ .pixels = 32 }); const ChannelCommand = struct { channel: *ChannelView, updated_at_ns: i128, action: union(enum) { move_and_zoom: struct { before_x: RangeF64, before_y: RangeF64, } } }; app: *App, fullscreen_channel: ?*ChannelView = null, axis_zoom: ?struct { channel: *ChannelView, axis: UI.Axis, start: f64, } = null, // TODO: Redo channel_undo_stack: std.BoundedArray(ChannelCommand, 100) = .{}, 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; var push_new_command = true; if (undo_stack.len > 0) { const top_command = &undo_stack.buffer[undo_stack.len - 1]; if (now_ns - top_command.updated_at_ns < std.time.ns_per_ms * 250) { top_command.updated_at_ns = now_ns; push_new_command = false; } } var view_rect = &channel_view.view_rect; if (push_new_command) { if (undo_stack.unusedCapacitySlice().len == 0) { _ = undo_stack.orderedRemove(0); } undo_stack.appendAssumeCapacity(ChannelCommand{ .channel = channel_view, .updated_at_ns = now_ns, .action = .{ .move_and_zoom = .{ .before_x = view_rect.x_range, .before_y = view_rect.y_range, } } }); } view_rect.x_range = x_range; view_rect.y_range = y_range; channel_view.follow = false; } fn pushChannelMoveCommandAxis(self: *MainScreen, channel_view: *ChannelView, axis: UI.Axis, view_range: RangeF64) void { if (axis == .X) { const view_rect = &channel_view.view_rect; self.pushChannelMoveCommand(channel_view, view_range, view_rect.y_range); } else { const view_rect = &channel_view.view_rect; self.pushChannelMoveCommand(channel_view, view_rect.x_range, view_range); } } fn showChannelViewGraph(self: *MainScreen, channel_view: *ChannelView) *UI.Box { 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 }, .align_x = .center, .align_y = .center, }); 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); self.pushChannelMoveCommand( channel_view, view_rect.x_range.sub(x_offset), 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; } self.pushChannelMoveCommand( channel_view, view_rect.x_range.zoom(sample_index_under_mouse.?, scale_factor), view_rect.y_range.zoom(sample_value_under_mouse.?, scale_factor) ); } if (signal.flags.contains(.middle_clicked)) { self.pushChannelMoveCommand(channel_view, channel_view.x_range, channel_view.y_range); } 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; } if (view_rect.x_range.size() == 0 or view_rect.y_range.size() == 0) { graph_box.setText(""); graph_box.text_color = srcery.hard_black; graph_box.font = .{ .variant = .bold_italic, .size = ui.rem(3) }; } return graph_box; } 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; } if (view_range.size() == 0) { return; } if (full_range.size() == 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 addRulerPlaceholder(self: *MainScreen, key: UI.Key, axis: UI.Axis) *UI.Box { var ui = &self.app.ui; var ruler = ui.createBox(.{ .key = key, .background = srcery.hard_black, .flags = &.{ .clip_view, .clickable, .scrollable }, .hot_cursor = .mouse_cursor_pointing_hand }); if (axis == .X) { ruler.size.x = UI.Sizing.initGrowFull(); ruler.size.y = ruler_size; } else { ruler.size.x = ruler_size; ruler.size.y = UI.Sizing.initGrowFull(); } return ruler; } fn showRuler(self: *MainScreen, ruler: *UI.Box, graph_box: *UI.Box, channel_view: *ChannelView, axis: UI.Axis) void { var ui = &self.app.ui; 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 and view_range.size() > 0) { const mouse_tooltip = ui.mouseTooltip(); mouse_tooltip.beginChildren(); defer mouse_tooltip.endChildren(); if (channel_view.getSampleRange(axis).hasInclusive(mouse_position_on_graph)) { if (axis == .Y and channel_view.unit != null) { const unit_name = channel_view.unit.?.name() orelse "Unknown"; _ = ui.label("{s}: {d:.3}", .{unit_name, mouse_position_on_graph}); } else if (axis == .X and channel_view.sample_rate != null) { const sample_rate = channel_view.sample_rate.?; _ = ui.label("{d:.3}s", .{mouse_position_on_graph / sample_rate}); } else { _ = 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, }); _ = ui.createBox(.{ .background = srcery.green, .float_rect = getLineOnRuler(channel_view, graph_box, axis, zoom_start.?, 0, 1), .float_relative_to = graph_box, .parent = graph_box }); } if (zoom_end != null) { _ = ui.createBox(.{ .background = srcery.green, .float_rect = getLineOnRuler(channel_view, ruler, axis, zoom_end.?, 0, 1), .float_relative_to = ruler, }); _ = ui.createBox(.{ .background = srcery.green, .float_rect = getLineOnRuler(channel_view, graph_box, axis, zoom_end.?, 0, 1), .float_relative_to = graph_box, .parent = graph_box }); } 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; } const new_view_range = view_range.zoom(mouse_position_on_graph, scale_factor); self.pushChannelMoveCommandAxis(channel_view, axis, new_view_range); } 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) { var new_view_range = RangeF64.init( @min(zoom_start.?, zoom_end.?), @max(zoom_start.?, zoom_end.?) ); if (axis == .Y) { new_view_range = new_view_range.flip(); } self.pushChannelMoveCommandAxis(channel_view, axis, new_view_range); } } self.axis_zoom = null; } } fn showChannelView(self: *MainScreen, channel_view: *ChannelView, height: UI.Sizing) !void { var ui = &self.app.ui; const show_ruler = true; 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 toolbar = ui.createBox(.{ .layout_direction = .left_to_right, .layout_gap = 16, .background = srcery.hard_black, .size_x = UI.Sizing.initGrowFull(), .size_y = UI.Sizing.initFixed(.{ .pixels = ui.rem(2) }) }); { toolbar.beginChildren(); defer toolbar.endChildren(); if (self.app.getChannelSourceDevice(channel_view)) |device_channel| { _ = device_channel; 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 (!show_ruler) { _ = self.showChannelViewGraph(channel_view); } else { var graph_box: *UI.Box = undefined; var x_ruler: *UI.Box = undefined; var y_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 = self.addRulerPlaceholder(ui.keyFromString("Y ruler"), .Y); graph_box = 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_x = ruler_size, .size_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 = self.addRulerPlaceholder(ui.keyFromString("X ruler"), .X); } self.showRuler(x_ruler, graph_box, channel_view, .X); self.showRuler(y_ruler, graph_box, channel_view, .Y); } } pub fn tick(self: *MainScreen) !void { var ui = &self.app.ui; if (ui.isKeyboardPressed(.key_escape)) { if (self.fullscreen_channel != null) { self.fullscreen_channel = null; } else { self.app.should_close = true; } } if (ui.isCtrlDown() and ui.isKeyboardPressed(.key_z)) { if (self.channel_undo_stack.popOrNull()) |command| { switch (command.action) { .move_and_zoom => |args| { const view_rect = &command.channel.view_rect; view_rect.x_range = args.before_x; view_rect.y_range = args.before_y; } } } } 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.textButton("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; 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()); } else { const scroll_area = ui.beginScrollbar(ui.keyFromString("Channels")); defer ui.endScrollbar(); scroll_area.layout_direction = .top_to_bottom; scroll_area.layout_gap = 4; 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.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(); 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.textButton("Add from device"); add_from_device.borders = UI.Borders.all(.{ .size = 2, .color = srcery.green }); if (ui.signal(add_from_device).clicked()) { self.app.current_screen = .channel_from_device; } } } }