diff --git a/docs/architecture.md b/docs/architecture.md index 0c8d8d3..cdf7fdf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,10 +107,11 @@ the whole script. The map fits the mission without its scenery, which lies far off. Each frame it gathers the things that would overlap on the screen into one mark, the most important first: the selection, the player, the ships, the markers, the camera's points and the scenery. Names then go in the same order, each -on the first side of its mark where it covers no mark and no name, and crosses no route where a side -is free. The map draws its own grid, so that its scale is always a cell, and draws only the part of -a range's ring or a route's leg that shows. A dragged ship or marker is queued as an edit of its -place, as the forms' edits are, when it's let go. +on the first side of its mark where it covers no mark, no name and none of the notes over the map, +such as the legend and the scale. A side where the name also crosses no route comes first. The map +draws its own grid, so that its scale is always a cell, and draws only the part of a range's ring or +a route's leg that shows. A dragged ship or marker is queued as an edit of its place, as the forms' +edits are, when it's let go. ## The debugger diff --git a/docs/debugging.md b/docs/debugging.md index e299d36..f141e28 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -48,7 +48,8 @@ inspector names its thread. | Pause | F6 | F8 | Pauses the whole mission, ships and all, or lets it go on | - **Threads** lists the script's threads: where each one is, what started it, its call depth and - what it waits for. A double click goes to a thread's line. + what it waits for. Where shows a thread's line, and its offset too where the column has room. A + double click goes to a thread's line. - **Variables** shows the game's variables that the source uses and the mission's globals, with their values in the game. A value that changed since the last hold is orange. While the script runs, the editor polls the game for them, 4 times a second by default (Settings · Editor link · diff --git a/docs/editing.md b/docs/editing.md index aec82dc..7f9cd2a 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -50,8 +50,9 @@ holds, named after its most important thing. click on an empty part of the map, shows the whole mission again. - Point at a numbered mark to list what it holds, and click it to zoom in to them. Where its things lie on the same spot, the click lists them instead, to pick one. -- Names go where they have room, the selection's and the player's first, and keep off the routes - where they can. Point at a mark to see a name that has no room. +- Names go where they have room, the selection's and the player's first. They never cover the + legend, the scale or the other notes on the map, and keep off the routes where they can. Point at + a mark to see a name that has no room. - Click a ship or a marker to select it: the inspector shows it, and the map marks it with its place. Drag it to move where it starts: its `position` changes in the source when you let go, as one step of Undo. Hold Shift to snap to 100. A click on an empty part of the map clears the diff --git a/src/palette.zig b/src/palette.zig index 26e0548..eddf399 100644 --- a/src/palette.zig +++ b/src/palette.zig @@ -10,6 +10,7 @@ const App = app_mod.App; const commands = @import("commands.zig"); const Command = commands.Command; const game = @import("game.zig"); +const panels = @import("panels.zig"); const ui = @import("ui.zig"); const fonts = ui.fonts; const gui = ui.gui; @@ -282,7 +283,7 @@ fn playForm(app: *App) void { const watched = if (state.watch) |index| if (index < found_ships.len) found_ships[index] else null else null; if (widgets.frame("watch", .{ .label = if (watched) |ship| ship.title else "The player's ship", - .mark = if (watched) |ship| .{ .icon = .{ .icon = .navigation_2, .color = if (ship.group) |group| theme.groups[group % theme.groups.len] else theme.text_menu } } else .none, + .mark = if (watched) |ship| .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(ship.*) } } else .none, })) _ = c.ImGui_OpenPopup("ships", 0); if (c.ImGui_BeginPopup("ships", 0)) { if (c.ImGui_MenuItemEx("The player's ship", null, state.watch == null, true)) state.watch = null; diff --git a/src/panels.zig b/src/panels.zig index 6025c2b..c21ec54 100644 --- a/src/panels.zig +++ b/src/panels.zig @@ -13,7 +13,8 @@ const App = app_mod.App; const Panel = app_mod.Panel; const commands = @import("commands.zig"); const game = @import("game.zig"); -const Document = @import("mission/document.zig").Document; +const document_mod = @import("mission/document.zig"); +const Document = document_mod.Document; const forms = @import("forms.zig"); const scene_view = @import("views/scene.zig"); const script_view = @import("views/script.zig"); @@ -139,7 +140,7 @@ fn tabCount(app: *App, panel: Panel) ?widgets.Pill { break :blk .{ .text = gui.print("{d}", .{found}), .style = if (found > 0) .err else .normal }; }, .breakpoints => blk: { - var buffer: [@import("mission/document.zig").max_breakpoints]usize = undefined; + var buffer: [document_mod.max_breakpoints]usize = undefined; break :blk .{ .text = gui.print("{d}", .{document.breakpoints(&buffer).len}) }; }, else => null, @@ -175,10 +176,13 @@ fn padded(x: f32, y: f32) void { gui.setCursor(gui.add(gui.cursor(), .{ .x = px(x), .y = px(y) })); } -/// A line that says the panel needs something the editor doesn't have yet. +/// A line that says the panel needs something the editor doesn't have yet, wrapped at the +/// panel's edge. fn waiting(icon: Icon, text: []const u8, plan: ?commands.Plan) void { padded(12, 12); const at = gui.cursor(); + gui.wrapAt(at.x + gui.available().x - px(12)); + defer c.ImGui_PopTextWrapPos(); widgets.iconItem(icon, fonts.size.icon_small, theme.text_off); gui.setCursor(.{ .x = at.x + px(22), .y = at.y - px(1) }); widgets.caption(text, theme.text_dim, fonts.size.small, .regular); @@ -278,7 +282,7 @@ fn outline(app: *App) void { /// How the outline shows `record`. fn recordRow(document: *const Document, record: game.Record, selected: bool, failed: bool) widgets.Row { - const group_color = if (record.group) |group| theme.groups[group % theme.groups.len] else theme.text_menu; + const group_color = groupColor(record); var shown: widgets.Row = .{ .label = record.title, .indent = 52, .selected = selected, .failed = failed }; shown.mark = switch (record.section) { .flight_groups => .{ .swatch = group_color }, @@ -324,6 +328,9 @@ const Find = struct { var query: [128:0]u8 = @splat(0); }; +/// The most lines Find lists. +const most_found = 500; + fn find(app: *App) void { const document = app.document orelse return waiting(.search, "No mission open", null); padded(8, 8); @@ -342,14 +349,13 @@ fn find(app: *App) void { found += 1; gui.pushIdInt(line); defer gui.popId(); - const shown = std.mem.trim(u8, text, " \t"); const result = widgets.row("line", .{ - .label = gui.z(shown[0..@min(shown.len, 60)]), + .label = std.mem.trim(u8, text, " \t"), .mono_label = true, .end = .{ .text = .{ .text = gui.print("{d}", .{line + 1}) } }, }); if (result.clicked) app.goTo(@intCast(line), @intCast(column)); - if (found >= 500) break; + if (found >= most_found) break; } if (found == 0) { padded(10, 6); @@ -359,6 +365,11 @@ fn find(app: *App) void { // The debugging panels, which need the game linked. +/// Where a table's first column starts, and the room a cell's text leaves before what follows it, +/// in design pixels: text that would come closer is cut with an ellipsis. +const table_inset = 12; +const cell_gap = 8; + fn tableHeader(columns: []const []const u8, widths: []const f32) void { const at = gui.cursor(); const width = gui.available().x; @@ -367,7 +378,7 @@ fn tableHeader(columns: []const []const u8, widths: []const f32) void { gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.table_head, 0); gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.table_head_line, 0); gui.sans(.medium, fonts.size.ui); - var x = at.x + px(12); + var x = at.x + px(table_inset); for (columns, widths) |column, column_width| { gui.drawText(list, .{ .x = x, .y = gui.textTop(at.y + height / 2) }, theme.text_dim, column); x += px(column_width); @@ -412,7 +423,7 @@ fn threadRow(app: *App, state: State, thread: State.Thread) void { if (held) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.held_row, 0) else if (gui.hovered()) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.row_hover, 0); gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.table_line, 0); const middle = at.y + height / 2; - var x = at.x + px(12); + var x = at.x + px(table_inset); gui.mono(.regular, fonts.size.ui); gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{thread.slot})); gui.popFont(); @@ -426,37 +437,40 @@ fn threadRow(app: *App, state: State, thread: State.Thread) void { gui.popFont(); } x += px(thread_widths[1]); - // Where: its line, from the build's map, and its offset. + // Where: its line, from the build's map, and its offset after it where the column has room. + const where_limit = x + px(thread_widths[2] - cell_gap); const line = if (thread.at) |offset| session.lineOf(offset) else null; var where_x = x; if (line) |found| { gui.mono(.regular, fonts.size.ui); const text = gui.print("line {d}", .{found + 1}); - gui.drawText(list, .{ .x = where_x, .y = gui.textTop(middle) }, if (held) theme.held_text else theme.text_row, text); + widgets.clippedText(list, .{ .x = where_x, .y = gui.textTop(middle) }, where_limit, if (held) theme.held_text else theme.text_row, text); where_x += gui.textSize(text).x; gui.popFont(); } gui.sans(.regular, fonts.size.ui); const offset_text = if (thread.at) |offset| gui.print("{s}offset {d}", .{ if (line != null) " · " else "", offset }) else "outside the script"; - gui.drawText(list, .{ .x = where_x, .y = gui.textTop(middle) }, theme.text_dim, offset_text); + if (line == null or where_x + gui.textSize(offset_text).x <= where_limit) { + widgets.clippedText(list, .{ .x = where_x, .y = gui.textTop(middle) }, where_limit, theme.text_dim, offset_text); + } gui.popFont(); x += px(thread_widths[2]); gui.mono(.regular, fonts.size.ui); - gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, startedBy(app, thread, line)); + widgets.clippedText(list, .{ .x = x, .y = gui.textTop(middle) }, x + px(thread_widths[3] - cell_gap), theme.text_row, startedBy(app, thread, line)); x += px(thread_widths[3]); gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{thread.depth})); gui.popFont(); x += px(thread_widths[4]); gui.sans(.regular, fonts.size.ui); const waits_for = if (thread.interrupted) "its trigger" else if (thread.wake != 0) gui.print("clock {d} s", .{thread.wake}) else "nothing"; - gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, waits_for); + widgets.clippedText(list, .{ .x = x, .y = gui.textTop(middle) }, at.x + width - px(cell_gap), theme.text_dim, waits_for); gui.popFont(); if (double_clicked) if (line) |found| app.goTo(found, 0); } /// The design's tag of the thread the game holds: the state on the accent, 7 pixels to each side. fn stateTag(list: *c.ImDrawList, left: f32, middle: f32, text: []const u8) void { - gui.sans(.semibold, 11); + gui.sans(.semibold, fonts.size.note); defer gui.popFont(); const height = px(17); const max = gui.vec2(left + gui.textSize(text).x + px(7) * 2, middle + height / 2); @@ -469,24 +483,30 @@ fn startedBy(app: *App, thread: State.Thread, line: ?u32) []const u8 { const document = app.document orelse return ""; const known = document.outline(); if (thread.trigger) |index| { - for (known.records) |record| { - if (record.section == .triggers and record.index == index) return gui.print("on({s}, \"{s}\")", .{ record.subject, record.condition }); - } + if (known.nth(.triggers, index)) |record| return triggerName(record.*); return gui.print("trigger {d}", .{index}); } if (thread.depth == 0) if (line) |found| if (known.at(found)) |record| if (record.section == .parts) return record.title; return "a part"; } +/// A trigger as the source writes it: `on(subject, "condition")`. +fn triggerName(record: game.Record) [:0]const u8 { + return gui.print("on({s}, \"{s}\")", .{ record.subject, record.condition }); +} + +const breakpoint_columns = [_][]const u8{ "Line", "Statement", "In" }; +const breakpoint_widths = [_]f32{ 64, 330, 160 }; + fn breakpoints(app: *App) void { const document = app.document orelse return waiting(.circle_dot, "No mission open", null); - var buffer: [@import("mission/document.zig").max_breakpoints]usize = undefined; + var buffer: [document_mod.max_breakpoints]usize = undefined; const lines = document.breakpoints(&buffer); if (lines.len == 0) { waiting(.circle_dot, gui.print("No breakpoints · {s} or a click in the margin sets one", .{app.keyText(.toggle_breakpoint) orelse "F9"}), null); return; } - tableHeader(&.{ "Line", "Statement", "In" }, &.{ 64, 330, 160 }); + tableHeader(&breakpoint_columns, &breakpoint_widths); const known = document.outline(); for (lines) |line| { gui.pushIdInt(line); @@ -504,11 +524,13 @@ fn breakpoints(app: *App) void { if (app.session.mapped()) gui.dot(list, .{ .x = at.x + px(17), .y = middle }, 4.5, theme.breakpoint) else gui.ring(list, .{ .x = at.x + px(17), .y = middle }, 3.75, 1.5, theme.breakpoint); gui.mono(.regular, fonts.size.ui); gui.drawText(list, .{ .x = at.x + px(28), .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{line + 1})); - gui.drawText(list, .{ .x = at.x + px(76), .y = gui.textTop(middle) }, theme.syntax.name, statement[0..@min(statement.len, 48)]); + const statement_x = at.x + px(table_inset + breakpoint_widths[0]); + const record_x = statement_x + px(breakpoint_widths[1]); + widgets.clippedText(list, .{ .x = statement_x, .y = gui.textTop(middle) }, record_x - px(cell_gap), theme.syntax.name, statement); gui.popFont(); if (record) |found| { gui.mono(.regular, fonts.size.ui); - gui.drawText(list, .{ .x = at.x + px(406), .y = gui.textTop(middle) }, theme.text_dim, found.title); + widgets.clippedText(list, .{ .x = record_x, .y = gui.textTop(middle) }, at.x + width - px(cell_gap), theme.text_dim, found.title); gui.popFont(); } if (clicked) app.goTo(@intCast(line), 0); @@ -713,8 +735,8 @@ fn usedIn(document: ?*Document, under: []const u8, name: []const u8) bool { return false; } -/// A variable's line, as the design's `.vr`: its name in the code font, its value at the end, in -/// orange where it changed since the last hold. +/// A variable's line, as the design's `.vr`: its name in the code font, cut short of its value, +/// and its value at the end, in orange where it changed since the last hold. fn variableRow(name: []const u8, value: []const u8, color: theme.Color, changed: bool) void { const at = gui.cursor(); const width = gui.available().x; @@ -723,8 +745,9 @@ fn variableRow(name: []const u8, value: []const u8, color: theme.Color, changed: const list = gui.drawList(); gui.mono(.regular, fonts.size.ui); const y = gui.textTop(at.y + height / 2); - gui.drawText(list, .{ .x = at.x + px(34), .y = y }, color, name); - gui.drawText(list, .{ .x = at.x + width - px(12) - gui.textSize(value).x, .y = y }, if (changed) theme.accent else theme.text_row, value); + const value_x = at.x + width - px(12) - gui.textSize(value).x; + widgets.clippedText(list, .{ .x = at.x + px(34), .y = y }, value_x - px(cell_gap), color, name); + gui.drawText(list, .{ .x = value_x, .y = y }, if (changed) theme.accent else theme.text_row, value); gui.popFont(); } @@ -760,7 +783,7 @@ fn inspector(app: *App) void { gui.setCursor(.{ .x = at.x + px(44), .y = at.y }); c.ImGui_BeginGroup(); const heading = switch (record.section) { - .triggers => gui.print("on({s}, \"{s}\")", .{ record.subject, record.condition }), + .triggers => triggerName(record.*), else => gui.z(record.title), }; widgets.monoCaption(heading, theme.text_strong, 13.5, .semibold); @@ -835,8 +858,9 @@ pub fn capitalNoun(section: game.Section) []const u8 { }; } +/// The colour of `record`'s flight group, for its icon in the outline and its mark on the map. pub fn groupColor(record: game.Record) theme.Color { - return if (record.group) |group| theme.groups[group % theme.groups.len] else theme.text_menu; + return if (record.group) |group| theme.group(group) else theme.text_menu; } /// The first lines of a record's code, in a small code view with their numbers. @@ -869,8 +893,8 @@ fn codeExcerpt(document: *Document, record: game.Record, width: f32) void { // The reference: what the word at the cursor is, from the game's catalogues. fn reference(app: *App) void { - const word_name = app.reference_word orelse return waiting(.book_open, "Put the cursor on a command or a word of mission source", null); - const word = app.language.find(word_name) orelse return waiting(.book_open, "Put the cursor on a command or a word of mission source", null); + const word = (if (app.reference_word) |name| app.language.find(name) else null) orelse + return waiting(.book_open, "Put the cursor on a command or a word of mission source", null); gui.pushVar2(c.ImGuiStyleVar_WindowPadding, .{ .x = px(12), .y = px(12) }); _ = c.ImGui_BeginChild("reference", .{ .x = 0, .y = 0 }, c.ImGuiChildFlags_AlwaysUseWindowPadding, 0); gui.popVar(1); diff --git a/src/screens/settings.zig b/src/screens/settings.zig index 2a785fb..f6e3438 100644 --- a/src/screens/settings.zig +++ b/src/screens/settings.zig @@ -312,7 +312,7 @@ fn portField(app: *App) void { /// The design's `.plan`: what's planned, in a small grey pill, its middle at the cursor. fn planTag(text: []const u8) void { - gui.sans(.semibold, 11); + gui.sans(.semibold, fonts.size.note); defer gui.popFont(); const at = gui.cursor(); const width = gui.textSize(text).x + px(7) * 2; diff --git a/src/screens/start.zig b/src/screens/start.zig index d89e09f..e50935f 100644 --- a/src/screens/start.zig +++ b/src/screens/start.zig @@ -395,7 +395,7 @@ fn yourMods(app: *App, width: f32) void { /// The design's `.tag`: a small rounded label, right-aligned at `right`. fn tag(list: *c.ImDrawList, right_middle: gui.Vec2, text: []const u8, background: theme.Color, foreground: theme.Color) void { - gui.sans(.semibold, 11); + gui.sans(.semibold, fonts.size.note); defer gui.popFont(); const width = gui.spacedWidth(text, 0.33) + px(7) * 2; const height = px(20); diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig index 36c1943..c9c9526 100644 --- a/src/ui/fonts.zig +++ b/src/ui/fonts.zig @@ -23,6 +23,8 @@ pub const size = struct { pub const small: f32 = 12; /// The status bar, counts and file paths. pub const smaller: f32 = 11.5; + /// Tags, such as Held, and the notes over the map. + pub const note: f32 = 11; /// Key chips, pills, badges and section headings. pub const tiny: f32 = 10.5; /// Icons in buttons, and the larger icons beside text. diff --git a/src/ui/theme.zig b/src/ui/theme.zig index 6a8f61d..3937f34 100644 --- a/src/ui/theme.zig +++ b/src/ui/theme.zig @@ -183,6 +183,11 @@ pub const groups = [_]Color{ rgb(0x5aa9ff), rgb(0xc9cfdb), rgb(0xff7a6b), rgb(0x8fd18a), rgb(0xc4a7ff), rgb(0xe7c06b), rgb(0x63d1c0), rgb(0xf4b27a), }; +/// The colour of the flight group at `index` (`groups`). +pub fn group(index: usize) Color { + return groups[index % groups.len]; +} + /// The design's panels: 3 pixels apart and 3 pixels inside the area they fill, each in a line of 1 /// pixel with corners of 3. The editor draws the line around each dock node, so the nodes are the /// panels' insides. @@ -292,3 +297,9 @@ test lighten { try std.testing.expectEqual(rgb(0x5aa9ff), lighten(rgb(0x5aa9ff), 0)); try std.testing.expectEqual(rgb(0xffffff), lighten(rgb(0x5aa9ff), 1)); } + +test group { + // The player's wing is the first group, and the colours start again after the last. + try std.testing.expectEqual(rgb(0x5aa9ff), group(0)); + try std.testing.expectEqual(group(1), group(groups.len + 1)); +} diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig index d8fdc50..0ae2443 100644 --- a/src/ui/widgets.zig +++ b/src/ui/widgets.zig @@ -575,14 +575,56 @@ pub const Badge = struct { pub fn clippedText(list: *c.ImDrawList, at: Vec2, limit: f32, color: Color, text: []const u8) void { if (at.x + gui.textSize(text).x <= limit) return gui.drawText(list, at, color, text); const ellipsis = "…"; - const room = limit - at.x - gui.textSize(ellipsis).x; - var end = text.len; - while (end > 0 and gui.textSize(text[0..end]).x > room) { - end -= 1; - while (end > 0 and text[end] & 0xc0 == 0x80) end -= 1; + const shown = fittingStart(text, limit - at.x - gui.textSize(ellipsis).x, textWidth); + gui.drawText(list, at, color, shown); + gui.drawText(list, .{ .x = at.x + gui.textSize(shown).x, .y = at.y }, color, ellipsis); +} + +/// How wide `text` is in the current font. +fn textWidth(text: []const u8) f32 { + return gui.textSize(text).x; +} + +/// The longest start of `text` that is at most `room` wide by `width` and ends between two +/// characters. It halves the lengths the start can be, since a longer start is never narrower. +/// `text` itself is wider than `room`. +fn fittingStart(text: []const u8, room: f32, comptime width: fn ([]const u8) f32) []const u8 { + var fits: usize = 0; + var too_long = text.len; + while (too_long - fits > 1) { + const half = fits + (too_long - fits) / 2; + // The nearest end between two characters, below the half or else above it. + var end = half; + while (end > fits and isContinuation(text[end])) end -= 1; + if (end == fits) { + end = half; + while (end < too_long and isContinuation(text[end])) end += 1; + if (end == too_long) break; + } + if (width(text[0..end]) <= room) fits = end else too_long = end; } - gui.drawText(list, at, color, text[0..end]); - gui.drawText(list, .{ .x = at.x + gui.textSize(text[0..end]).x, .y = at.y }, color, ellipsis); + return text[0..fits]; +} + +/// Whether `byte` continues a character of UTF-8 rather than starting one. +fn isContinuation(byte: u8) bool { + return byte & 0xc0 == 0x80; +} + +test fittingStart { + const characters = struct { + /// A width of 1 for each character. + fn width(text: []const u8) f32 { + const count = std.unicode.utf8CountCodepoints(text) catch @panic("a start that splits a character"); + return @floatFromInt(count); + } + }.width; + try std.testing.expectEqualStrings("abc", fittingStart("abcdef", 3, characters)); + try std.testing.expectEqualStrings("", fittingStart("abcdef", 0.5, characters)); + // A cut never splits a character: "é" is two bytes. + try std.testing.expectEqualStrings("é", fittingStart("éa", 1, characters)); + try std.testing.expectEqualStrings("aéé", fittingStart("aééé", 3, characters)); + try std.testing.expectEqualStrings("aé", fittingStart("aé€é", 2.5, characters)); } /// The design's small uppercase badges, such as START and HELD, ending at `right`. Where the badge @@ -883,7 +925,7 @@ pub fn textField(id: []const u8, value: []const u8, options: TextField) ?[]const if (options.tag) |tag| { const tag_width = px(18); c.ImDrawList_AddRectFilledEx(list, at, .{ .x = at.x + tag_width, .y = max.y }, tag.color, px(3), c.ImDrawFlags_RoundCornersLeft); - gui.sans(.semibold, 11); + gui.sans(.semibold, fonts.size.note); gui.drawText(list, .{ .x = at.x + (tag_width - gui.textSize(tag.letter).x) / 2, .y = gui.textTop(middle) }, tag.text, tag.letter); gui.popFont(); left = at.x + tag_width + px(6); diff --git a/src/views/scene.zig b/src/views/scene.zig index a3e6925..fb601a7 100644 --- a/src/views/scene.zig +++ b/src/views/scene.zig @@ -125,8 +125,10 @@ const Map = struct { var pointer: ?[2]f64 = null; }; -/// The pixels around a marker's middle that the pointer takes it by. +/// The pixels around a marker's middle that the pointer takes it by, and how far past a selected +/// marker its brackets reach. const grab_radius = 11; +const bracket_reach = 5; /// The marks' sizes, in design pixels: a ship's or a marker's, the player's, a camera point's and a /// planet's (a cluster's is `drawing.cluster_size`). const marker_size = 18; @@ -143,7 +145,7 @@ const least_opened = 400; /// list of things too close together to show apart. const most_listed = 12; const list_width = 200; -/// How much lighter than its group's colour the player's name is. +/// How much lighter than its group's colour the player's name is (`playerColor`). const player_tint = 0.47; /// How much a notch of the wheel zooms, and how far in and out the map goes, in the game's /// units across. @@ -340,17 +342,33 @@ fn map(app: *App, document: *Document, scene: game.Scene) void { drawing.thing(list, mark.middle, thing.kind, if (thing.heading) |heading| projection.project(heading) else null, mark.size, color); } + // The notes over the map, which the names keep off: the legend, the scale, the axes' note, the + // player's start where the map doesn't show it, and the selection's brackets and place. + const routes_shown = Map.routes and scene.routes.len > 0; + const legend_box = drawing.legendBox(corners[0], routes_shown); + const scale_at: Vec2 = .{ .x = corners[0].x + px(drawing.note_margin), .y = corners[1].y - px(drawing.note_margin) }; + const note = projection.note(); + const note_box = drawing.axesNoteBox(corners, note); + const start: ?drawing.Pill = if (scene.player) |player| startPill(corners, shown, projection, scene.things[player], known) else null; + const chosen: ?Mark = for (gathered.marks) |mark| { + if (mark.count == 1 and selected != null and selected.?.eql(Ref.of(scene.things[mark.first]))) break mark; + } else null; + const place_note: ?drawing.PlaceNote = if (chosen) |mark| placeNoteOf(scene, projection, mark) else null; var labels: drawing.Labels = .{ .corners = corners, .lines = lines }; for (gathered.marks) |mark| labels.take(mark.box()); + for ([_]?[2]Vec2{ + legend_box, + drawing.scaleBox(scale_at, cell, scale), + note_box, + if (start) |pill| pill.box else null, + if (chosen) |mark| drawing.grown(mark.box(), px(bracket_reach)) else null, + if (place_note) |placed| placed.box else null, + }) |taken| if (taken) |box| labels.take(box); const named = nameMarks(list, &labels, scene, known, gathered.marks); for (rings) |ring| nameRing(list, &labels, ring); - for (gathered.marks) |mark| { - const thing = scene.things[mark.first]; - if (mark.count > 1 or selected == null or !selected.?.eql(Ref.of(thing))) continue; - const place = placeOf(thing); - const axes = projection.axes(); - drawing.brackets(list, mark.middle, px(mark.size) / 2 + px(5)); - drawing.placeNote(list, mark.middle, axisNames(projection), .{ place[axes[0]], place[axes[1]] }); + if (chosen) |mark| { + drawing.brackets(list, mark.middle, px(mark.size) / 2 + px(bracket_reach)); + drawing.placeNote(list, place_note.?); } if (hovered) |which| { const mark = gathered.marks[which]; @@ -371,16 +389,10 @@ fn map(app: *App, document: *Document, scene: game.Scene) void { const settled = c.ImGui_GetTime() - Map.zoomed_at > c.ImGui_GetIO().*.MouseDoubleClickTime; if (pointer != null and hovered == null and settled and c.ImGui_IsMouseDoubleClicked(0)) Map.fit = true; - drawing.legend(list, corners[0], Map.routes and scene.routes.len > 0); - drawing.scaleBar(list, .{ .x = corners[0].x + px(12), .y = corners[1].y - px(12) }, cell, scale); - gui.sans(.regular, 11); - const note = projection.note(); - gui.drawText(list, .{ .x = corners[1].x - px(12) - gui.textSize(note).x, .y = corners[1].y - px(12) - c.ImGui_GetFontSize() }, theme.text_dim, note); - gui.popFont(); - if (scene.player) |player| { - const thing = scene.things[player]; - if (known.nth(thing.section, thing.index)) |record| drawStartOffMap(list, corners, shown, projection, thing, record.*); - } + drawing.legend(list, legend_box, routes_shown); + drawing.scaleBar(list, scale_at, cell, scale); + drawing.axesNote(list, note_box, note); + if (start) |pill| drawing.pill(list, pill); Map.pointer = if (pointer) |at| plot.fromPixels(at) else null; } @@ -677,9 +689,7 @@ fn nameMarks(list: *c.ImDrawList, labels: *drawing.Labels, scene: game.Scene, kn const thing = scene.things[mark.first]; const record = known.nth(thing.section, thing.index) orelse continue; const text = if (mark.count > 1) gui.print("{s} +{d}", .{ record.title, mark.count - 1 }) else record.title; - const color = if (scene.player == mark.first) - theme.lighten(panels.groupColor(record.*), player_tint) - else switch (thing.kind) { + const color = if (scene.player == mark.first) playerColor(record.*) else switch (thing.kind) { .ship, .marker => theme.text, .camera, .scenery => theme.text_dim, }; @@ -738,7 +748,7 @@ fn drawRanges(list: *c.ImDrawList, scene: game.Scene, projection: Projection, sc /// A ring's label, inside it at its top or else at its bottom, where the ring is wide enough to /// hold it and the label covers no mark and no name. fn nameRing(list: *c.ImDrawList, labels: *drawing.Labels, ring: Ring) void { - gui.sans(.regular, 11); + gui.sans(.regular, fonts.size.note); defer gui.popFont(); const text = gui.print("{s} · {s}", .{ ring.range.label, drawing.distance(ring.range.radius) }); const size = gui.textSize(text); @@ -774,15 +784,17 @@ fn drawRoutes(list: *c.ImDrawList, scene: game.Scene, gathered: Gathered, projec .ships, .flight_groups => record.group, else => null, } else null; + // The selection's routes stand out where it's on any; otherwise every route shows alike. + const focused = isOnAnyRoute(scene.routes, selected, selected_group); const Drawn = struct { from: usize, to: usize, way: game.Way, color: Color }; var drawn: std.AutoHashMapUnmanaged(Drawn, void) = .empty; for (scene.routes) |route| { - const base = if (route.group) |group| theme.groups[group % theme.groups.len] else theme.text_dim; + const base = if (route.group) |group| theme.group(group) else theme.text_dim; for (route.legs) |leg| { const from = endOf(scene, gathered, projection, leg.from); const to = endOf(scene, gathered, projection, leg.to); if (from.mark != null and from.mark == to.mark) continue; - const focus: f32 = if (inspected == null) 0.75 else if (isOnRoute(route, leg, selected, selected_group)) 1 else 0.3; + const focus: f32 = if (!focused) 0.75 else if (isOnRoute(route, leg, selected, selected_group)) 1 else 0.3; const color = theme.fade(base, focus); const key: Drawn = .{ .from = from.key(leg.from), .to = to.key(leg.to), .way = leg.way, .color = color }; const seen = drawn.getOrPut(gui.frameAllocator(), key) catch return lines.items; @@ -794,6 +806,12 @@ fn drawRoutes(list: *c.ImDrawList, scene: game.Scene, gathered: Gathered, projec return lines.items; } +/// Whether any leg of `routes` is on the selection (`isOnRoute`). +fn isOnAnyRoute(routes: []const game.Route, selected: ?usize, selected_group: ?usize) bool { + for (routes) |route| for (route.legs) |leg| if (isOnRoute(route, leg, selected, selected_group)) return true; + return false; +} + /// Whether a leg of `route` is on the selection: the selected ship's route, its group's, or a leg /// to or from the selected marker. fn isOnRoute(route: game.Route, leg: game.Leg, selected: ?usize, selected_group: ?usize) bool { @@ -814,11 +832,12 @@ fn endOf(scene: game.Scene, gathered: Gathered, projection: Projection, thing: u return .{ .middle = plot.toPixels(projection.project(placeOf(scene.things[thing]))) }; } -/// Where the player starts, at the map's edge, where the map doesn't show it: the way to it, and -/// how far it is from the map's middle. -fn drawStartOffMap(list: *c.ImDrawList, corners: [2]Vec2, shown: plot.Rect, projection: Projection, player: game.Thing, record: game.Record) void { +/// The note at the map's edge that points the way to where the player starts, where the map +/// doesn't show it, with how far that is from the map's middle. +fn startPill(corners: [2]Vec2, shown: plot.Rect, projection: Projection, player: game.Thing, known: game.Outline) ?drawing.Pill { + const record = known.nth(player.section, player.index) orelse return null; const at = projection.project(placeOf(player)); - if (at[0] >= shown.x_min and at[0] <= shown.x_max and at[1] >= shown.y_min and at[1] <= shown.y_max) return; + if (at[0] >= shown.x_min and at[0] <= shown.x_max and at[1] >= shown.y_min and at[1] <= shown.y_max) return null; const middle: [2]f64 = .{ (shown.x_min + shown.x_max) / 2, (shown.y_min + shown.y_max) / 2 }; const across = at[0] - middle[0]; const up = at[1] - middle[1]; @@ -826,23 +845,49 @@ fn drawStartOffMap(list: *c.ImDrawList, corners: [2]Vec2, shown: plot.Rect, proj // The edge the player is beyond, by which way it is most. const ratio = (shown.x_max - shown.x_min) / (shown.y_max - shown.y_min); const sideways = @abs(across) > @abs(up) * ratio; - const icon: Icon = if (sideways) (if (across > 0) .arrow_right else .arrow_left) else (if (up > 0) .arrow_up else .arrow_down); - const color = theme.lighten(panels.groupColor(record), player_tint); - gui.sans(.regular, fonts.size.smaller); - defer gui.popFont(); const text = gui.print("{s} start · {s}", .{ record.title, drawing.distance(far) }); - const width = px(9 + 12 + 6 + 9) + gui.textSize(text).x; - const height = px(24); + const width = drawing.Pill.width(text); + const height = px(drawing.Pill.height); + const margin = px(drawing.box_margin); const centre: Vec2 = .{ .x = (corners[0].x + corners[1].x) / 2, .y = (corners[0].y + corners[1].y) / 2 }; const min: Vec2 = if (sideways) - .{ .x = if (across > 0) corners[1].x - px(10) - width else corners[0].x + px(10), .y = centre.y - height / 2 } + .{ .x = if (across > 0) corners[1].x - margin - width else corners[0].x + margin, .y = centre.y - height / 2 } else - .{ .x = centre.x - width / 2, .y = if (up > 0) corners[0].y + px(10) else corners[1].y - px(10) - height }; - const max: Vec2 = .{ .x = min.x + width, .y = min.y + height }; - gui.rect(list, min, max, theme.fade(theme.bar, 0.9), height / 2); - gui.outline(list, min, max, theme.border_off, height / 2); - widgets.drawIconCentered(list, .{ .x = min.x + px(9 + 6), .y = (min.y + max.y) / 2 }, icon, fonts.size.icon_tiny, color); - gui.drawText(list, .{ .x = min.x + px(9 + 12 + 6), .y = gui.textTop((min.y + max.y) / 2) }, color, text); + .{ .x = centre.x - width / 2, .y = if (up > 0) corners[0].y + margin else corners[1].y - margin - height }; + return .{ + .box = .{ min, .{ .x = min.x + width, .y = min.y + height } }, + .icon = if (sideways) (if (across > 0) .arrow_right else .arrow_left) else (if (up > 0) .arrow_up else .arrow_down), + .color = playerColor(record.*), + .text = text, + }; +} + +/// The colour of the player's name and of the note that points to its start: its group's, +/// lighter. +fn playerColor(record: game.Record) Color { + return theme.lighten(panels.groupColor(record), player_tint); +} + +/// The note by the selected mark: where its thing is on the map's axes. +fn placeNoteOf(scene: game.Scene, projection: Projection, mark: Mark) drawing.PlaceNote { + const place = placeOf(scene.things[mark.first]); + const axes = projection.axes(); + return .at(mark.middle, axisNames(projection), .{ place[axes[0]], place[axes[1]] }); +} + +test isOnAnyRoute { + const legs = [_]game.Leg{.{ .from = 0, .to = 1, .way = .fly }}; + const routes = [_]game.Route{ + .{ .ship = 2, .group = 0, .legs = &legs }, + .{ .ship = null, .group = 3, .legs = &legs }, + }; + // With nothing selected, or a record no route takes, the routes show alike. + try std.testing.expect(!isOnAnyRoute(&routes, null, null)); + try std.testing.expect(!isOnAnyRoute(&routes, 5, 4)); + // A route's ship, a marker a route passes, or a group with a route of its own. + try std.testing.expect(isOnAnyRoute(&routes, 2, null)); + try std.testing.expect(isOnAnyRoute(&routes, 1, null)); + try std.testing.expect(isOnAnyRoute(&routes, null, 3)); } test Projection { diff --git a/src/views/scene/drawing.zig b/src/views/scene/drawing.zig index 44ed64f..3b9556d 100644 --- a/src/views/scene/drawing.zig +++ b/src/views/scene/drawing.zig @@ -11,7 +11,9 @@ const fonts = ui.fonts; const gui = ui.gui; const plot = ui.plot; const theme = ui.theme; +const widgets = ui.widgets; const Color = theme.Color; +const Icon = ui.icons.Icon; const Vec2 = gui.Vec2; const px = gui.px; @@ -26,6 +28,10 @@ const cluster_figures = fonts.size.tiny; const cluster_padding = 4; /// How long a leg to or from a place off the map is drawn, in pixels. const stub_length = 56; +/// How far the notes over the map are from its edges, in design pixels: a note in a box, such as +/// the legend, and one without, such as the scale. +pub const box_margin = 10; +pub const note_margin = 12; pub fn distanceTo(a: Vec2, b: Vec2) f32 { return @sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); @@ -281,28 +287,45 @@ pub fn brackets(list: *c.ImDrawList, middle: Vec2, half: f32) void { } } -/// The note by the selected thing: where it is on the map's axes, `names` across and up, and how -/// to move it. -pub fn placeNote(list: *c.ImDrawList, middle: Vec2, names: [2][]const u8, values: [2]f64) void { - const where = gui.print("{s} {d} · {s} {d}", .{ names[0], @round(values[0]), names[1], @round(values[1]) }); +/// The note by the selected thing: where it is on the map's axes, and how to move it. +pub const PlaceNote = struct { + box: [2]Vec2, + where: []const u8, + const how = "Drag · Shift: snap 100"; - gui.mono(.regular, fonts.size.smaller); - const where_width = gui.textSize(where).x; - gui.popFont(); - gui.sans(.regular, fonts.size.smaller); - const how_width = gui.textSize(how).x; - gui.popFont(); - const width = @max(where_width, how_width) + px(18); - const height = px(42); - const min: Vec2 = .{ .x = middle.x - px(14) - width, .y = middle.y + px(14) }; - const max: Vec2 = .{ .x = min.x + width, .y = min.y + height }; + /// In design pixels: how far its corner is from the mark's middle, across and down, its + /// height, its padding each side, and the middles of its two lines, down from its top. + const offset = 14; + const height = 42; + const inset = 9; + const lines = [2]f32{ 13, 29 }; + + /// The note by the mark at `middle`, at `values` on the axes `names` across and up. + pub fn at(middle: Vec2, names: [2][]const u8, values: [2]f64) PlaceNote { + const where = gui.print("{s} {d} · {s} {d}", .{ names[0], @round(values[0]), names[1], @round(values[1]) }); + gui.mono(.regular, fonts.size.smaller); + const where_width = gui.textSize(where).x; + gui.popFont(); + gui.sans(.regular, fonts.size.smaller); + const how_width = gui.textSize(how).x; + gui.popFont(); + const width = @max(where_width, how_width) + px(2 * inset); + const min: Vec2 = .{ .x = middle.x - px(offset) - width, .y = middle.y + px(offset) }; + return .{ .box = .{ min, .{ .x = min.x + width, .y = min.y + px(height) } }, .where = where }; + } +}; + +/// Draws `note` (`PlaceNote.at`). +pub fn placeNote(list: *c.ImDrawList, note: PlaceNote) void { + const min, const max = note.box; gui.rect(list, min, max, theme.fade(theme.panel, 0.94), 4); gui.outline(list, min, max, theme.border_strong, 4); + const left = min.x + px(PlaceNote.inset); gui.mono(.regular, fonts.size.smaller); - gui.drawText(list, .{ .x = min.x + px(9), .y = gui.textTop(min.y + px(13)) }, theme.text_strong, where); + gui.drawText(list, .{ .x = left, .y = gui.textTop(min.y + px(PlaceNote.lines[0])) }, theme.text_strong, note.where); gui.popFont(); gui.sans(.regular, fonts.size.smaller); - gui.drawText(list, .{ .x = min.x + px(9), .y = gui.textTop(min.y + px(29)) }, theme.text_muted, how); + gui.drawText(list, .{ .x = left, .y = gui.textTop(min.y + px(PlaceNote.lines[1])) }, theme.text_muted, PlaceNote.how); gui.popFont(); } @@ -368,46 +391,138 @@ pub fn leg(list: *c.ImDrawList, from: End, to: End, look: LegLook, corners: [2]V return .{ along(start, way, shown[0]), along(start, way, shown[1]) }; } -/// What the map's marks and lines are, in its top left corner: where the ships start, and while the -/// routes show, the legs ships fly and those they jump. -pub fn legend(list: *c.ImDrawList, corner: Vec2, routes: bool) void { - const rows = [_][]const u8{ "Start", "Flies", "Jumps" }; - const shown: usize = if (routes) rows.len else 1; +/// The legend's rows: where the ships start, and while the routes show, the legs ships fly and those +/// they jump. +const legend_rows = [_][]const u8{ "Start", "Flies", "Jumps" }; +/// The legend, in design pixels: the room for a row's sample, the gap between it and the row's +/// text, its rows' height, and its padding across and down. +const legend_sample = 18; +const legend_gap = 7; +const legend_row = 20; +const legend_padding: Vec2 = .{ .x = 10, .y = 5 }; + +/// Where the legend goes, in the map's top left corner `corner`. +pub fn legendBox(corner: Vec2, routes: bool) [2]Vec2 { + const rows = legendRows(routes); gui.sans(.regular, fonts.size.smaller); defer gui.popFont(); var widest: f32 = 0; - for (rows[0..shown]) |text| widest = @max(widest, gui.textSize(text).x); - const sample = px(18); - const row = px(20); - const min: Vec2 = .{ .x = corner.x + px(10), .y = corner.y + px(10) }; - const max: Vec2 = .{ .x = min.x + px(10 + 7 + 10) + sample + widest, .y = min.y + px(10) + row * @as(f32, @floatFromInt(shown)) }; + for (rows) |text| widest = @max(widest, gui.textSize(text).x); + const min: Vec2 = .{ .x = corner.x + px(box_margin), .y = corner.y + px(box_margin) }; + const width = px(2 * legend_padding.x + legend_sample + legend_gap) + widest; + const height = px(2 * legend_padding.y + legend_row * @as(f32, @floatFromInt(rows.len))); + return .{ min, .{ .x = min.x + width, .y = min.y + height } }; +} + +fn legendRows(routes: bool) []const []const u8 { + return legend_rows[0..if (routes) legend_rows.len else 1]; +} + +/// What the map's marks and lines are, in `box` (`legendBox`). +pub fn legend(list: *c.ImDrawList, box: [2]Vec2, routes: bool) void { + const min, const max = box; gui.rect(list, min, max, theme.fade(theme.bar, 0.9), 4); gui.outline(list, min, max, theme.border_off, 4); - for (rows[0..shown], 0..) |text, index| { - const middle = min.y + px(5) + row * (@as(f32, @floatFromInt(index)) + 0.5); - const left = min.x + px(10); + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + const sample = px(legend_sample); + const left = min.x + px(legend_padding.x); + for (legendRows(routes), 0..) |text, index| { + const middle = min.y + px(legend_padding.y) + px(legend_row) * (@as(f32, @floatFromInt(index)) + 0.5); switch (index) { 0 => thing(list, .{ .x = left + sample / 2, .y = middle }, .ship, null, fonts.size.icon_tiny, theme.text_menu), else => _ = leg(list, .{ .middle = .{ .x = left, .y = middle } }, .{ .middle = .{ .x = left + sample + px(4), .y = middle } }, .{ .way = if (index == 1) .fly else .jump, .starts = false, .color = theme.text_menu, - }, .{ min, max }), + }, box), } - gui.drawText(list, .{ .x = left + sample + px(7), .y = gui.textTop(middle) }, theme.text_menu, text); + gui.drawText(list, .{ .x = left + sample + px(legend_gap), .y = gui.textTop(middle) }, theme.text_menu, text); } } +/// The scale's ticks, at its ends, and the gap between them and its length written above, in design +/// pixels. +const scale_tick = 6; +const scale_gap = 3; + +/// Where the scale goes, its left end at `corner` (`scaleBar`): its bar, and its length written +/// above it. +pub fn scaleBox(corner: Vec2, cell: f64, scale: f32) [2]Vec2 { + gui.sans(.regular, fonts.size.note); + defer gui.popFont(); + const length = @as(f32, @floatCast(cell)) * scale; + const top = corner.y - px(scale_tick + scale_gap) - c.ImGui_GetFontSize(); + return .{ .{ .x = corner.x, .y = top }, .{ .x = corner.x + @max(length, gui.textSize(distance(cell)).x), .y = corner.y } }; +} + /// The scale, a cell of the grid long, its left end at `corner`, at `scale` pixels to the game's /// unit. pub fn scaleBar(list: *c.ImDrawList, corner: Vec2, cell: f64, scale: f32) void { const length = @as(f32, @floatCast(cell)) * scale; const bottom = corner.y; - var points = [_]Vec2{ .{ .x = corner.x, .y = bottom - px(6) }, .{ .x = corner.x, .y = bottom }, .{ .x = corner.x + length, .y = bottom }, .{ .x = corner.x + length, .y = bottom - px(6) } }; + const tick = px(scale_tick); + var points = [_]Vec2{ .{ .x = corner.x, .y = bottom - tick }, .{ .x = corner.x, .y = bottom }, .{ .x = corner.x + length, .y = bottom }, .{ .x = corner.x + length, .y = bottom - tick } }; c.ImDrawList_AddPolyline(list, &points, points.len, theme.text_muted, px(1), 0); - gui.sans(.regular, 11); + const box = scaleBox(corner, cell, scale); + gui.sans(.regular, fonts.size.note); defer gui.popFont(); - gui.drawText(list, .{ .x = corner.x, .y = bottom - px(9) - c.ImGui_GetFontSize() }, theme.text_muted, distance(cell)); + gui.drawText(list, box[0], theme.text_muted, distance(cell)); +} + +/// Where the note that says which way the axes point goes, in the map's bottom right corner. +pub fn axesNoteBox(corners: [2]Vec2, text: []const u8) [2]Vec2 { + gui.sans(.regular, fonts.size.note); + defer gui.popFont(); + const size = gui.textSize(text); + const max: Vec2 = .{ .x = corners[1].x - px(note_margin), .y = corners[1].y - px(note_margin) }; + return .{ .{ .x = max.x - size.x, .y = max.y - c.ImGui_GetFontSize() }, max }; +} + +/// The note that says which way the axes point, `text`, in `box` (`axesNoteBox`). +pub fn axesNote(list: *c.ImDrawList, box: [2]Vec2, text: []const u8) void { + gui.sans(.regular, fonts.size.note); + defer gui.popFont(); + gui.drawText(list, box[0], theme.text_dim, text); +} + +/// A note at the map's edge that points the way to something the map doesn't show, such as where +/// the player starts. +pub const Pill = struct { + box: [2]Vec2, + icon: Icon, + color: Color, + text: []const u8, + + /// In design pixels: its height, then along it, its padding at each end, the icon's room, and + /// the gap between the icon and the text. + pub const height = 24; + const inset = 9; + const icon_room = 12; + const gap = 6; + + /// How wide a pill with `text` is. + pub fn width(text: []const u8) f32 { + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + return px(inset + icon_room + gap + inset) + gui.textSize(text).x; + } +}; + +/// Draws `shown` (`Pill`). +pub fn pill(list: *c.ImDrawList, shown: Pill) void { + const min, const max = shown.box; + gui.rect(list, min, max, theme.fade(theme.bar, 0.9), Pill.height / 2); + gui.outline(list, min, max, theme.border_off, Pill.height / 2); + widgets.drawIconCentered(list, .{ .x = min.x + px(Pill.inset + Pill.icon_room / 2), .y = (min.y + max.y) / 2 }, shown.icon, fonts.size.icon_tiny, shown.color); + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + gui.drawText(list, .{ .x = min.x + px(Pill.inset + Pill.icon_room + Pill.gap), .y = gui.textTop((min.y + max.y) / 2) }, shown.color, shown.text); +} + +/// The box `box`, `by` pixels larger each way. +pub fn grown(box: [2]Vec2, by: f32) [2]Vec2 { + return .{ .{ .x = box[0].x - by, .y = box[0].y - by }, .{ .x = box[1].x + by, .y = box[1].y + by } }; } /// A distance as the map writes it: `4k` for 4000, `1.5k`, `2.5M` for 2,500,000, or `500`. @@ -464,6 +579,11 @@ test Labels { try std.testing.expectEqual(null, labels.place(&.{ right, left }, size)); } +test grown { + const box: [2]Vec2 = .{ .{ .x = 10, .y = 20 }, .{ .x = 30, .y = 40 } }; + try std.testing.expectEqual([2]Vec2{ .{ .x = 5, .y = 15 }, .{ .x = 35, .y = 45 } }, grown(box, 5)); +} + test reachOf { const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 300, .y = 400 } }; // From inside the map, the nearest is its own place and the farthest the far corner.