From a84a9129a363340fa3119792bf2abb073f3a1df5 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Thu, 15 Oct 2026 22:53:48 +0200 Subject: [PATCH] fix: the map's toolbar fits narrow windows In a window narrower than the default, Follow's label and list covered the map toolbar's chips. The toolbar now leaves things out in turn as it runs out of room: the chips' labels, whose names then show when you point at a chip, Follow's label, the planned Live and Orders chips, room in Follow's list, and then Follow. At the default width it looks as before. --- docs/editing.md | 2 + src/ui/widgets.zig | 56 ++++++++++++++----- src/views/scene.zig | 130 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 148 insertions(+), 40 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index 7f9cd2a..2ffc6b8 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -63,6 +63,8 @@ holds, named after its most important thing. - **Routes** shows where the script sends each ship and flight group (see below). - **Follow** keeps a ship in the middle of the map. - Where the map doesn't show the player's start, a note at its edge points to it. +- In a narrow window, the toolbar's chips show only their icons: point at one for its name. Where + that's not enough, Follow's label goes, then Live and Orders, and then Follow. **Live** and **Orders** show where the ships are and what they do in the running game, and come with the live scene ([#21](https://github.com/OpenReliant/openreliant-editor/issues/21)). diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig index 0ae2443..f81a98e 100644 --- a/src/ui/widgets.zig +++ b/src/ui/widgets.zig @@ -768,6 +768,8 @@ pub const Frame = struct { disabled: ?Disabled = null, /// Its width; by default the space left on the line. width: ?f32 = null, + /// What it's for, where no label beside it says so. + tooltip: ?[]const u8 = null, }; /// The design's `.frame`: a field that opens a list, as the inspector's. @@ -794,7 +796,7 @@ pub fn frame(id: []const u8, shown: Frame) bool { gui.popFont(); }; if (shown.chevron) drawIcon(list, .{ .x = max.x - px(8) - px(12), .y = middle }, .chevron_down, fonts.size.icon_tiny, theme.text_dim); - if (shown.disabled) |off| gui.tooltip(off.reason); + if (shown.disabled) |off| gui.tooltip(off.reason) else if (shown.tooltip) |tip| gui.tooltip(tip); return clicked; } @@ -1033,26 +1035,54 @@ pub fn choice(id: []const u8, options: []const []const u8, current: usize, look: } /// The design's `.chip` that turns something on and off, such as the map's Ranges: an icon and a -/// label, lit while on. True when clicked. -pub fn toggleChip(id: []const u8, label: []const u8, icon: Icon, on: bool, disabled: ?Disabled) bool { - gui.sans(.regular, fonts.size.small); - defer gui.popFont(); +/// label, lit while on. +pub const ToggleChip = struct { + label: []const u8, + icon: Icon, + on: bool = false, + disabled: ?Disabled = null, + /// The icon alone, with the label in its tooltip, for a bar without room for the label. + icon_only: bool = false, + + /// In design pixels: the padding at each end, the gap between the icon and the label, and the + /// width of a chip with its icon alone. + const padding = 9; + const gap = 6; const icon_size = fonts.size.icon_small; - const width = px(9) + px(icon_size) + px(6) + gui.textSize(label).x + px(9); + pub const icon_only_width = padding * 2 + icon_size; + + pub fn width(shown: ToggleChip) f32 { + if (shown.icon_only) return px(icon_only_width); + gui.sans(.regular, fonts.size.small); + defer gui.popFont(); + return px(padding * 2 + icon_size + gap) + gui.textSize(shown.label).x; + } +}; + +/// Draws `shown` at the cursor. True when clicked. +pub fn toggleChip(id: []const u8, shown: ToggleChip) bool { + const width = shown.width(); const height = px(24); const at = gui.cursor(); - const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = width, .y = height }, 0) and disabled == null; - if (disabled == null) gui.handCursor(); + const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = width, .y = height }, 0) and shown.disabled == null; + if (shown.disabled == null) gui.handCursor(); const list = gui.drawList(); const max = gui.vec2(at.x + width, at.y + height); - const lit = on and disabled == null; + const lit = shown.on and shown.disabled == null; gui.rect(list, at, max, if (lit) theme.pill else theme.field, 4); gui.outline(list, at, max, if (lit) theme.border_strong else theme.border, 4); - const color = if (disabled != null) theme.text_off else if (lit) theme.text_strong else theme.text_muted; + const color = if (shown.disabled != null) theme.text_off else if (lit) theme.text_strong else theme.text_muted; const middle = at.y + height / 2; - drawIcon(list, .{ .x = at.x + px(9), .y = middle }, icon, icon_size, color); - gui.drawText(list, .{ .x = at.x + px(9) + px(icon_size) + px(6), .y = gui.textTop(middle) }, color, label); - if (disabled) |off| gui.tooltip(off.reason); + if (shown.icon_only) { + drawIconCentered(list, .{ .x = at.x + width / 2, .y = middle }, shown.icon, ToggleChip.icon_size, color); + gui.tooltip(if (shown.disabled) |off| gui.print("{s} · {s}", .{ shown.label, off.reason }) else shown.label); + return clicked; + } + drawIcon(list, .{ .x = at.x + px(ToggleChip.padding), .y = middle }, shown.icon, ToggleChip.icon_size, color); + gui.sans(.regular, fonts.size.small); + defer gui.popFont(); + gui.drawText(list, .{ .x = at.x + px(ToggleChip.padding + ToggleChip.icon_size + ToggleChip.gap), .y = gui.textTop(middle) }, color, shown.label); + if (shown.disabled) |off| gui.tooltip(off.reason); return clicked; } diff --git a/src/views/scene.zig b/src/views/scene.zig index fb601a7..d77ffd4 100644 --- a/src/views/scene.zig +++ b/src/views/scene.zig @@ -154,6 +154,12 @@ const least_across = 200; const most_across = 1e9; /// The places a drag with Shift held snaps to, in the game's units. const snap = 100; +/// How wide Follow's list is, at most and at least, the gap between the toolbar's chips, and the +/// least gap between them and Follow, in design pixels. +const most_follow_width = 120; +const least_follow_width = 64; +const chip_gap = 6; +const follow_gap = 12; pub fn draw(app: *App) void { const document = app.document orelse return; @@ -192,7 +198,8 @@ fn lower(text: []const u8) []const u8 { return std.ascii.lowerString(gui.frameAllocator().alloc(u8, text.len) catch return text, text); } -/// The map's toolbar: the projection, what it shows, the record it follows, and Fit. +/// The map's toolbar: the projection, what it shows, the record it follows, and Fit, as room +/// allows (`ToolbarFit`). The chips that don't fit even without Follow are left out. fn toolbar(document: *Document, scene: game.Scene) void { const at = gui.cursor(); const width = gui.available().x; @@ -215,39 +222,90 @@ fn toolbar(document: *Document, scene: game.Scene) void { gui.rect(list, .{ .x = x, .y = middle - px(9) }, .{ .x = x + px(1), .y = middle + px(9) }, theme.border, 0); x += px(9); const live_planned: widgets.Disabled = .{ .reason = gui.print("Planned · {f}", .{commands.plans.live_scene}) }; - const chips = [_]struct { []const u8, []const u8, Icon, ?*bool }{ - .{ "live", "Live", .radio, null }, - .{ "ranges", "Ranges", .circle_dashed, &Map.ranges }, - .{ "routes", "Routes", .route, &Map.routes }, - .{ "orders", "Orders", .arrow_up_right, null }, - .{ "curves", "Curves", .spline, &Map.curves }, + const chips = [_]struct { []const u8, widgets.ToggleChip, ?*bool }{ + .{ "live", .{ .label = "Live", .icon = .radio, .disabled = live_planned }, null }, + .{ "ranges", .{ .label = "Ranges", .icon = .circle_dashed, .on = Map.ranges }, &Map.ranges }, + .{ "routes", .{ .label = "Routes", .icon = .route, .on = Map.routes }, &Map.routes }, + .{ "orders", .{ .label = "Orders", .icon = .arrow_up_right, .disabled = live_planned }, null }, + .{ "curves", .{ .label = "Curves", .icon = .spline, .on = Map.curves }, &Map.curves }, }; + + // At the end: Fit, and before it Follow's list and its label. + const fit_width = px(30); + const fit_left = at.x + width - px(10) - fit_width; + const follow_end = fit_left - px(6); + const follow_label = "Follow"; + gui.sans(.regular, fonts.size.small); + const label_width = gui.textSize(follow_label).x + px(6); + gui.popFont(); + // The chips' widths with their labels and with their icons alone, and the planned chips' share. + var labelled = px(chip_gap * (chips.len - 1)); + var icons = labelled; + var planned: f32 = 0; for (chips) |chip| { + labelled += chip[1].width(); + icons += px(widgets.ToggleChip.icon_only_width); + if (chip[1].disabled != null) planned += px(widgets.ToggleChip.icon_only_width + chip_gap); + } + const fit: ToolbarFit = .of(follow_end - x - px(follow_gap), labelled, icons, planned, label_width); + const follow_left = if (fit.follow_width) |follow| follow_end - follow else fit_left; + const chips_end = follow_left - (if (fit.follow_label) label_width else 0) - px(follow_gap); + + for (chips) |chip| { + var shown = chip[1]; + shown.icon_only = !fit.chip_labels; + if (shown.disabled != null and !fit.planned_chips) continue; + if (x + shown.width() > chips_end) break; gui.setCursor(.{ .x = x, .y = middle - px(12) }); - if (widgets.toggleChip(chip[0], chip[1], chip[2], if (chip[3]) |on| on.* else false, if (chip[3] == null) live_planned else null)) { - if (chip[3]) |on| on.* = !on.*; + if (widgets.toggleChip(chip[0], shown)) { + if (chip[2]) |on| on.* = !on.*; } - x = gui.lastItemRight() + px(6); + x = gui.lastItemRight() + px(chip_gap); } - - // At the end: Follow, and Fit. - const fit_width = px(30); - const follow_width = px(120); - var right = at.x + width - px(10) - fit_width; - gui.setCursor(.{ .x = right, .y = middle - px(13) }); + gui.setCursor(.{ .x = fit_left, .y = middle - px(13) }); if (widgets.toolButton("fit", .{ .icon = .scan, .icon_only = true, .height = 26, .tooltip = "Zoom to fit the mission" })) Map.fit = true; - right -= px(6) + follow_width; - gui.setCursor(.{ .x = right, .y = middle - px(13) }); - followPick(document, scene, follow_width); - gui.sans(.regular, fonts.size.small); - const follow_label = "Follow"; - gui.drawText(list, .{ .x = right - px(6) - gui.textSize(follow_label).x, .y = gui.textTop(middle) }, theme.text_dim, follow_label); - gui.popFont(); + if (fit.follow_width) |follow| { + gui.setCursor(.{ .x = follow_left, .y = middle - px(13) }); + followPick(document, scene, follow, if (fit.follow_label) null else follow_label); + } + if (fit.follow_label) { + gui.sans(.regular, fonts.size.small); + gui.drawText(list, .{ .x = follow_left - label_width, .y = gui.textTop(middle) }, theme.text_dim, follow_label); + gui.popFont(); + } gui.setCursor(.{ .x = at.x, .y = at.y + height }); } -/// Follow's list: none, or a ship, which the map then keeps in its middle. -fn followPick(document: *Document, scene: game.Scene, width: f32) void { +/// What the map's toolbar shows in `room` pixels between the projection's switch and Fit. Where it +/// has no room for everything, it leaves out, in turn: the chips' labels, Follow's label, the chips +/// of what's planned, room in Follow's list down to its least width, and then Follow. +const ToolbarFit = struct { + chip_labels: bool = true, + planned_chips: bool = true, + follow_label: bool = true, + /// Follow's list's width, or null where Follow is left out. + follow_width: ?f32, + + /// The fit for chips `labelled` wide with their labels and `icons` wide with their icons + /// alone, of which the planned chips take `planned`, and Follow's label `label` wide. + fn of(room: f32, labelled: f32, icons: f32, planned: f32, label: f32) ToolbarFit { + const most = px(most_follow_width); + if (labelled + label + most <= room) return .{ .follow_width = most }; + if (icons + label + most <= room) return .{ .chip_labels = false, .follow_width = most }; + if (icons + most <= room) return .{ .chip_labels = false, .follow_label = false, .follow_width = most }; + const unplanned = icons - planned; + return .{ + .chip_labels = false, + .planned_chips = false, + .follow_label = false, + .follow_width = if (unplanned + px(least_follow_width) <= room) @min(room - unplanned, most) else null, + }; + } +}; + +/// Follow's list: none, or a ship, which the map then keeps in its middle. `tooltip` names it +/// where its label is left out. +fn followPick(document: *Document, scene: game.Scene, width: f32, tooltip: ?[]const u8) void { const known = document.outline(); const allocator = gui.frameAllocator(); var choices: std.ArrayList(widgets.Choice) = .empty; @@ -265,9 +323,9 @@ fn followPick(document: *Document, scene: game.Scene, width: f32) void { }; } const frame: widgets.Frame = if (current) |record| - .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } }, .width = width } + .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } }, .width = width, .tooltip = tooltip } else - .{ .label = "none", .dim_label = true, .width = width }; + .{ .label = "none", .dim_label = true, .width = width, .tooltip = tooltip }; if (widgets.pick("follow", frame, choices.items)) |index| Map.follow = refs.items[index]; } @@ -875,6 +933,24 @@ fn placeNoteOf(scene: game.Scene, projection: Projection, mark: Mark) drawing.Pl return .at(mark.middle, axisNames(projection), .{ place[axes[0]], place[axes[1]] }); } +test ToolbarFit { + // Chips 400 wide with their labels and 184 with their icons alone, 76 of it the planned chips', + // and Follow's label 46 wide. + const fit = struct { + fn of(room: f32) ToolbarFit { + return .of(room, 400, 184, 76, 46); + } + }.of; + try std.testing.expectEqual(ToolbarFit{ .follow_width = 120 }, fit(600)); + try std.testing.expectEqual(ToolbarFit{ .chip_labels = false, .follow_width = 120 }, fit(500)); + try std.testing.expectEqual(ToolbarFit{ .chip_labels = false, .follow_label = false, .follow_width = 120 }, fit(320)); + // Then the planned chips go, and Follow's list narrows down to its least width. + try std.testing.expectEqual(ToolbarFit{ .chip_labels = false, .planned_chips = false, .follow_label = false, .follow_width = 120 }, fit(250)); + try std.testing.expectEqual(ToolbarFit{ .chip_labels = false, .planned_chips = false, .follow_label = false, .follow_width = 92 }, fit(200)); + // Below that, Follow goes. + try std.testing.expectEqual(ToolbarFit{ .chip_labels = false, .planned_chips = false, .follow_label = false, .follow_width = null }, fit(150)); +} + test isOnAnyRoute { const legs = [_]game.Leg{.{ .from = 0, .to = 1, .way = .fly }}; const routes = [_]game.Route{