From 09fccb7069f7197d79a7e6aa864ccbc30ed5b7bb Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 2 Mar 2025 16:02:04 +0200 Subject: [PATCH] add zooming and panning using mouse in channel view --- src/app.zig | 285 +++++++++++++++++++++++++++++++++++++++------ src/graph.zig | 125 +++++++++----------- src/grayscale.fs | 19 +++ src/main.zig | 8 +- src/platform.zig | 10 +- src/rect-utils.zig | 7 ++ src/ui.zig | 210 ++++++++++++++++++++++++++------- src/utils.zig | 24 ++++ 8 files changed, 529 insertions(+), 159 deletions(-) create mode 100644 src/grayscale.fs diff --git a/src/app.zig b/src/app.zig index 23ac0a1..0baf257 100644 --- a/src/app.zig +++ b/src/app.zig @@ -7,9 +7,10 @@ const Assets = @import("./assets.zig"); const Graph = @import("./graph.zig"); const NIDaq = @import("ni-daq/root.zig"); const rect_utils = @import("./rect-utils.zig"); -const remap = @import("./utils.zig").remap; const TaskPool = @import("ni-daq/task-pool.zig"); +const remap = @import("./utils.zig").remap; +const lerpColor = @import("./utils.zig").lerpColor; const log = std.log.scoped(.app); const assert = std.debug.assert; const clamp = std.math.clamp; @@ -102,7 +103,7 @@ task_pool: TaskPool, shown_window: enum { channels, add_from_device -} = .add_from_device, +} = .channels, shown_modal: ?union(enum) { no_library_error, @@ -118,6 +119,13 @@ last_hot_channel: ?[:0]const u8 = null, show_device_filter_dropdown: bool = false, show_channel_type_filter_dropdown: bool = false, +should_close: bool = false, + +graph_start_sample: ?struct { + value: f64, + axis: UI.Axis + } = null, + pub fn init(self: *App, allocator: std.mem.Allocator) !void { self.* = App{ .allocator = allocator, @@ -401,14 +409,15 @@ fn showChannelViewSlider(self: *App, view_rect: *Graph.ViewOptions, sample_count const middle_box = self.ui.clickableBox("Middle knob"); { - middle_box.flags.insert(.draggable_x); + middle_box.flags.insert(.draggable); middle_box.background = rl.Color.black.alpha(0.5); middle_box.size.y = UI.Size.pixels(32, 1); } const left_knob_box = self.ui.clickableBox("Left knob"); { - left_knob_box.flags.insert(.draggable_x); + left_knob_box.active_cursor = .mouse_cursor_resize_ew; + left_knob_box.flags.insert(.draggable); left_knob_box.background = rl.Color.black.alpha(0.5); left_knob_box.size.x = UI.Size.pixels(8, 1); left_knob_box.size.y = UI.Size.pixels(32, 1); @@ -416,7 +425,8 @@ fn showChannelViewSlider(self: *App, view_rect: *Graph.ViewOptions, sample_count const right_knob_box = self.ui.clickableBox("Right knob"); { - right_knob_box.flags.insert(.draggable_x); + right_knob_box.active_cursor = .mouse_cursor_resize_ew; + right_knob_box.flags.insert(.draggable); right_knob_box.background = rl.Color.black.alpha(0.5); right_knob_box.size.x = UI.Size.pixels(8, 1); right_knob_box.size.y = UI.Size.pixels(32, 1); @@ -561,12 +571,199 @@ fn showChannelView(self: *App, channel_view: *ChannelView) !void { } { + var channel_rect_opts: *Graph.ViewOptions = &channel_view.view_rect; + const graph_box = self.ui.newBoxFromString("Graph"); - graph_box.background = rl.Color.blue; + graph_box.flags.insert(.clickable); + graph_box.flags.insert(.draggable); + graph_box.background = srcery.black; graph_box.size.x = UI.Size.percent(1, 0); graph_box.size.y = UI.Size.pixels(channel_view.height, 1); + self.ui.pushParent(graph_box); + defer self.ui.popParent(); - Graph.drawCached(&channel_view.view_cache, graph_box.persistent.size, channel_view.view_rect, samples); + const graph_rect = graph_box.computedRect(); + + const signal = self.ui.signalFromBox(graph_box); + + var axis = UI.Axis.X; + var zooming: bool = false; + var start_sample: ?f64 = null; + var stop_sample: ?f64 = null; + + if (signal.hot) { + if (signal.shift_modifier) { + axis = UI.Axis.Y; + } else { + axis = UI.Axis.X; + } + + if (self.graph_start_sample) |graph_start_sample| { + axis = graph_start_sample.axis; + zooming = true; + } + + 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)) { + self.graph_start_sample = .{ + .value = mouse_sample, + .axis = axis + }; + } + + if (self.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)) { + self.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.01) { + 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 = self.ui.newBox(UI.Key.initNil()); + fill.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.setFixedRect(.{ + .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.setFixedRect(.{ + .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 = self.ui.newBox(UI.Key.initNil()); + marker.background = srcery.green; + + if (axis == .X) { + const value = samples[@intFromFloat(sample)]; + marker.setFmtText(.text, "{d:0.2} | {d:0.6}", .{sample, value}); + marker.setFixedRect(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(.text, "{d:0.2}", .{sample}); + marker.setFixedRect(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 = self.ui.newBox(UI.Key.initNil()); + marker.background = srcery.green; + + marker.setFmtText(.text, "{d:0.2}", .{sample}); + if (axis == .X) { + marker.setFixedRect(.{ + .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.setFixedRect(.{ + .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()) { + if (signal.shift_modifier) { + const drag_offset = remap( + f64, + 0, graph_rect.height, + 0, channel_rect_opts.max_value - channel_rect_opts.min_value, + signal.drag.y + ); + + channel_rect_opts.max_value += @floatCast(drag_offset); + channel_rect_opts.min_value += @floatCast(drag_offset); + } else { + const drag_offset = remap( + f64, + 0, graph_rect.width, + 0, channel_rect_opts.to - channel_rect_opts.from, + signal.drag.x + ); + + channel_rect_opts.from -= @floatCast(drag_offset); + channel_rect_opts.to -= @floatCast(drag_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; } @@ -579,6 +776,10 @@ fn showChannelView(self: *App, channel_view: *ChannelView) !void { } fn showChannelsWindow(self: *App) !void { + if (rl.isKeyPressed(rl.KeyboardKey.key_escape)) { + self.should_close = true; + } + const scroll_area = self.ui.pushScrollbar(self.ui.newKeyFromString("Channels")); defer self.ui.popScrollbar(); scroll_area.layout_axis = .Y; @@ -601,13 +802,22 @@ fn showChannelsWindow(self: *App) !void { center_box.layout_gap = 32; const from_file_button = self.ui.button(.text, "Add from file"); - from_file_button.background = srcery.green; + from_file_button.borders.all(.{ .size = 2, .color = srcery.green }); if (self.ui.signalFromBox(from_file_button).clicked()) { - log.debug("TODO: Not implemented", .{}); + std.debug.print("{}\n", .{std.fs.max_path_bytes}); + 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 from_device_button = self.ui.button(.text, "Add from device"); - from_device_button.background = srcery.green; + from_device_button.borders.all(.{ .size = 2, .color = srcery.green }); if (self.ui.signalFromBox(from_device_button).clicked()) { self.shown_window = .add_from_device; } @@ -691,16 +901,16 @@ fn showChannelInfoPanel(self: *App, hot_channel: ?[:0]const u8) !void { log.err("ni_daq.listDeviceAIMeasurementTypes(): {}", .{ e }); } - rows.appendAssumeCapacity(Row{ - .name = "Foo", - .value = "bar" - }); - self.showLabelRows(rows.constSlice()); } } fn showAddFromDeviceWindow(self: *App) !void { + if (rl.isKeyPressed(rl.KeyboardKey.key_escape)) { + self.shown_window = .channels; + return; + } + const ni_daq = &(self.ni_daq orelse return); const device_names = try ni_daq.listDeviceNames(); @@ -945,46 +1155,47 @@ fn showAddFromDeviceWindow(self: *App) !void { fn showToolbar(self: *App) void { const toolbar = self.ui.newBoxFromString("Toolbar"); - toolbar.background = rl.Color.green; + toolbar.background = srcery.black; toolbar.layout_axis = .X; toolbar.size = .{ .x = UI.Size.percent(1, 0), .y = UI.Size.pixels(32, 1), }; + toolbar.borders.bottom = .{ + .size = 4, + .color = srcery.hard_black + }; self.ui.pushParent(toolbar); defer self.ui.popParent(); + self.ui.pushStyle(); + defer self.ui.popStyle(); + + self.ui.style.borders.all(.{ + .size = 4, + .color = srcery.hard_black + }); + self.ui.style.background = srcery.xgray2; + { - const box = self.ui.button(.text, "Add from file"); - box.background = rl.Color.red; + const box = self.ui.button(.text, "Start all"); box.size.y = UI.Size.percent(1, 1); const signal = self.ui.signalFromBox(box); if (signal.clicked()) { - if (Platform.openFilePicker()) |file| { - defer file.close(); - // TODO: Handle error - // self.appendChannelFromFile(file) catch @panic("Failed to append channel from file"); - } else |err| { - // TODO: Show error message to user; - log.err("Failed to pick file: {}", .{ err }); - } } } + self.ui.spacer(.{ .x = UI.Size.percent(1, 0) }); + { - const box = self.ui.button(.text, "Add from device"); - box.background = rl.Color.lime; + const box = self.ui.button(.text, "Help"); box.size.y = UI.Size.percent(1, 1); const signal = self.ui.signalFromBox(box); if (signal.clicked()) { - if (self.shown_window == .add_from_device) { - self.shown_window = .channels; - } else { - self.shown_window = .add_from_device; - } + } } } @@ -1014,7 +1225,7 @@ fn showModalNoLibraryError(self: *App) void { const link = self.ui.newBoxFromString("Link"); link.flags.insert(.clickable); - link.flags.insert(.hover_mouse_hand); + link.hot_cursor = .mouse_cursor_pointing_hand; link.flags.insert(.text_underline); link.size.x = UI.Size.text(1, 1); link.size.y = UI.Size.text(1, 1); @@ -1249,9 +1460,9 @@ pub fn tick(self: *App) !void { } } - // On the first frame, render the UI twice. - // So that on the second pass widgets that depend on sizes from other widgets have settled - if (self.ui.frame_index == 0) { + // On the first frame or when the window resizes, render the UI twice. + // So that on the second pass widgets that depend on sizes from other widgets have "settled" + if (self.ui.frame_index == 0 or rl.isWindowResized()) { try self.updateUI(); } diff --git a/src/graph.zig b/src/graph.zig index 9e73fbc..c292a51 100644 --- a/src/graph.zig +++ b/src/graph.zig @@ -11,6 +11,7 @@ const clamp = std.math.clamp; const disable_caching = false; comptime { + // Just making sure that release build has caching enabled if (builtin.mode != .Debug) { assert(disable_caching == false); } @@ -23,7 +24,49 @@ pub const ViewOptions = struct { max_value: f64, left_aligned: bool = true, color: rl.Color = srcery.red, - dot_size: f32 = 2 + + 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 Cache = struct { @@ -61,31 +104,6 @@ pub const Cache = struct { } }; -fn mapSampleX(draw_rect: rl.Rectangle, view_rect: ViewOptions, index: f64) f64 { - return remap( - f64, - view_rect.from, view_rect.to, - draw_rect.x, draw_rect.x + draw_rect.width, - index - ); -} - -fn mapSampleY(draw_rect: rl.Rectangle, view_rect: ViewOptions, sample: f64) f64 { - return remap( - f64, - view_rect.min_value, view_rect.max_value, - draw_rect.y + draw_rect.height, draw_rect.y, - sample - ); -} - -fn mapSamplePointToGraph(draw_rect: rl.Rectangle, view_rect: ViewOptions, index: f64, sample: f64) Vec2 { - return .{ - .x = @floatCast(mapSampleX(draw_rect, view_rect, index)), - .y = @floatCast(mapSampleY(draw_rect, view_rect, sample)) - }; -} - fn clampIndex(value: f32, size: usize) f32 { const size_f32: f32 = @floatFromInt(size); return clamp(value, 0, size_f32); @@ -123,25 +141,15 @@ fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f column_max = @max(column_max, sample); } - const x = mapSampleX(draw_rect, options, @floatFromInt(from_index)); - const y_min = mapSampleY(draw_rect, options, column_min); - const y_max = mapSampleY(draw_rect, options, column_max); + 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 (column_samples.len == 1) { + if (@abs(y_max - y_min) < 1) { + const avg = (y_min + y_max) / 2; rl.drawLineV( - mapSamplePointToGraph(draw_rect, options, i, samples[from_index]), - mapSamplePointToGraph(draw_rect, options, i-1, samples[clampIndexUsize(i-1, samples.len-1)]), - options.color - ); - - rl.drawLineV( - mapSamplePointToGraph(draw_rect, options, i, samples[from_index]), - mapSamplePointToGraph(draw_rect, options, i+1, samples[clampIndexUsize(i+1, samples.len-1)]), - options.color - ); - } else if (@abs(y_max - y_min) < 1) { - rl.drawPixelV( - .{ .x = @floatCast(x), .y = @floatCast(y_min) }, + .{ .x = @floatCast(x), .y = @floatCast(avg) }, + .{ .x = @floatCast(x), .y = @floatCast(avg+1) }, options.color ); } else { @@ -161,31 +169,14 @@ fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f ); defer rl.endScissorMode(); - { - const from_index = clampIndexUsize(@floor(options.from), samples.len); - const to_index = clampIndexUsize(@ceil(options.to) + 1, samples.len); + 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 = mapSamplePointToGraph(draw_rect, options, @floatFromInt(i), samples[i]); - const to_point = mapSamplePointToGraph(draw_rect, options, @floatFromInt(i + 1), samples[i + 1]); - rl.drawLineV(from_point, to_point, options.color); - } - } - } - - { - const from_index = clampIndexUsize(@ceil(options.from), samples.len); - const to_index = clampIndexUsize(@ceil(options.to), samples.len); - - const min_circle_size = 0.5; - const max_circle_size = options.dot_size; - var circle_size = remap(f32, samples_threshold, 0.2, min_circle_size, max_circle_size, samples_per_column); - circle_size = @min(circle_size, max_circle_size); - - for (from_index..to_index) |i| { - const center = mapSamplePointToGraph(draw_rect, options, @floatFromInt(i), samples[i]); - rl.drawCircleV(center, circle_size, options.color); + 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); } } } diff --git a/src/grayscale.fs b/src/grayscale.fs new file mode 100644 index 0000000..9beb1dc --- /dev/null +++ b/src/grayscale.fs @@ -0,0 +1,19 @@ +#version 330 + +// Input vertex attributes (from vertex shader) +in vec2 fragTexCoord; +in vec4 fragColor; + +// Input uniform values +uniform sampler2D texture0; +uniform vec4 colDiffuse; + +// Output fragment color +out vec4 finalColor; + +void main() +{ + vec4 texelColor = texture(texture0, fragTexCoord)*colDiffuse*fragColor; + float luminance = dot(texelColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + gl_FragColor = vec4(luminance, luminance, luminance, texelColor.a); +} \ No newline at end of file diff --git a/src/main.zig b/src/main.zig index 6d5af4b..124ab7d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -157,7 +157,9 @@ pub fn main() !void { 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_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"); } @@ -170,7 +172,9 @@ pub fn main() !void { profiler = try Profiler.init(allocator, 10 * target_fps, @divFloor(std.time.ns_per_s, target_fps), font_face); } - while (!rl.windowShouldClose()) { + rl.setExitKey(rl.KeyboardKey.key_null); + + while (!rl.windowShouldClose() and !app.should_close) { rl.beginDrawing(); defer rl.endDrawing(); diff --git a/src/platform.zig b/src/platform.zig index 88c607a..70fa8f4 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -89,7 +89,7 @@ pub fn toggleConsoleWindow() void { // TODO: Maybe return the file path instead of an opened file handle? // So the user of this function could do something more interesting. -pub fn openFilePicker() !std.fs.File { +pub fn openFilePicker(allocator: std.mem.Allocator) ![]u8 { if (builtin.os.tag != .windows) { return error.NotSupported; } @@ -125,13 +125,7 @@ pub fn openFilePicker() !std.fs.File { const filename_len = std.mem.indexOfScalar(u16, &filename_w_buffer, 0).?; const filename_w = filename_w_buffer[0..filename_len]; - var filename_buffer: [std.fs.max_path_bytes]u8 = undefined; - // It should be safe to do "catch unreachable" here because `filename_buffer` will always be big enough. - const filename = std.fmt.bufPrint(&filename_buffer, "{s}", .{std.fs.path.fmtWtf16LeAsUtf8Lossy(filename_w)}) catch unreachable; - - // TODO: Use the `openFileAbsoluteW` function. - // Could not get it to work, because it always threw OBJECT_PATH_SYNTAX_BAD error - return try std.fs.openFileAbsolute(filename, .{ }); + return try std.fmt.allocPrint(allocator, "{s}", .{std.fs.path.fmtWtf16LeAsUtf8Lossy(filename_w)}); } pub fn init() void { diff --git a/src/rect-utils.zig b/src/rect-utils.zig index a94f5b4..6e610c2 100644 --- a/src/rect-utils.zig +++ b/src/rect-utils.zig @@ -10,6 +10,13 @@ pub fn position(rect: rl.Rectangle) rl.Vector2 { return rl.Vector2.init(rect.x, rect.y); } +pub fn positionAt(rect: rl.Rectangle, normalised_position: rl.Vector2) rl.Vector2 { + return rl.Vector2.init( + rect.x + normalised_position.x * rect.width, + rect.y + normalised_position.y * rect.height + ); +} + pub fn size(rect: rl.Rectangle) rl.Vector2 { return rl.Vector2.init(rect.width, rect.height); } diff --git a/src/ui.zig b/src/ui.zig index f824598..16af59c 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -2,13 +2,15 @@ const std = @import("std"); const rl = @import("raylib"); const Assets = @import("./assets.zig"); const rect_utils = @import("./rect-utils.zig"); +const utils = @import("./utils.zig"); const srcery = @import("./srcery.zig"); const FontFace = @import("./font-face.zig"); +const builtin = @import("builtin"); const log = std.log.scoped(.ui); const assert = std.debug.assert; -const Vec2 = rl.Vector2; -const Rect = rl.Rectangle; +pub const Vec2 = rl.Vector2; +pub const Rect = rl.Rectangle; const clamp = std.math.clamp; const UI = @This(); @@ -43,7 +45,7 @@ const RectFormatted = struct { } }; -const Axis = enum { +pub const Axis = enum { X, Y, @@ -191,7 +193,10 @@ pub const Signal = struct { flags: std.EnumSet(Flag) = .{}, drag: Vec2 = .{ .x = 0, .y = 0 }, scroll: Vec2 = .{ .x = 0, .y = 0 }, + relative_mouse: Vec2 = .{ .x = 0, .y = 0 }, hot: bool = false, + active: bool = false, + shift_modifier: bool = false, pub fn clicked(self: Signal) bool { return self.flags.contains(.left_clicked) or self.flags.contains(.right_clicked); @@ -240,13 +245,21 @@ pub const Signal = struct { const BoxIndex = std.math.IntFittingRange(0, max_boxes); +pub const Style = struct { + borders: Box.Borders = .{}, + rounded: bool = false, + background: ?rl.Color = null, +}; + pub const Box = struct { pub const Persistent = struct { size: Vec2 = .{ .x = 0, .y = 0 }, position: Vec2 = .{ .x = 0, .y = 0 }, children_size: Vec2 = .{ .x = 0, .y = 0 }, - sroll_offset: f32 = 0 + sroll_offset: f32 = 0, + hot: f32 = 0, + active: f32 = 0, }; pub const Flag = enum { @@ -255,9 +268,7 @@ pub const Box = struct { highlight_hot, highlight_active, - draggable_x, - draggable_y, - + draggable, scrollable, fixed_x, @@ -265,8 +276,6 @@ pub const Box = struct { fixed_width, fixed_height, - hover_mouse_hand, - skip_draw, text_underline, @@ -276,6 +285,25 @@ pub const Box = struct { pub const Flags = std.EnumSet(Flag); + pub const Border = struct { + size: f32 = 0, + color: rl.Color = rl.Color.magenta, + }; + + pub const Borders = struct { + left: Border = .{}, + right: Border = .{}, + top: Border = .{}, + bottom: Border = .{}, + + pub fn all(self: *Borders, border: Border) void { + self.left = border; + self.right = border; + self.top = border; + self.bottom = border; + } + }; + allocator: std.mem.Allocator, key: Key, @@ -294,6 +322,9 @@ pub const Box = struct { } = null, texture: ?rl.Texture2D = null, view_offset: Vec2 = .{ .x = 0, .y = 0 }, + borders: Borders = .{}, + hot_cursor: ?rl.MouseCursor = null, + active_cursor: ?rl.MouseCursor = null, persistent: Persistent = .{}, @@ -463,6 +494,8 @@ arenas: [2]std.heap.ArenaAllocator, boxes: std.BoundedArray(Box, max_boxes) = .{}, parent_index_stack: std.BoundedArray(BoxIndex, max_boxes) = .{}, +style_stack: std.BoundedArray(Style, 16) = .{}, +style: Style = .{}, frame_index: u64 = 0, hot_box_key: ?Key = null, @@ -474,16 +507,26 @@ mouse_delta: Vec2 = .{ .x = 0, .y = 0 }, mouse_buttons: std.EnumSet(rl.MouseButton) = .{}, window_size: Vec2 = .{ .x = 0, .y = 0 }, +dt: f32 = 0, + +show_grayscale: bool = false, +grayscale_shader: rl.Shader, + pub fn init(allocator: std.mem.Allocator) UI { + const shader = rl.loadShaderFromMemory(null, @embedFile("./grayscale.fs")); + assert(shader.id != 0); + return UI{ .arenas = .{ std.heap.ArenaAllocator.init(allocator), std.heap.ArenaAllocator.init(allocator) }, - .mouse = rl.getMousePosition() + .mouse = rl.getMousePosition(), + .grayscale_shader = shader }; } pub fn deinit(self: *UI) void { self.arenas[0].deinit(); self.arenas[1].deinit(); + rl.unloadShader(self.grayscale_shader); } pub fn begin(self: *UI) void { @@ -492,6 +535,7 @@ pub fn begin(self: *UI) void { const mouse = rl.getMousePosition(); self.mouse_delta = mouse.subtract(self.mouse); + self.dt = rl.getFrameTime(); // TODO: Maybe add a flag to enable this for active box // const active_box_flags = self.getActiveBoxFlags(); @@ -517,13 +561,13 @@ pub fn begin(self: *UI) void { } else { i += 1; } - } } self.frame_index += 1; _ = self.frameArena().reset(.retain_capacity); self.parent_index_stack.len = 0; + self.style_stack.len = 0; if (self.active_box_keys.count() == 0) { self.hot_box_key = null; @@ -563,24 +607,50 @@ pub fn end(self: *UI) void { self.popParent(); assert(self.parent_index_stack.len == 0); + // Mouse cursor { - var active_box_flags = self.getActiveBoxFlags(); - var hover_box_flags: Box.Flags = .{}; - if (self.hot_box_key) |hot_box_key| { - if (self.findBoxByKey(hot_box_key)) |hot_box| { - hover_box_flags = hot_box.flags; + var cursor: ?rl.MouseCursor = null; + + var active_iter = self.active_box_keys.iterator(); + while (active_iter.next()) |active_box_key| { + if (self.findBoxByKey(active_box_key.value.*)) |active_box| { + cursor = active_box.active_cursor; + + if (cursor != null) { + break; + } } } - if (active_box_flags.contains(.draggable_x)) { - rl.setMouseCursor(@intFromEnum(rl.MouseCursor.mouse_cursor_resize_ew)); - } else if (active_box_flags.contains(.draggable_y)) { - rl.setMouseCursor(@intFromEnum(rl.MouseCursor.mouse_cursor_resize_ns)); - } else if (hover_box_flags.contains(.hover_mouse_hand)) { - rl.setMouseCursor(@intFromEnum(rl.MouseCursor.mouse_cursor_pointing_hand)); - } else { - rl.setMouseCursor(@intFromEnum(rl.MouseCursor.mouse_cursor_default)); + if (cursor == null) { + if (self.hot_box_key) |hot_box_key| { + if (self.findBoxByKey(hot_box_key)) |hot_box| { + cursor = hot_box.hot_cursor; + } + } } + + rl.setMouseCursor(@intFromEnum(cursor orelse rl.MouseCursor.mouse_cursor_default)); + } + + // Update Animations + { + const fast_rate = 1 - std.math.pow(f32, 2, (-50 * self.dt)); + + for (self.boxes.slice()) |*_box| { + const box: *Box = _box; + if (box.key.isNil()) continue; + + const is_hot: f32 = @floatFromInt(@intFromBool(self.isKeyHot(box.key))); + const is_active: f32 = @floatFromInt(@intFromBool(self.isKeyActive(box.key))); + + box.persistent.hot += fast_rate * (is_hot - box.persistent.hot ); + box.persistent.active += fast_rate * (is_active - box.persistent.active); + } + } + + if (rl.isKeyPressed(rl.KeyboardKey.key_f5) and builtin.mode == .Debug) { + self.show_grayscale = !self.show_grayscale; } const root_box = self.findBoxByKey(root_box_key).?; @@ -588,6 +658,14 @@ pub fn end(self: *UI) void { self.calcLayout(root_box, .Y); } +pub fn pushStyle(self: *UI) void { + self.style_stack.appendAssumeCapacity(self.style); +} + +pub fn popStyle(self: *UI) void { + self.style = self.style_stack.pop(); +} + fn getActiveBoxFlags(self: *UI) Box.Flags { var result_flags: Box.Flags = .{}; @@ -603,7 +681,15 @@ fn getActiveBoxFlags(self: *UI) Box.Flags { pub fn draw(self: *UI) void { const root_box = self.findBoxByKey(root_box_key).?; - self.drawBox(root_box); + + if (self.show_grayscale) { + self.grayscale_shader.activate(); + defer self.grayscale_shader.deactivate(); + + self.drawBox(root_box); + } else { + self.drawBox(root_box); + } if (debug) { const font = Assets.font(.text); @@ -682,6 +768,13 @@ fn drawBox(self: *UI, box: *Box) void { return; } + var value_shift: f32 = 0; + if (box.flags.contains(.highlight_active) and self.isKeyActive(box.key)) { + value_shift = -0.5 * box.persistent.active; + } else if (box.flags.contains(.highlight_hot) and self.isKeyHot(box.key)) { + value_shift = 0.6 * box.persistent.hot; + } + const box_rect = box.computedRect(); const do_scissor = box.hasClipping(); @@ -696,16 +789,29 @@ fn drawBox(self: *UI, box: *Box) void { defer if (do_scissor) rl.endScissorMode(); if (box.background) |background| { - rl.drawRectangleRec(box_rect, background); + rl.drawRectangleRec(box_rect, utils.shiftColorInHSV(background, value_shift)); } - if (self.isKeyActive(box.key)) { - if (box.flags.contains(.highlight_active)) { - rl.drawRectangleLinesEx(box_rect, 2, rl.Color.orange); - } - } else if (self.isKeyHot(box.key)) { - if (box.flags.contains(.highlight_hot)) { - rl.drawRectangleLinesEx(box_rect, 3, rl.Color.blue); + const borders_with_coords = .{ + .{ box.borders.left , rl.Vector2.init(0, 0), rl.Vector2.init(0, 1), rl.Vector2.init( 1, 0) }, + .{ box.borders.right , rl.Vector2.init(1, 0), rl.Vector2.init(1, 1), rl.Vector2.init(-1, 0) }, + .{ box.borders.top , rl.Vector2.init(0, 0), rl.Vector2.init(1, 0), rl.Vector2.init( 0, 1) }, + .{ box.borders.bottom, rl.Vector2.init(0, 1), rl.Vector2.init(1, 1), rl.Vector2.init( 0, -1) } + }; + inline for (borders_with_coords) |border_with_coords| { + const border = border_with_coords[0]; + const line_from = border_with_coords[1]; + const line_to = border_with_coords[2]; + const inset_direction: rl.Vector2 = border_with_coords[3]; + + if (border.size > 0) { + const inset = inset_direction.multiply(Vec2.init(border.size/2, border.size/2)); + rl.drawLineEx( + rect_utils.positionAt(box_rect, line_from).add(inset), + rect_utils.positionAt(box_rect, line_to).add(inset), + border.size, + utils.shiftColorInHSV(border.color, value_shift) + ); } } @@ -750,13 +856,16 @@ fn drawBox(self: *UI, box: *Box) void { text_rect.y += (box_rect.height - text_size.y) / 2; } - font.drawText(text.content, .{ .x = text_rect.x, .y = text_rect.y }, text.color); + // text_rect.y += box.persistent.active * text_rect.height*0.1; + + const text_color = utils.shiftColorInHSV(text.color, value_shift); + font.drawText(text.content, .{ .x = text_rect.x, .y = text_rect.y }, text_color); if (box.flags.contains(.text_underline)) { rl.drawLineV( rect_utils.bottomLeft(text_rect), rect_utils.bottomRight(text_rect), - text.color + text_color ); } } @@ -1177,6 +1286,12 @@ pub fn newBoxNoAppend(self: *UI, key: Key) *Box { .index = box_index.? }; + if (!key.isNil()) { + box.background = self.style.background; + box.rounded = self.style.rounded; + box.borders = self.style.borders; + } + return box; } @@ -1221,7 +1336,7 @@ pub fn signalFromBox(self: *UI, box: *Box) Signal { const key = box.key; const clickable = box.flags.contains(.clickable); - const draggable = box.flags.contains(.draggable_x) or box.flags.contains(.draggable_y); + const draggable = box.flags.contains(.draggable); const scrollable = box.flags.contains(.scrollable); const is_mouse_inside = rect_utils.isInsideVec2(rect, self.mouse); @@ -1241,7 +1356,7 @@ pub fn signalFromBox(self: *UI, box: *Box) Signal { if (event == .mouse_released and clickable and is_active and is_mouse_inside) { const mouse_button = event.mouse_released; - result.insertMousePressed(mouse_button); + result.insertMouseReleased(mouse_button); result.insertMouseClicked(mouse_button); self.active_box_keys.remove(mouse_button); @@ -1250,7 +1365,7 @@ pub fn signalFromBox(self: *UI, box: *Box) Signal { if (event == .mouse_released and clickable and is_active and !is_mouse_inside) { const mouse_button = event.mouse_released; - result.insertMousePressed(mouse_button); + result.insertMouseReleased(mouse_button); self.hot_box_key = null; self.active_box_keys.remove(mouse_button); @@ -1279,7 +1394,6 @@ pub fn signalFromBox(self: *UI, box: *Box) Signal { result.insertMouseDragged(mouse_button); result.drag = self.mouse_delta; } - } } @@ -1290,6 +1404,9 @@ pub fn signalFromBox(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.shift_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); return result; } @@ -1412,7 +1529,7 @@ pub fn popScrollbar(self: *UI) void { if (!content_area.flags.contains(.skip_draw)) { const scrollbar_area = self.newBoxFromString("Scrollbar area"); - scrollbar_area.background = rl.Color.gold; + scrollbar_area.background = srcery.hard_black; scrollbar_area.flags.insert(.scrollable); scrollbar_area.size = .{ .x = UI.Size.pixels(24, 1), @@ -1421,10 +1538,13 @@ pub fn popScrollbar(self: *UI) void { self.pushParent(scrollbar_area); defer self.popParent(); - const draggable = self.newBoxFromString("Scrollbar button"); - draggable.background = rl.Color.dark_brown; - draggable.flags.insert(.clickable); - draggable.flags.insert(.draggable_y); + const draggable = self.clickableBox("Scrollbar button"); + draggable.background = srcery.black; + draggable.borders.all(.{ + .size = 4, + .color = srcery.xgray3 + }); + draggable.flags.insert(.draggable); draggable.size = .{ .x = UI.Size.percent(1, 1), .y = UI.Size.percent(visible_percent, 1), @@ -1471,7 +1591,7 @@ pub fn clickableBox(self: *UI, key: []const u8) *Box { box.flags.insert(.clickable); box.flags.insert(.highlight_active); box.flags.insert(.highlight_hot); - box.flags.insert(.hover_mouse_hand); + box.hot_cursor = rl.MouseCursor.mouse_cursor_pointing_hand; return box; } diff --git a/src/utils.zig b/src/utils.zig index a1af17d..01e37cc 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -16,6 +16,30 @@ pub fn rgba(r: u8, g: u8, b: u8, a: f32) rl.Color { return rl.Color.init(r, g, b, a * 255); } +pub fn lerpColor(from: rl.Color, to: rl.Color, t: f32) rl.Color { + const r = std.math.lerp(@as(f32, @floatFromInt(from.r)), @as(f32, @floatFromInt(to.r)), t); + const g = std.math.lerp(@as(f32, @floatFromInt(from.g)), @as(f32, @floatFromInt(to.g)), t); + const b = std.math.lerp(@as(f32, @floatFromInt(from.b)), @as(f32, @floatFromInt(to.b)), t); + const a = std.math.lerp(@as(f32, @floatFromInt(from.a)), @as(f32, @floatFromInt(to.a)), t); + + return rl.Color{ + .r = @intFromFloat(r), + .g = @intFromFloat(g), + .b = @intFromFloat(b), + .a = @intFromFloat(a), + }; +} + +pub fn shiftColorInHSV(color: rl.Color, value_shift: f32) rl.Color { + if (value_shift == 0) { + return color; + } + + var hsv = rl.colorToHSV(color); + hsv.z = std.math.clamp(hsv.z * (1 + value_shift), 0, 1); + return rl.colorFromHSV(hsv.x, hsv.y, hsv.z); +} + pub fn drawUnderline(rect: rl.Rectangle, size: f32, color: rl.Color) void { rl.drawRectangleRec(rl.Rectangle{ .x = rect.x,