add rulers to channel plots
This commit is contained in:
parent
6e332df183
commit
859c36e93a
1003
src/app.zig
1003
src/app.zig
File diff suppressed because it is too large
Load Diff
220
src/graph.zig
220
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
196
src/range.zig
Normal file
196
src/range.zig
Normal file
@ -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)
|
||||
);
|
||||
}
|
587
src/screens/main_screen.zig
Normal file
587
src/screens/main_screen.zig
Normal file
@ -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()) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
303
src/ui.zig
303
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();
|
||||
}
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user