Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 55 additions & 8 deletions libs/dvui-singleton-app/src/unix_impl.zig
Original file line number Diff line number Diff line change
Expand Up @@ -110,22 +110,69 @@ pub const Primary = struct {
}
};

fn trySendArgv(io: std.Io, addr: std.Io.net.UnixAddress, argv: []const []const u8) !bool {
fn trySendArgv(_: std.Io, addr: std.Io.net.UnixAddress, argv: []const []const u8) !bool {
// Either the primary accepts us, or the socket file is stale. We can't
// distinguish between "no listener" and other transient connect errors
// without enumerating the impl's error set, so treat any failure here
// as "no live primary" — the caller will fall through to remove the
// stale socket and retry binding.
const stream = addr.connect(io) catch return false;
defer stream.close(io);

var wbuf: [4096]u8 = undefined;
var sw = stream.writer(io, &wbuf);
try root.writeArgvIo(&sw.interface, argv);
try sw.interface.flush();
//
// Use libc connect directly: std.Io's posixConnectUnix does not map
// ECONNREFUSED, so a stale socket triggers unexpectedErrno + stack trace
// even though the caller catches the error.
const fd = connectUnixClient(addr.path) orelse return false;
defer _ = std.c.close(fd);

writeArgvFd(fd, argv) catch return false;
return true;
}

fn connectUnixClient(path: []const u8) ?std.c.fd_t {
const sock = std.c.socket(std.c.AF.UNIX, std.c.SOCK.STREAM, 0);
if (sock < 0) return null;
errdefer _ = std.c.close(@intCast(sock));

var storage: std.c.sockaddr.un = .{
.family = std.c.AF.UNIX,
.path = undefined,
};
const addr_len: std.c.socklen_t = @intCast(@offsetOf(std.c.sockaddr.un, "path") + path.len + 1);
if (path.len >= storage.path.len) return null;
@memcpy(storage.path[0..path.len], path);
storage.path[path.len] = 0;
const rc = std.c.connect(@intCast(sock), @ptrCast(&storage), addr_len);
if (rc == 0) return @intCast(sock);
_ = std.c.close(@intCast(sock));
switch (std.posix.errno(rc)) {
.CONNREFUSED => return null,
else => return null,
}
}

fn writeArgvFd(fd: std.c.fd_t, argv: []const []const u8) !void {
var hdr: [4]u8 = undefined;
std.mem.writeInt(u32, &hdr, @intCast(argv.len), .little);
try writeAllFd(fd, &hdr);
var total: u64 = 4;
for (argv) |arg| {
if (arg.len > root.max_arg_bytes) return root.Error.ArgTooLong;
total += 4 + @as(u64, arg.len);
if (total > root.max_total_bytes) return root.Error.PayloadTooLarge;
std.mem.writeInt(u32, &hdr, @intCast(arg.len), .little);
try writeAllFd(fd, &hdr);
if (arg.len > 0) try writeAllFd(fd, arg);
}
}

fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) !void {
var index: usize = 0;
while (index < bytes.len) {
const n = std.c.write(fd, bytes[index..].ptr, bytes.len - index);
if (n < 0) return error.WriteFailed;
index += @intCast(n);
}
}

fn buildSocketPath(allocator: std.mem.Allocator, dir: []const u8, app_id: []const u8) ![]u8 {
const trimmed = trimTrailingSlash(dir);
const uid: u32 = @intCast(std.c.getuid());
Expand Down
77 changes: 64 additions & 13 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1126,14 +1126,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
dvui.log.err("Failed to tick hotkeys", .{});
};

for (dvui.events()) |*e| {
switch (e.evt) {
.mouse => |me| {
editor.tools.radial_menu.mouse_position = me.p;
},
else => {},
}
}
processHoldOpenRadialMenu(editor);

if (editor.tools.radial_menu.visible) {
editor.drawRadialMenu() catch {
Expand Down Expand Up @@ -1333,6 +1326,59 @@ pub fn setWindowStyle(_: *Editor) void {
fizzy.backend.setWindowStyle(dvui.currentWindow());
}

/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after
/// the opening finger lifts; close on tool button click or a non-drag click outside.
fn processHoldOpenRadialMenu(editor: *Editor) void {
const rm = &editor.tools.radial_menu;
if (!rm.visible or !rm.opened_by_press) {
rm.outside_click_press_p = null;
return;
}

const dismiss_move_threshold: f32 = dvui.Dragging.threshold;

for (dvui.events()) |*e| {
if (e.evt != .mouse) continue;
const me = e.evt.mouse;
rm.mouse_position = me.p;

const primary = me.button.pointer() or me.button.touch();
if (!primary) continue;

switch (me.action) {
.press => {
if (!rm.containsPhysical(me.p)) {
rm.outside_click_press_p = me.p;
} else {
rm.outside_click_press_p = null;
}
},
.motion => {
if (rm.outside_click_press_p) |press_p| {
if (me.p.diff(press_p).length() > dismiss_move_threshold) {
rm.outside_click_press_p = null;
}
}
},
.release => {
if (rm.suppress_next_pointer_release) {
rm.suppress_next_pointer_release = false;
rm.outside_click_press_p = null;
continue;
}
if (rm.outside_click_press_p) |press_p| {
const moved = me.p.diff(press_p).length() > dismiss_move_threshold;
if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) {
rm.close();
}
rm.outside_click_press_p = null;
}
},
else => {},
}
}
}

pub fn drawRadialMenu(editor: *Editor) !void {
var fw: dvui.FloatingWidget = undefined;
fw.init(@src(), .{}, .{
Expand All @@ -1342,10 +1388,8 @@ pub fn drawRadialMenu(editor: *Editor) !void {

const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0);

if (dvui.firstFrame(fw.data().id)) {
editor.tools.radial_menu.center = editor.tools.radial_menu.mouse_position;
}

// `center` is set when the menu opens (Space down or hold on empty workspace) and stays
// fixed until close so tool buttons remain hoverable/clickable.
const center = fw.data().rectScale().pointFromPhysical(editor.tools.radial_menu.center);

const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len;
Expand Down Expand Up @@ -1493,8 +1537,12 @@ pub fn drawRadialMenu(editor: *Editor) !void {
};
angle += step;

if (button.clicked() or button.hovered()) {
if (button.hovered()) {
editor.tools.set(tool);
}
if (button.clicked()) {
editor.tools.set(tool);
editor.tools.radial_menu.close();
}

button.deinit();
Expand Down Expand Up @@ -1530,6 +1578,9 @@ pub fn drawRadialMenu(editor: *Editor) !void {
.rect = rect,
})) {
file.editor.playing = !file.editor.playing;
if (editor.tools.radial_menu.opened_by_press) {
editor.tools.radial_menu.close();
}
}
}
}
Expand Down
90 changes: 90 additions & 0 deletions src/editor/Fling.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Reusable flick / momentum helper.
//!
//! Tracks a smoothed drag velocity (sampled once per frame during a drag), then
//! coasts with exponential decay after release. It's one-dimensional: use a single
//! instance for a 1-D scrub (the sprite cover flow) or one per axis for a 2-D pan
//! (the canvas). All values are in the caller's own units — index units, viewport
//! pixels, whatever — so only the `Tuning` thresholds need to match those units.
//!
//! Typical wiring per frame:
//! press: fling.begin()
//! drag: accumulate this frame's movement, then fling.sample(delta) once
//! release: if (!fling.release(tuning)) settleImmediately()
//! draw: while (fling.coasting) pos += fling.step(tuning).? // then check coasting

const std = @import("std");
const dvui = @import("dvui");

const Fling = @This();

/// Per-call-site feel tuning. Units match whatever the caller flings.
pub const Tuning = struct {
/// Velocity decay rate (1/s); higher stops the coast sooner.
decay: f32 = 4.0,
/// Minimum release speed needed to start coasting (units/s).
min_start: f32 = 1.2,
/// Speed at which the coast ends (units/s).
stop: f32 = 0.6,
/// Clamp on coast speed so a hard flick can't run away (units/s).
max: f32 = 50.0,
/// If the pointer was still longer than this before release (s), don't coast.
idle_s: f32 = 0.08,
};

/// True while a release coast is still running.
coasting: bool = false,
/// Current coast velocity (units/s).
vel: f32 = 0.0,
/// Smoothed velocity built up during the drag, sampled on release (units/s).
drag_vel: f32 = 0.0,
/// Frame timestamp (ns) of the last frame that had drag motion — used to detect a
/// pause before release, which should cancel the coast.
last_drag_ns: i128 = 0,

/// Begin a fresh drag: cancel any coast and clear the velocity estimate.
pub fn begin(self: *Fling) void {
self.coasting = false;
self.vel = 0.0;
self.drag_vel = 0.0;
self.last_drag_ns = dvui.frameTimeNS();
}

/// Feed the total drag movement for this frame. Call once per frame that had drag
/// motion — `frameTimeNS` is constant within a frame, so per-event sampling is moot.
pub fn sample(self: *Fling, frame_delta: f32) void {
const dt = dvui.secondsSinceLastFrame();
if (dt > 0.0 and dt < 0.1) {
const inst = frame_delta / dt;
self.drag_vel = self.drag_vel * 0.6 + inst * 0.4;
}
self.last_drag_ns = dvui.frameTimeNS();
}

/// Decide what happens on release. Starts a coast (returns true) when the pointer
/// was still moving fast enough and wasn't paused; otherwise returns false so the
/// caller can settle immediately. Always clears the drag velocity estimate.
pub fn release(self: *Fling, t: Tuning) bool {
const idle_s: f32 = @floatCast(@as(f64, @floatFromInt(dvui.frameTimeNS() - self.last_drag_ns)) / 1_000_000_000.0);
self.coasting = idle_s <= t.idle_s and @abs(self.drag_vel) > t.min_start;
if (self.coasting) self.vel = std.math.clamp(self.drag_vel, -t.max, t.max);
self.drag_vel = 0.0;
return self.coasting;
}

/// Advance the coast one frame and return the position delta to apply, or null when
/// not coasting. On the frame the coast finishes it still returns the final delta
/// but leaves `coasting` false, so check `coasting` afterward to detect the stop.
pub fn step(self: *Fling, t: Tuning) ?f32 {
if (!self.coasting) return null;
const dt = dvui.secondsSinceLastFrame();
const delta = self.vel * dt;
self.vel *= @exp(-t.decay * dt);
if (@abs(self.vel) < t.stop) self.coasting = false;
return delta;
}

/// Immediately stop any coast (e.g. input got suppressed).
pub fn cancel(self: *Fling) void {
self.coasting = false;
self.vel = 0.0;
}
18 changes: 14 additions & 4 deletions src/editor/Keybinds.zig
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,20 @@ pub fn tick() !void {
}

if (ke.matchBind("quick_tools")) {
fizzy.editor.tools.radial_menu.visible = switch (ke.action) {
.down, .repeat => true,
.up => false,
};
const rm = &fizzy.editor.tools.radial_menu;
switch (ke.action) {
.down => {
const mp = dvui.currentWindow().mouse_pt;
rm.mouse_position = mp;
rm.center = mp;
rm.opened_by_press = false;
rm.suppress_next_pointer_release = false;
rm.outside_click_press_p = null;
rm.visible = true;
},
.repeat => rm.visible = true,
.up => rm.close(),
}
// If we include a refresh here, the underlying gui has a chance to reset the cursor
dvui.refresh(null, @src(), dvui.currentWindow().data().id);
}
Expand Down
6 changes: 3 additions & 3 deletions src/editor/Settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ hold_menu_duration_ms: u32 = 500,
/// Whether or not to show rulers on each canvas.
show_rulers: bool = true,

/// Sprites panel: fly side cards away for a single-card focus view and snap
/// scroll when the focus sprite changes (also toggled from the sprites pane).
scrolling_cards: bool = false,
/// Sprites panel: when true, show side cards in the cover-flow strip; when false,
/// fly them away for single-card focus (snap scroll)
scrolling_cards: bool = true,

/// When true, print frame/draw perf stats to the console (Debug / ReleaseSafe only for tick stats).
perf_logging: bool = false,
Expand Down
26 changes: 26 additions & 0 deletions src/editor/Tools.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ pub const RadialMenu = struct {
mouse_position: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 },
center: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 },
visible: bool = false,
/// Opened by press-and-hold on empty workspace (not Space / quick-tools). Both paths pin
/// `center` at open; this flag only selects hold-specific dismiss behavior.
opened_by_press: bool = false,
/// Ignore the first pointer release after a hold-open (lifting the opening finger).
suppress_next_pointer_release: bool = false,
/// Press began outside the menu while it is hold-open; used for click-outside dismiss.
outside_click_press_p: ?dvui.Point.Physical = null,

pub fn close(self: *RadialMenu) void {
self.visible = false;
self.opened_by_press = false;
self.suppress_next_pointer_release = false;
self.outside_click_press_p = null;
}

/// Physical hit radius for the radial tool ring (matches `drawRadialMenu` outer disc).
pub fn hitRadiusPhysical() f32 {
return 165.0;
}

pub fn containsPhysical(self: RadialMenu, p: dvui.Point.Physical) bool {
const r = hitRadiusPhysical();
const dx = p.x - self.center.x;
const dy = p.y - self.center.y;
return dx * dx + dy * dy <= r * r;
}
};

pub const default_pencil_stroke_size: u8 = 1;
Expand Down
2 changes: 1 addition & 1 deletion src/editor/explorer/settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ pub fn draw() !void {
fizzy.editor.markSettingsDirty();
}

if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Scrolling sprite cards", .{
if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Show sprite cover-flow cards", .{
.expand = .none,
})) {
fizzy.editor.markSettingsDirty();
Expand Down
Loading
Loading