Compare commits

...

10 commits

Author SHA1 Message Date
David Vanderson
28b8e708ab sliderEntry: tamp down on the exponential ramp (thanks KnockerPulsar) 2023-11-10 15:21:11 -05:00
David Vanderson
656c07ebec README 2023-11-09 15:25:06 -05:00
David Vanderson
f4e7a1de70 INSIDE: edits for widget drawing 2023-11-09 15:22:43 -05:00
David Vanderson
d9d10f22eb INSIDE: link to sliderEntry, fix key bubbling 2023-11-09 15:19:41 -05:00
David Vanderson
18938d28fa sliderEntry: keyboard left/right keys 2023-11-09 15:08:56 -05:00
David Vanderson
eb63633692 sliderEntry: add interval option 2023-11-09 14:57:11 -05:00
David Vanderson
c727cb0cf6 box: separate drawBackground from install
sliderEntry needs this to show a hover state

In general it looks like separating any drawing into separate functions
is a good idea.
2023-11-09 14:48:10 -05:00
David Vanderson
6f5db54509 sliderEntry: work without min or max
When we don't have a min or max, use how far the mouse is from the one
we have and run that through an exponential.

When we don't have either, save the mouse-down position and value at
that time and run the exponential from that point.
2023-11-08 22:02:55 -05:00
David Vanderson
7eacd49de1 sliderEntry: add init opts with min/max 2023-11-08 20:51:51 -05:00
David Vanderson
0dfc88ca71 sliderEntry text: commit on lost focus and escape key 2023-11-08 20:17:42 -05:00
5 changed files with 215 additions and 64 deletions

View file

@ -38,6 +38,15 @@ pub fn button(src: std.builtin.SourceLocation, label_str: []const u8, opts: Opti
}
```
See the code for [pub fn sliderEntry](https://github.com/david-vanderson/dvui/blob/master/src/dvui.zig#:~:text=pub%20fn%20sliderEntry) for an advanced example that includes:
* swapping the kind of widget
* min size calculated from font
* tab index
* storing data from frame to frame
* intercepting events and forwarding to child widgets
* tracking ctrl key for ctrl-click
* drawing a rounded rect
### One Frame At a Time
DVUI is an immediate-mode GUI, so widgets are created on the fly. We also process the whole list of events that happened since last frame.
@ -69,7 +78,6 @@ Here the widget has a rectangle, but hasn't drawn anything. Animations (fading
* `install()`
* `parentSet()` set this widget as the new parent
* `register()` provides debugging information
* draws border and background
* some widgets set the clipping rectangle (sometimes called scissor rectangle) to prevent drawing outside its given space
* this is how a scroll container prevents children that are half-off the scroll viewport from drawing over other widgets
@ -82,10 +90,9 @@ Now the widget is the parent widget, so further widgets nested here will be chil
See the Event Handling section for details.
* `drawBackground()`, `draw()`, `drawFocus()`
* `drawBackground()`, `draw()`, `drawFocus()`, `drawCursor()`
* draw parts of the widget, there's some variety here
* some widgets (BoxWidget) don't have a draw at all, they only do border/background
* some widgets (ButtonWidget) only have drawFocus to maybe draw a focus border
* some widgets (BoxWidget) only do border/background
* `deinit()`
* some widgets process some events here

View file

@ -12,7 +12,8 @@ Examples:
- ```zig build run-standalone-sdl```
- ```zig build run-ontop-sdl```
Get Started: find the widget you want in the example and copy the code from the `demo()` function in `src/dvui.zig`.
## Get Started
Find the widget you want in the example and copy the code from `src/Examples.zig`.
This document is a broad overview. See [inside](/INSIDE.md) for implementation details and how to write and modify widgets.

View file

@ -26,6 +26,10 @@ var checkbox_bool: bool = false;
var icon_image_size_extra: f32 = 0;
var icon_image_rotation: f32 = 0;
var slider_val: f32 = 0.0;
var slider_entry_val: f32 = 0.05;
var slider_entry_min: bool = true;
var slider_entry_max: bool = true;
var slider_entry_interval: bool = true;
var text_entry_buf = std.mem.zeroes([30]u8);
var text_entry_password_buf = std.mem.zeroes([30]u8);
var text_entry_password_buf_obf_enable: bool = true;
@ -345,25 +349,6 @@ pub fn basicWidgets() !void {
}
}
{
var hbox = try dvui.box(@src(), .horizontal, .{ .expand = .horizontal, .min_size_content = .{ .h = 40 } });
defer hbox.deinit();
try dvui.label(@src(), "Sliders", .{}, .{ .gravity_y = 0.5 });
_ = try dvui.slider(@src(), .horizontal, &slider_val, .{ .expand = .horizontal, .gravity_y = 0.5, .corner_radius = dvui.Rect.all(100) });
_ = try dvui.slider(@src(), .vertical, &slider_val, .{ .expand = .vertical, .min_size_content = .{ .w = 10 }, .corner_radius = dvui.Rect.all(100) });
try dvui.label(@src(), "Value: {d:2.2}", .{slider_val}, .{ .gravity_y = 0.5 });
}
{
var hbox = try dvui.box(@src(), .horizontal, .{ .expand = .horizontal, .min_size_content = .{ .h = 40 } });
defer hbox.deinit();
try dvui.label(@src(), "Slider Entry", .{}, .{ .gravity_y = 0.5 });
_ = try dvui.sliderEntry(@src(), "val: {d:0.2}", &slider_val, .{ .gravity_y = 0.5 });
try dvui.label(@src(), "(enter or ctrl-click)", .{}, .{ .gravity_y = 0.5 });
}
try dvui.checkbox(@src(), &checkbox_bool, "Checkbox", .{});
{
@ -386,6 +371,34 @@ pub fn basicWidgets() !void {
_ = try dvui.dropdown(@src(), &entries, &dropdown_val, .{ .min_size_content = .{ .w = 120 } });
}
{
var hbox = try dvui.box(@src(), .horizontal, .{ .expand = .horizontal, .min_size_content = .{ .h = 40 } });
defer hbox.deinit();
try dvui.label(@src(), "Sliders", .{}, .{ .gravity_y = 0.5 });
_ = try dvui.slider(@src(), .horizontal, &slider_val, .{ .expand = .horizontal, .gravity_y = 0.5, .corner_radius = dvui.Rect.all(100) });
_ = try dvui.slider(@src(), .vertical, &slider_val, .{ .expand = .vertical, .min_size_content = .{ .w = 10 }, .corner_radius = dvui.Rect.all(100) });
try dvui.label(@src(), "Value: {d:2.2}", .{slider_val}, .{ .gravity_y = 0.5 });
}
{
var hbox = try dvui.box(@src(), .horizontal, .{});
defer hbox.deinit();
try dvui.label(@src(), "Slider Entry", .{}, .{ .gravity_y = 0.5 });
_ = try dvui.sliderEntry(@src(), "val: {d:0.3}", .{ .value = &slider_entry_val, .min = (if (slider_entry_min) 0 else null), .max = (if (slider_entry_max) 1 else null), .interval = (if (slider_entry_interval) 0.1 else null) }, .{ .gravity_y = 0.5 });
try dvui.label(@src(), "(enter or ctrl-click)", .{}, .{ .gravity_y = 0.5 });
}
{
var hbox = try dvui.box(@src(), .horizontal, .{ .padding = .{ .x = 10 } });
defer hbox.deinit();
try dvui.checkbox(@src(), &slider_entry_min, "Min", .{});
try dvui.checkbox(@src(), &slider_entry_max, "Max", .{});
try dvui.checkbox(@src(), &slider_entry_interval, "Interval", .{});
}
{
var hbox = try dvui.box(@src(), .horizontal, .{});
defer hbox.deinit();
@ -949,6 +962,7 @@ pub fn animations() !void {
try button_wiggle.install();
button_wiggle.processEvents();
try button_wiggle.drawBackground();
try dvui.labelNoFmt(@src(), "Wiggle", button_wiggle.data().options.strip().override(.{ .gravity_x = 0.5, .gravity_y = 0.5 }));
try button_wiggle.drawFocus();

View file

@ -3562,6 +3562,7 @@ pub const FloatingWindowWidget = struct {
// don't have margin, so turn that off
self.layout = BoxWidget.init(@src(), .vertical, false, self.options.override(.{ .margin = .{}, .expand = .both }));
try self.layout.install();
try self.layout.drawBackground();
}
pub fn processEventsBefore(self: *Self) void {
@ -4301,6 +4302,7 @@ pub fn expander(src: std.builtin.SourceLocation, label_str: []const u8, opts: Op
var bcbox = BoxWidget.init(@src(), .horizontal, false, options.strip());
defer bcbox.deinit();
try bcbox.install();
try bcbox.drawBackground();
const size = try options.fontGet().lineHeight();
if (expanded) {
try icon(@src(), "down_arrow", entypo.triangle_down, .{ .gravity_y = 0.5, .min_size_content = .{ .h = size } });
@ -5631,6 +5633,7 @@ pub fn box(src: std.builtin.SourceLocation, dir: enums.Direction, opts: Options)
var ret = try currentWindow().arena.create(BoxWidget);
ret.* = BoxWidget.init(src, dir, false, opts);
try ret.install();
try ret.drawBackground();
return ret;
}
@ -5638,6 +5641,7 @@ pub fn boxEqual(src: std.builtin.SourceLocation, dir: enums.Direction, opts: Opt
var ret = try currentWindow().arena.create(BoxWidget);
ret.* = BoxWidget.init(src, dir, true, opts);
try ret.install();
try ret.drawBackground();
return ret;
}
@ -5672,7 +5676,6 @@ pub const BoxWidget = struct {
pub fn install(self: *Self) !void {
try self.wd.register(self.wd.options.name orelse "Box", null);
try self.wd.borderAndBackground(.{});
// our rect for children has to start at 0,0
self.childRect = self.wd.contentRect().justSize();
@ -5696,6 +5699,10 @@ pub const BoxWidget = struct {
_ = parentSet(self.widget());
}
pub fn drawBackground(self: *Self) !void {
try self.wd.borderAndBackground(.{});
}
pub fn widget(self: *Self) Widget {
return Widget.init(self, data, rectFor, screenRectScale, minSizeForChild, processEvent);
}
@ -5891,6 +5898,7 @@ pub const ScrollAreaWidget = struct {
}
try self.hbox.install();
try self.hbox.drawBackground();
// the viewport is also set in ScrollContainer but we need it here in
// case the scroll bar modes are auto
@ -5910,6 +5918,7 @@ pub const ScrollAreaWidget = struct {
self.vbox = BoxWidget.init(@src(), .vertical, false, self.hbox.data().options.strip().override(.{ .expand = .both, .name = "ScrollAreaWidget vbox" }));
try self.vbox.install();
try self.vbox.drawBackground();
if (self.si.horizontal != .none) {
if (self.init_opts.horizontal_bar == .show or (self.init_opts.horizontal_bar == .auto and (self.si.virtual_size.w > self.si.viewport.w))) {
@ -6670,6 +6679,7 @@ pub const ScaleWidget = struct {
self.box = BoxWidget.init(@src(), .vertical, false, self.wd.options.strip().override(.{ .expand = .both }));
try self.box.install();
try self.box.drawBackground();
}
pub fn widget(self: *Self) Widget {
@ -6788,6 +6798,7 @@ pub const MenuWidget = struct {
self.box = BoxWidget.init(@src(), self.init_opts.dir, false, self.wd.options.strip().override(.{ .expand = .both }));
try self.box.install();
try self.box.drawBackground();
}
pub fn close(self: *Self) void {
@ -7898,6 +7909,7 @@ pub fn slider(src: std.builtin.SourceLocation, dir: enums.Direction, percent: *f
}
var knob = BoxWidget.init(@src(), .horizontal, false, .{ .rect = knobRect, .padding = .{}, .margin = .{}, .background = true, .border = Rect.all(1), .corner_radius = Rect.all(100), .color_fill = fill_color });
try knob.install();
try knob.drawBackground();
if (b.data().id == focusedWidgetId()) {
try knob.wd.focusBorder();
}
@ -7916,19 +7928,31 @@ pub var slider_entry_defaults: Options = .{
.padding = Rect.all(2),
.color_style = .control,
.background = true,
// min size calulated from font
};
pub const SliderEntryInitOptions = struct {
value: *f32,
min: ?f32 = null,
max: ?f32 = null,
interval: ?f32 = null,
};
/// Combines a slider and a text entry box on key press. Displays value on top of slider.
///
/// Returns true if percent was changed.
pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const u8, value: *f32, opts: Options) !bool {
pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const u8, init_opts: SliderEntryInitOptions, opts: Options) !bool {
// This widget swaps between either a slider with a label or a text entry.
// The tricky part of this is maintaining focus. Strategy is a containing
// box that will keep focus, and forward events to the text entry.
//
// We are intentinally keeping this simple by only swapping between slider
// and textEntry on a frame boundary.
// We are keeping this simple by only swapping between slider and textEntry
// on a frame boundary.
const exp_min_change = 0.1;
const exp_stretch = 0.02;
const key_percentage = 0.05;
var options = slider_entry_defaults.override(opts);
if (options.min_size_content == null) {
@ -7936,7 +7960,9 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
options.min_size_content = .{ .w = msize.w * 10, .h = msize.h };
}
var ret = false;
var b = try box(src, .horizontal, options);
var hover = false;
var b = BoxWidget.init(src, .horizontal, false, options);
try b.install();
defer b.deinit();
if (b.data().visible()) {
@ -7950,10 +7976,15 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
var text_mode = dataGet(null, b.data().id, "_text_mode", bool) orelse false;
var ctrl_down = dataGet(null, b.data().id, "_ctrl", bool) orelse false;
// must call dataGet/dataSet on these every frame to prevent them from
// getting purged
_ = dataGet(null, b.data().id, "_start_x", f32);
_ = dataGet(null, b.data().id, "_start_v", f32);
if (text_mode) {
var te_buf = dataGetSlice(null, b.data().id, "_buf", []u8) orelse blk: {
var buf = [_]u8{0} ** 20;
_ = std.fmt.bufPrintZ(&buf, "{d:0.3}", .{value.*}) catch {};
_ = std.fmt.bufPrintZ(&buf, "{d:0.3}", .{init_opts.value.*}) catch {};
dataSetSlice(null, b.data().id, "_buf", &buf);
break :blk dataGetSlice(null, b.data().id, "_buf", []u8).?;
};
@ -7969,20 +8000,33 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
sel.end = std.math.maxInt(usize);
}
var new_val: ?f32 = null;
var evts = events();
for (evts) |*e| {
if (e.evt == .key) {
ctrl_down = e.evt.key.mod.controlCommand();
}
if (!text_mode) {
// if we are switching out of text mode, skip processing any
// remaining events
continue;
}
if (!eventMatch(e, .{ .id = b.data().id, .r = rs.r }) and !te.matchEvent(e))
continue;
if (e.evt == .key and e.evt.key.code == .enter and e.evt.key.action == .down) {
if (e.evt == .key and e.evt.key.action == .down and e.evt.key.code == .enter) {
e.handled = true;
text_mode = false;
value.* = std.fmt.parseFloat(f32, std.mem.sliceTo(te_buf, 0)) catch 0;
ret = true;
new_val = std.fmt.parseFloat(f32, te_buf[0..te.len]) catch null;
}
if (e.evt == .key and e.evt.key.action == .down and e.evt.key.code == .escape) {
e.handled = true;
text_mode = false;
// don't set new_val, we are escaping
}
// don't want TextEntry to get focus
@ -7996,14 +8040,32 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
}
}
if (b.data().id != focusedWidgetId()) {
// we lost focus
text_mode = false;
new_val = std.fmt.parseFloat(f32, te_buf[0..te.len]) catch null;
}
if (!text_mode) {
refresh(null, @src(), b.data().id);
if (new_val) |nv| {
init_opts.value.* = nv;
ret = true;
}
}
try te.draw();
try te.drawCursor();
te.deinit();
} else {
// show slider and label
const track = Rect{ .x = knobsize / 2, .y = br.h / 2 - 2, .w = br.w - knobsize, .h = 4 };
const trackrs = b.widget().screenRectScale(track);
const trackrs = b.widget().screenRectScale(.{ .x = knobsize / 2, .w = br.w - knobsize });
const min_x = trackrs.r.x;
const max_x = trackrs.r.x + trackrs.r.w;
const px_scale = trackrs.s;
var evts = events();
for (evts) |*e| {
if (e.evt == .key) {
@ -8026,27 +8088,76 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
} else {
captureMouse(b.data().id);
p = me.p;
dataSet(null, b.data().id, "_start_x", me.p.x);
dataSet(null, b.data().id, "_start_v", init_opts.value.*);
}
} else if (me.action == .release and me.button.pointer()) {
captureMouse(null);
e.handled = true;
captureMouse(null);
dataRemove(null, b.data().id, "_start_x");
dataRemove(null, b.data().id, "_start_v");
} else if (me.action == .motion and captured(b.data().id)) {
e.handled = true;
p = me.p;
} else if (me.action == .position) {
e.handled = true;
//hovered = true;
hover = true;
}
if (p) |pp| {
var min: f32 = trackrs.r.x;
var max: f32 = trackrs.r.x + trackrs.r.w;
if (max > min) {
const v = pp.x;
value.* = (v - min) / (max - min);
value.* = @max(0, @min(1, value.*));
if (max_x > min_x) {
ret = true;
if (init_opts.min != null and init_opts.max != null) {
// lerp but make sure we can hit the max
if (pp.x > max_x) {
init_opts.value.* = init_opts.max.?;
} else {
const px_lerp = @max(0, @min(1, (pp.x - min_x) / (max_x - min_x)));
init_opts.value.* = init_opts.min.? + px_lerp * (init_opts.max.? - init_opts.min.?);
if (init_opts.interval) |ival| {
init_opts.value.* = init_opts.min.? + ival * @round((init_opts.value.* - init_opts.min.?) / ival);
}
}
} else if (init_opts.min != null) {
// only have min, go exponentially to the right
if (pp.x < min_x) {
init_opts.value.* = init_opts.min.?;
} else {
const base = if (init_opts.min.? == 0) exp_min_change else @exp(math.ln10 * @floor(@log10(@fabs(init_opts.min.?)))) * exp_min_change;
const how_far = @max(0, (pp.x - min_x)) / px_scale;
const how_much = (@exp(how_far * exp_stretch) - 1) * base;
init_opts.value.* = init_opts.min.? + how_much;
if (init_opts.interval) |ival| {
init_opts.value.* = init_opts.min.? + ival * @round((init_opts.value.* - init_opts.min.?) / ival);
}
}
} else if (init_opts.max != null) {
// only have max, go exponentially to the left
if (pp.x > max_x) {
init_opts.value.* = init_opts.max.?;
} else {
const base = if (init_opts.max.? == 0) exp_min_change else @exp(math.ln10 * @floor(@log10(@fabs(init_opts.max.?)))) * exp_min_change;
const how_far = @max(0, (max_x - pp.x)) / px_scale;
const how_much = (@exp(how_far * exp_stretch) - 1) * base;
init_opts.value.* = init_opts.max.? - how_much;
if (init_opts.interval) |ival| {
init_opts.value.* = init_opts.max.? - ival * @round((init_opts.max.? - init_opts.value.*) / ival);
}
}
} else {
// neither min nor max, go exponentially away from starting value
if (dataGet(null, b.data().id, "_start_x", f32)) |start_x| {
if (dataGet(null, b.data().id, "_start_v", f32)) |start_v| {
const base = if (start_v == 0) exp_min_change else @exp(math.ln10 * @floor(@log10(@fabs(start_v)))) * exp_min_change;
const how_far = (pp.x - start_x) / px_scale;
const how_much = (@exp(@fabs(how_far) * exp_stretch) - 1) * base;
init_opts.value.* = if (how_far < 0) start_v - how_much else start_v + how_much;
if (init_opts.interval) |ival| {
init_opts.value.* = start_v + ival * @round((init_opts.value.* - start_v) / ival);
}
}
}
}
}
}
},
@ -8055,15 +8166,23 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
text_mode = true;
} else if (ke.action == .down or ke.action == .repeat) {
switch (ke.code) {
.left => {
.left, .right => {
e.handled = true;
value.* = @max(0, @min(1, value.* - 0.05));
ret = true;
},
.right => {
e.handled = true;
value.* = @max(0, @min(1, value.* + 0.05));
ret = true;
if (init_opts.interval) |ival| {
init_opts.value.* = init_opts.value.* + (if (ke.code == .left) -ival else ival);
} else {
const how_much = @fabs(init_opts.value.*) * key_percentage;
init_opts.value.* = if (ke.code == .left) init_opts.value.* - how_much else init_opts.value.* + how_much;
}
if (init_opts.min) |min| {
init_opts.value.* = @max(min, init_opts.value.*);
}
if (init_opts.max) |max| {
init_opts.value.* = @min(max, init_opts.value.*);
}
},
else => {},
}
@ -8071,15 +8190,25 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
},
else => {},
}
if (e.bubbleable()) {
b.wd.parent.processEvent(e, true);
}
}
const knobRect = Rect{ .x = (br.w - knobsize) * value.*, .w = knobsize, .h = knobsize };
const knobrs = b.widget().screenRectScale(knobRect);
try b.wd.borderAndBackground(.{ .fill_color = if (hover) b.wd.options.color(.hover) else b.wd.options.color(.fill) });
try pathAddRect(knobrs.r, options.corner_radiusGet().scale(knobrs.s));
try pathFillConvex(options.color(.press));
// only draw handle if we have a min and max
if (init_opts.min != null and init_opts.max != null) {
const how_far = (init_opts.value.* - init_opts.min.?) / (init_opts.max.? - init_opts.min.?);
const knobRect = Rect{ .x = (br.w - knobsize) * math.clamp(how_far, 0, 1), .w = knobsize, .h = knobsize };
const knobrs = b.widget().screenRectScale(knobRect);
try label(@src(), label_fmt orelse "{d:.3}", .{value.*}, options.strip().override(.{ .expand = .both, .gravity_x = 0.5, .gravity_y = 0.5 }));
try pathAddRect(knobrs.r, options.corner_radiusGet().scale(knobrs.s));
try pathFillConvex(options.color(.press));
}
try label(@src(), label_fmt orelse "{d:.3}", .{init_opts.value.*}, options.strip().override(.{ .expand = .both, .gravity_x = 0.5, .gravity_y = 0.5 }));
}
if (b.data().id == focusedWidgetId()) {
@ -8088,6 +8217,11 @@ pub fn sliderEntry(src: std.builtin.SourceLocation, comptime label_fmt: ?[]const
dataSet(null, b.data().id, "_text_mode", text_mode);
dataSet(null, b.data().id, "_ctrl", ctrl_down);
if (ret) {
refresh(null, @src(), b.data().id);
}
return ret;
}

View file

@ -1,13 +1,8 @@
update README/INSIDE and screenshot
SliderFloat/SliderDrag thing (see open issue)
- ctrl-click or enter (or any keypress?) should transition into text editing
- min/max vs. continuous change?
- hover?
- keyboard left-right
- esc from entry
- entry should close when we lose focus
- tab index of 0
- how to add a link to a specific function (like to sliderEntry?)
- add to url #:~:text=function_name
file zig pull request for sliceAsBytes/bytesAsSlice to include sentinel