diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5ab3a43..48f62c9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,10 +68,11 @@ Write idiomatic Zig 0.17, as OpenReliant's guide describes: name every number, g
`enum` and switch over it exhaustively, use tagged unions for variants and optionals for "none",
check invariants with `comptime`, and share code rather than copy it.
-C and C++ come in through two shims and one header: the text editor's
-([`src/ui/text_editor`](src/ui/text_editor)), Dear ImGui's internal calls
-([`src/ui/imgui_extras`](src/ui/imgui_extras)), and [`src/platform/c.h`](src/platform/c.h), which
-the build translates into the `c` module with SDL3 and Dear Bindings' C API for Dear ImGui.
+C and C++ come in through three shims and one header: the text editor's
+([`src/ui/text_editor`](src/ui/text_editor)), ImPlot's for the map ([`src/ui/plot`](src/ui/plot)),
+Dear ImGui's internal calls ([`src/ui/imgui_extras`](src/ui/imgui_extras)), and
+[`src/platform/c.h`](src/platform/c.h), which the build translates into the `c` module with SDL3
+and Dear Bindings' C API for Dear ImGui.
Add `test` blocks next to the code in the same change, with inputs built by hand so that they run
without the game's files. `--check-missions` opens each of a game's own missions as source and
diff --git a/README.md b/README.md
index 19a217f..15b64cd 100644
--- a/README.md
+++ b/README.md
@@ -5,14 +5,15 @@ A mission editor, script debugger and mod editor for
StarLancer's engine. It links to the running game, so that a change to a mission or a mod shows in
the game as it's made.
-**Status:** Mission mode's Script view works. It opens a mod's missions, a mission file or one of
-the game's own missions as source, colours it with the game's commands, checks it as it's typed,
-offers the game's commands and conditions as you type, explains them on hover and in the Reference
-panel, builds the mission, and plays it in OpenReliant, from its start or from a part. The inspector
-edits the records as forms, and the outline adds new ones ([Editing records](docs/editing.md)).
-Linked to the game, it holds the mission's script at breakpoints, steps through it, and shows its
-threads and values ([Debugging a mission](docs/debugging.md)). The rest of the [design](design/README.md) is in
-place and greyed out, each part with the issue that plans it.
+**Status:** Mission mode's Script and Scene views work. The Script view opens a mod's missions, a
+mission file or one of the game's own missions as source, colours it with the game's commands,
+checks it as it's typed, offers the game's commands and conditions as you type, explains them on
+hover and in the Reference panel, builds the mission, and plays it in OpenReliant, from its start or
+from a part. The inspector edits the records as forms, the outline adds new ones, and the Scene
+view's map shows the ships and markers and moves them ([Editing records](docs/editing.md)). Linked
+to the game, it holds the mission's script at breakpoints, steps through it, and shows its threads
+and values ([Debugging a mission](docs/debugging.md)). The rest of the [design](design/README.md) is
+in place and greyed out, each part with the issue that plans it.
The plan is OpenReliant's [#1126](https://github.com/OpenReliant/openreliant/issues/1126).

diff --git a/build.zig b/build.zig
index 4946127..129279a 100644
--- a/build.zig
+++ b/build.zig
@@ -159,12 +159,14 @@ fn addWindowsResources(b: *std.Build, exe: *std.Build.Step.Compile, target: std.
}
/// Dear ImGui with its docking branch, Dear Bindings' C API over it, its SDL3 and SDL_GPU backends,
-/// and the text editor with its C shim, as one static library. It is built optimized whatever the
-/// editor's own mode, so that typing in a long mission stays smooth in a debug build too.
+/// and the text editor and ImPlot with their C shims, as one static library. It is built optimized
+/// whatever the editor's own mode, so that typing in a long mission stays smooth in a debug build
+/// too.
fn interfaceLibrary(b: *std.Build, target: std.Build.ResolvedTarget, sdl_library: *std.Build.Step.Compile) *std.Build.Step.Compile {
const imgui = b.dependency("imgui", .{});
const bindings = b.dependency("dear_bindings", .{});
const text_editor = b.dependency("text_editor", .{});
+ const implot = b.dependency("implot", .{});
const library = b.addLibrary(.{
.name = "interface",
.linkage = .static,
@@ -190,17 +192,20 @@ fn interfaceLibrary(b: *std.Build, target: std.Build.ResolvedTarget, sdl_library
library.root_module.addCSourceFile(.{ .file = text_editor.path("TextEditor.cpp"), .flags = flags });
library.root_module.addCSourceFile(.{ .file = b.path("src/ui/text_editor/shim.cpp"), .flags = flags });
library.root_module.addCSourceFile(.{ .file = b.path("src/ui/imgui_extras/extras.cpp"), .flags = flags });
+ library.root_module.addCSourceFiles(.{ .root = implot.path("."), .files = &.{ "implot.cpp", "implot_items.cpp" }, .flags = flags });
+ library.root_module.addCSourceFile(.{ .file = b.path("src/ui/plot/plot_shim.cpp"), .flags = flags });
// The libraries' licences go with the program.
for ([_]struct { std.Build.LazyPath, []const u8 }{
.{ imgui.path("LICENSE.txt"), "LICENSE-imgui.txt" },
.{ bindings.path("LICENSE.txt"), "LICENSE-dear-bindings.txt" },
.{ text_editor.path("LICENSE"), "LICENSE-text-editor.txt" },
+ .{ implot.path("LICENSE"), "LICENSE-implot.txt" },
}) |notice| b.getInstallStep().dependOn(&b.addInstallFile(notice[0], notice[1]).step);
return library;
}
/// The folders the interface's headers are in.
-fn interfaceIncludes(b: *std.Build) [7]std.Build.LazyPath {
+fn interfaceIncludes(b: *std.Build) [9]std.Build.LazyPath {
const imgui = b.dependency("imgui", .{});
const bindings = b.dependency("dear_bindings", .{});
return .{
@@ -211,6 +216,8 @@ fn interfaceIncludes(b: *std.Build) [7]std.Build.LazyPath {
b.dependency("text_editor", .{}).path("."),
b.path("src/ui/text_editor"),
b.path("src/ui/imgui_extras"),
+ b.dependency("implot", .{}).path("."),
+ b.path("src/ui/plot"),
};
}
diff --git a/build.zig.zon b/build.zig.zon
index 1f42ec2..62bd639 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -44,6 +44,10 @@
.url = "git+https://codeberg.org/ziglang/rc?ref=zig-0.17.x#a8a3b3cebbe6e414fa12d6ad028c56a3a349c75e",
.hash = "rc-0.0.0-rMftL71EAAAxhe4swpM5rrfcmC9k_j7ApwJJPRssBiDL",
},
+ .implot = .{
+ .url = "git+https://github.com/epezent/implot.git?ref=v1.0#524f9fcd48d76c13fdf94c5ffbba8787a1ff7e39",
+ .hash = "N-V-__8AAKSTCwA_ALBxWjOi20O0c9_WB3Y1COzRQxwZLpLy",
+ },
},
.paths = .{
"build.zig",
diff --git a/design/images/mission-scene.png b/design/images/mission-scene.png
index 6dbd9a0..281bea5 100644
Binary files a/design/images/mission-scene.png and b/design/images/mission-scene.png differ
diff --git a/design/screens/mission-scene.html b/design/screens/mission-scene.html
index 4d537d6..df3b833 100644
--- a/design/screens/mission-scene.html
+++ b/design/screens/mission-scene.html
@@ -274,6 +274,10 @@
Ranges
+
diff --git a/docs/architecture.md b/docs/architecture.md
index 8353907..0c8d8d3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -24,10 +24,12 @@ own library, which `build.zig.zon` pins, so it reads every mission exactly as th
| [`panels.zig`](../src/panels.zig) | The dockable panels: the outline, problems, the inspector and the others |
| [`forms.zig`](../src/forms.zig) | The inspector's forms: a record's fields as controls that write the source |
| [`views/script.zig`](../src/views/script.zig) | The Script view: the source, its gutter, help and completion, and the listing |
+| [`views/scene.zig`](../src/views/scene.zig) | The Scene view: the map of the mission's ships and markers, its ranges, curves and routes, and moving them |
+| [`views/scene/drawing.zig`](../src/views/scene/drawing.zig) | How the map draws its parts: the grid, rings, legs, marks, names and notes |
| [`palette.zig`](../src/palette.zig) | The command palette, Play from part, and the game's missions |
| [`screens/`](../src/screens) | The start screen and the settings |
| [`toasts.zig`](../src/toasts.zig) | Short messages that fade out |
-| [`ui/`](../src/ui) | The interface kit: colours, fonts, icons, widgets, the text editor |
+| [`ui/`](../src/ui) | The interface kit: colours, fonts, icons, widgets, the text editor, and the map's plot (ImPlot, through a C shim) |
| [`platform/`](../src/platform) | The window, SDL_GPU, ImGui's backends, and the C header the build translates |
## The game profile
@@ -41,6 +43,8 @@ profile, a `game.Profile`:
parameters, the game's variables, and the names of its values;
- the records a mission's source declares, for the outline and the inspector, the fields each
record's table can give, and the changes that edit them or declare a new record;
+- what the map shows of a mission: where its records start and which way its ships point, the
+ ranges its triggers watch, and its curves;
- building source into a mission file, with the map of its script's statements to the source's
lines, decompiling a mission file into source, and comparing two missions and their scripts;
- the game's messages on the editor link, and the script's flags for a set of offsets;
@@ -80,6 +84,34 @@ are each on one. While drawing, the interface only queues the change (`Document.
makes it once the frame is drawn, since making it checks the text again, which replaces the outline
the frame drew from.
+## The map
+
+The Scene view's map draws on ImPlot, which gives it equal axes, the pointer's pan and dragging; the
+editor reaches it through a C shim (`ui/plot/plot_shim.h`), as it reaches the text editor. The
+editor zooms the map itself, around the pointer, and switches ImPlot's zoom off, since ImPlot
+doesn't zoom while the pointer is over a marker.
+
+The profile says what the map shows (`Profile.scene`). StarLancer's places each ship and marker by
+its `position`, and says which are scenery: the planets of the original's table
+(`create.planets.original`) and the sun's and nebulae's markers. It points a ship as OpenReliant
+turns it by its angles (`mission.recordOrientation`), rings each ShipReached trigger's nav point or
+waypoint at the 4000 the game watches within, rings each ship the script's `SetFlybackMarker`
+names at its range, and draws each curve as the director's camera runs it
+(`executor.curves.point`). It reads the routes from the script's syntax tree, in the order the
+source gives it: the orders that `SetAI` gives, found by the command catalogue's parameters and
+sorted by OpenReliant's order table (`ai.orders.Order`) into flying, jumping and coming in, and the
+patrol routes `SetPatrolRoute` sets. A branch that leaves a ship in different places stops its
+route. The document keeps the scene until its text or its outline changes, since reading it parses
+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.
+
## The debugger
The debugger links to the game over OpenReliant's editor link ([Debugging a mission](debugging.md)).
@@ -99,8 +131,7 @@ profile (`Profile.link`), so that it never names StarLancer's tags or opcodes.
Each shows in the interface, greyed out, with its issue:
-- the Scene view ([#2](https://github.com/OpenReliant/openreliant-editor/issues/2)) and the Blocks
- view ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3));
+- the Blocks view ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3));
- Mod mode, with Reload scripts and Play with this mod only
([#4](https://github.com/OpenReliant/openreliant-editor/issues/4));
- the debugger's conditions, log points, Watch, values inline and Debugging screen
diff --git a/docs/editing.md b/docs/editing.md
index f534ae1..aec82dc 100644
--- a/docs/editing.md
+++ b/docs/editing.md
@@ -32,3 +32,59 @@ A name the source uses without declaring it shows in the Problems panel with **D
a record under that name: choose what kind it is.
Squads, formations and curves list other records, and are written in the source.
+
+## The map
+
+The Scene view (Ctrl+2, or Cmd+2 on macOS) shows the mission's map. **Top**, **Side** and
+**Front** look at it from above, from the side and from the front; the note in the map's corner
+says which way its axes point. Ships show as arrows that point the way they start, and nav points,
+waypoints and points, such as where ships jump in, as diamonds, in their flight groups' colours.
+Planets, the sun and nebulae show as faint discs. The scale in the corner is one cell of the grid.
+
+A mission opens fitted to its ships and markers, leaving out the planets and the sun, which lie far
+off. Many of the game's missions take place in several areas millions of units apart, so they open
+as an overview: things that would overlap on the screen show as one round mark with how many it
+holds, named after its most important thing.
+
+- Drag the map to move it, and scroll to zoom in and out around the pointer. **Fit**, or a double
+ 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.
+- 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
+ selection.
+- **Ranges** rings each nav point or waypoint a ShipReached trigger watches, at the 4000 the game
+ watches within, and dots a ring where the script sets a flyback marker, at its range.
+ **Curves** shows the curves the director's camera flies, with their points.
+- **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.
+
+**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)).
+
+### Routes
+
+A route links the places the script sends a ship or a flight group to, in its group's colour. A
+dotted leg is one the ship flies (Fly and Escort), and a dashed leg one it jumps (Jump Out and Warp
+Out). An arrowhead shows where each leg goes, and a small ring where the route starts. In an
+overview, the player's route shows the order the mission visits its areas in.
+
+- A route starts where its ship starts, or a group's where its first ship starts. A ship the script
+ brings in by a jump or a warp waits far off until then, so its route starts where it comes in.
+- When a ship reaches a marker that a ShipReached trigger watches, the trigger's orders continue
+ its route from there.
+- A patrol route (`SetPatrolRoute`) goes round its flight group's waypoints in the order the mission
+ lists them, and back to the first.
+- Where an `if` sends a ship to different places, the route stops there, since either could be
+ where it is next.
+- A leg to or from a place off the map is a short stub that points the way.
+- Select a ship, a group or a marker to make the routes it's on stand out, and click an empty part
+ of the map to show them all alike again.
+
+Routes read the script in the order the source gives its orders. A trigger runs when its condition
+holds, so in the game a ship may take a route's legs in another order. The live scene will show
+where ships really go.
diff --git a/src/app.zig b/src/app.zig
index 45ef76f..3fb90be 100644
--- a/src/app.zig
+++ b/src/app.zig
@@ -317,7 +317,14 @@ pub const App = struct {
if (commands.info(command).planned) |plan| return .{ .reason = gui.print("Planned · {f}", .{plan}) };
const needs_document = "Open a mission first";
return switch (command) {
- .add_record, .save, .build, .show_source, .show_listing, .toggle_breakpoint, .clear_breakpoints, .go_to_line, .comment_line, .find, .find_next, .replace, .undo, .redo, .cut, .copy, .paste, .select_all => if (app.document == null) .{ .reason = needs_document } else null,
+ .add_record, .save, .build, .show_source, .show_listing, .clear_breakpoints, .go_to_line, .find, .find_next, .replace, .undo, .redo => if (app.document == null) .{ .reason = needs_document } else null,
+ // These act on the source's cursor and selection, which only the Script view shows.
+ .toggle_breakpoint, .comment_line, .cut, .copy, .paste, .select_all => if (app.document == null)
+ .{ .reason = needs_document }
+ else if (app.view != .script)
+ .{ .reason = "Script view only" }
+ else
+ null,
.play, .play_from_part => if (app.document == null) .{ .reason = needs_document } else if (app.settings.game_folder == null) .{ .reason = "Set the game's folder in Game settings" } else null,
.new_mission => if (app.settings.game_folder == null and app.project == null) .{ .reason = "Set the game's folder in Game settings" } else null,
.edit_game_mission => if (app.settings.game_folder == null) .{ .reason = "Set the game's folder in Game settings" } else null,
@@ -352,7 +359,6 @@ pub const App = struct {
fn viewUnavailable(app: *const App, command: Command) ?widgets.Disabled {
return switch (app.mode) {
.mission => switch (command) {
- .view_2 => .{ .reason = gui.print("Planned · {f}", .{commands.plans.scene}) },
.view_3 => .{ .reason = gui.print("Planned · {f}", .{commands.plans.blocks}) },
.view_4 => .{ .reason = "Mission mode has three views" },
else => null,
@@ -423,24 +429,28 @@ pub const App = struct {
.paste => if (app.document) |document| document.editor.paste(),
.select_all => if (app.document) |document| document.editor.selectAll(),
.find, .replace => if (app.document) |document| {
- app.source_tab = .source;
+ app.showSource(.source);
document.editor.openFind();
},
- .find_next => if (app.document) |document| document.editor.findNext(),
+ .find_next => if (app.document) |document| {
+ app.showSource(.source);
+ document.editor.findNext();
+ },
.comment_line => if (app.document) |document| document.editor.toggleComments(),
.go_to_line => app.palette.show(.line),
.palette => app.palette.show(.commands),
.go_to => app.palette.show(.records),
.view_1 => app.view = .script,
- .view_2, .view_3, .view_4 => {},
+ .view_2 => app.view = .scene,
+ .view_3, .view_4 => {},
.layout_full, .reset_layout => {
app.open_panels = .initFill(true);
app.layout_due = true;
},
.full_screen => app.window.toggleFullScreen(),
.build => app.build(),
- .show_source => app.source_tab = .source,
- .show_listing => app.source_tab = .listing,
+ .show_source => app.showSource(.source),
+ .show_listing => app.showSource(.listing),
.toggle_breakpoint => if (app.document) |document| app.toggleBreakpoint(document.editor.cursor().line),
.clear_breakpoints => if (app.document) |document| {
document.clearBreakpoints();
@@ -1012,23 +1022,47 @@ pub const App = struct {
.added => |added| {
const line = added.line orelse return app.toasts.push(.info, gui.print("Can't add {s}", .{asked.add.name orelse asked.add.kind.label}), reason);
app.selection = .{ .section = added.section, .line = line };
- app.goTo(line, 0);
+ // The map shows the new record selected, and the Script view its line.
+ if (app.view == .script) app.goTo(line, 0) else app.placeCursor(line, 0);
},
}
}
+ /// The record the inspector shows and the map marks: the one selected, or else the one the
+ /// source's cursor is in.
+ pub fn inspected(app: *const App) ?*const game.Record {
+ const document = app.document orelse return null;
+ const known = document.outline();
+ if (app.selection) |chosen| return known.find(chosen.section, chosen.line);
+ return known.at(@intCast(document.editor.cursor().line));
+ }
+
pub fn select(app: *App, record: game.Record) void {
app.selection = .{ .section = record.section, .line = record.first_line };
}
- /// Moves the source's cursor to `line` and `column`, counting from 0, and shows it.
+ /// Moves the source's cursor to `line` and `column`, counting from 0, and shows it in the
+ /// Script view, switching to it from another view.
pub fn goTo(app: *App, line: u32, column: u32) void {
const document = app.document orelse return;
- app.source_tab = .source;
- app.screen = .workspace;
+ app.showSource(.source);
+ app.placeCursor(line, column);
+ document.editor.focus();
+ }
+
+ /// Moves the source's cursor to `line` and `column`, counting from 0, and scrolls the source
+ /// to it, without switching views.
+ fn placeCursor(app: *App, line: u32, column: u32) void {
+ const document = app.document orelse return;
document.editor.setCursor(.{ .line = line, .index = column });
document.editor.scrollToLine(line);
- document.editor.focus();
+ }
+
+ /// Shows the source panel's `tab` in the Script view, switching to it from another view.
+ fn showSource(app: *App, tab: SourceTab) void {
+ app.screen = .workspace;
+ app.view = .script;
+ app.source_tab = tab;
}
fn askQuit(app: *App) void {
diff --git a/src/commands.zig b/src/commands.zig
index ba26772..42b8993 100644
--- a/src/commands.zig
+++ b/src/commands.zig
@@ -193,7 +193,6 @@ fn defaults(comptime windows_linux: []const []const u8, comptime macos: []const
/// Issues of what doesn't work yet, by what each plans.
pub const plans = struct {
- pub const scene: Plan = .{ .issue = 2 };
pub const blocks: Plan = .{ .issue = 3 };
/// Mod mode's views, Reload scripts, and Play with this mod only.
pub const mod_mode: Plan = .{ .issue = 4 };
diff --git a/src/forms.zig b/src/forms.zig
index a928c07..067855e 100644
--- a/src/forms.zig
+++ b/src/forms.zig
@@ -92,7 +92,7 @@ pub fn trigger(app: *App, document: *Document, known: game.Outline, shown: game.
}
}
- const repeats = fieldOf(shown, "repeats");
+ const repeats = shown.given("repeats");
const repeat_index: usize = if (repeats) |given| (if (std.mem.eql(u8, given.value, "\"always\"")) 1 else if (std.mem.eql(u8, given.value, "\"once\"")) 0 else 2) else 0;
form.label("Repeats", false);
if (widgets.segmented("repeats", &.{ "Once", "Always", "Counted" }, repeat_index, off)) |chosen| if (chosen != repeat_index) {
@@ -105,7 +105,7 @@ pub fn trigger(app: *App, document: *Document, known: game.Outline, shown: game.
};
for (document.profile.fields(shown)) |spec| if (std.mem.eql(u8, spec.name, "component")) form.field(spec, labelOf(spec.name), false);
form.label("Deferred", false);
- const deferred = fieldOf(shown, "deferred");
+ const deferred = shown.given("deferred");
const checked = if (deferred) |given| std.mem.eql(u8, given.value, "true") else false;
if (widgets.checkbox("deferred", "Run on scheduler", checked, off)) form.set("deferred", if (checked) null else "true");
}
@@ -139,7 +139,7 @@ const Form = struct {
gui.tooltip(spec.about);
gui.pushId(spec.name);
defer gui.popId();
- const given = fieldOf(form.shown, spec.name);
+ const given = form.shown.given(spec.name);
switch (spec.kind) {
.record => |sections| {
const target = if (given) |found| (if (found.names_record) form.known.named(found.value) else null) else null;
@@ -178,7 +178,9 @@ const Form = struct {
/// A vector's three boxes, X, Y and Z in their colours. A vector left out shows the origin.
fn vector(form: Form, spec: game.FieldForm, given: ?game.Field) void {
var values: [3]f64 = @splat(0);
- if (given) |found| parseVector(found.value, &values);
+ if (given) |found| if (found.vector()) |vector_given| {
+ values = vector_given;
+ };
const tags = [3]widgets.Tag{
.{ .letter = "X", .color = theme.axis_x },
.{ .letter = "Y", .color = theme.axis_y },
@@ -211,7 +213,7 @@ const Form = struct {
gui.setCursor(.{ .x = at.x + @as(f32, @floatFromInt(place)) * (each + px(row_gap)), .y = at.y });
gui.pushId(spec.name);
defer gui.popId();
- const given = fieldOf(form.shown, spec.name);
+ const given = form.shown.given(spec.name);
const tag: widgets.Tag = .{ .letter = spec.letter, .color = theme.axis_off, .text = theme.text };
if (widgets.textField("number", if (given) |found| found.value else "0", .{ .number = true, .tag = tag, .width = each, .disabled = form.off })) |typed| {
if (form.numberText(typed)) |value| form.set(spec.name, value);
@@ -292,12 +294,6 @@ fn markOf(shown: game.Record) widgets.RowMark {
return .{ .icon = .{ .icon = panels.sectionIcon(shown.section), .color = panels.groupColor(shown) } };
}
-/// The field `name` of `shown`'s table, if it gives one.
-fn fieldOf(shown: game.Record, name: []const u8) ?game.Field {
- for (shown.fields) |given| if (std.mem.eql(u8, given.name, name)) return given;
- return null;
-}
-
/// A field's label from its name: `component` as `Component`, and `_` as a space.
fn labelOf(name: []const u8) []const u8 {
const made = gui.frameAllocator().dupe(u8, name) catch return name;
@@ -306,24 +302,6 @@ fn labelOf(name: []const u8) []const u8 {
return made;
}
-/// The numbers of a vector as the outline shows it, `{ -2000, 0, 42000 }`, into `values`; the ones
-/// it doesn't give stay as they are.
-fn parseVector(text: []const u8, values: *[3]f64) void {
- var parts = std.mem.tokenizeAny(u8, text, "{}, ");
- for (values) |*value| {
- const part = parts.next() orelse return;
- value.* = std.fmt.parseFloat(f64, part) catch return;
- }
-}
-
test labelOf {
try std.testing.expectEqualStrings("Formation point", labelOf("formation_point"));
}
-
-test parseVector {
- var values: [3]f64 = @splat(0);
- parseVector("{ -2000, 0, 42000.5 }", &values);
- try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, values);
- parseVector("…", &values);
- try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, values);
-}
diff --git a/src/game.zig b/src/game.zig
index ab32d23..3129533 100644
--- a/src/game.zig
+++ b/src/game.zig
@@ -46,6 +46,8 @@ pub const Profile = struct {
/// The kind of value a type of mission source takes, such as `FlightGroup?` or `ShipType`, as
/// the language's parameters and fields name them.
fieldKind: *const fn (type_name: []const u8) FieldKind,
+ /// What the map shows of the mission whose source is `text`, as `known` outlines it.
+ scene: *const fn (arena: Allocator, text: []const u8, known: Outline) Allocator.Error!Scene,
/// The kinds of record the outline's + adds, in the order its menu lists them.
new_kinds: []const NewKind,
/// The change to `text`, whose outline is `known`, that declares a new record of `kind` after
@@ -204,6 +206,85 @@ pub const FieldKind = union(enum) {
source,
};
+/// What the map shows of a mission (`Profile.scene`), in the game's coordinates, Y down: where its
+/// records start, the ranges its triggers watch, its curves, and where its script sends its ships.
+pub const Scene = struct {
+ things: []const Thing,
+ /// The player's ship, by its place in `things`, where the mission has one.
+ player: ?usize = null,
+ ranges: []const Range,
+ /// Each curve, as the points it passes.
+ curves: []const []const [3]f64,
+ routes: []const Route = &.{},
+ /// The field the map writes a dragged thing's place to, such as `position`.
+ place_field: []const u8,
+};
+
+/// A record the map places.
+pub const Thing = struct {
+ /// The record, by its section and its place among the section's (`Outline.nth`).
+ section: Section,
+ index: usize,
+ kind: ThingKind,
+ /// Where it starts.
+ place: [3]f64,
+ /// The way it points, as a unit vector; null for what points nowhere, such as a marker.
+ heading: ?[3]f64 = null,
+};
+
+/// What a thing on the map is, which picks how it's drawn.
+pub const ThingKind = enum {
+ /// A ship, a capital ship or a station: an arrow that points the way it heads.
+ ship,
+ /// A nav point, a waypoint or a point, such as where a ship jumps in: a diamond.
+ marker,
+ /// A point of the director's camera's curves: shown with the curves.
+ camera,
+ /// Scenery far off, such as a planet or the sun: a ring, which the map's fit leaves out.
+ scenery,
+};
+
+/// Where the script sends a ship or a group: a leg from each place it sends it to the next, as its
+/// orders read in the order the source gives them. It's a reading of the script, not a run of it:
+/// triggers run when their conditions hold, which may be in another order.
+pub const Route = struct {
+ /// The ship it's for, by its place in `Scene.things`; none for a group's.
+ ship: ?usize,
+ /// The flight group whose colour it takes: the ship's, or the group's own (`Record.group`).
+ group: ?usize,
+ legs: []const Leg,
+};
+
+/// A leg of a route, between two things by their place in `Scene.things`.
+pub const Leg = struct {
+ from: usize,
+ to: usize,
+ way: Way,
+ /// Whether the route starts at `from`: where its ship starts, or where it comes in.
+ starts: bool = false,
+};
+
+/// How a ship goes along a leg.
+pub const Way = enum { fly, jump };
+
+/// A distance the mission watches within, around one of its records.
+pub const Range = struct {
+ /// The record it's around, by its section and its place among the section's.
+ section: Section,
+ index: usize,
+ radius: f64,
+ /// What watches it, such as a trigger's condition.
+ label: []const u8,
+ style: RangeStyle,
+};
+
+pub const RangeStyle = enum {
+ /// A trigger fires within it.
+ trigger,
+ /// A hint of the game's, such as the display's flyback marker.
+ hint,
+};
+
/// A kind of record the outline's + adds.
pub const NewKind = struct {
/// The word that declares it, such as `ship`.
@@ -490,6 +571,15 @@ pub const Field = struct {
value: []const u8,
/// Whether the value names another record, such as `group = alpha`.
names_record: bool = false,
+
+ /// The value as a place or a direction, where the source writes one as numbers:
+ /// `{ -2000, 0, 42000 }`.
+ pub fn vector(field: Field) ?[3]f64 {
+ var parts = std.mem.tokenizeAny(u8, field.value, "{}, ");
+ var made: [3]f64 = undefined;
+ for (&made) |*value| value.* = std.fmt.parseFloat(f64, parts.next() orelse return null) catch return null;
+ return made;
+ }
};
pub const Record = struct {
@@ -521,6 +611,12 @@ pub const Record = struct {
first_line: u32,
last_line: u32,
column: u32 = 0,
+
+ /// The field `name` its table gives, if it gives one.
+ pub fn given(record: Record, name: []const u8) ?Field {
+ for (record.fields) |field| if (std.mem.eql(u8, field.name, name)) return field;
+ return null;
+ }
};
/// The number of characters to add, remove or change to turn `a` into `b`, for names up to 64
@@ -560,6 +656,11 @@ test Mission {
try std.testing.expectEqual(20, built.statementOn(8).?.offset);
}
+test Field {
+ try std.testing.expectEqual([3]f64{ -2000, 0, 42000.5 }, (Field{ .name = "position", .value = "{ -2000, 0, 42000.5 }" }).vector().?);
+ try std.testing.expectEqual(null, (Field{ .name = "position", .value = "…" }).vector());
+}
+
test editDistance {
try std.testing.expectEqual(3, editDistance("enemy", "enemies"));
try std.testing.expectEqual(0, editDistance("nav", "nav"));
diff --git a/src/game/starlancer.zig b/src/game/starlancer.zig
index 07b46e6..8d9e23d 100644
--- a/src/game/starlancer.zig
+++ b/src/game/starlancer.zig
@@ -12,6 +12,12 @@ const dte = openreliant.dte;
const source = dte.source;
const syntax = source.syntax;
const commands = openreliant.engine.game.executor.commands;
+const orders = openreliant.engine.game.ai.orders;
+const curves = openreliant.engine.game.executor.curves;
+const math = openreliant.engine.surrender.math;
+const game_mission = openreliant.engine.game.mission;
+const gameobj = openreliant.engine.game.gameobj;
+const planets = openreliant.engine.game.create.planets;
const conditions = openreliant.engine.vm.conditions;
const vm = openreliant.engine.vm;
const mission_editor = openreliant.engine.game.mission.editor;
@@ -31,6 +37,7 @@ pub const profile: game.Profile = .{
.edit = edit,
.fields = fieldForms,
.fieldKind = fieldKind,
+ .scene = scene,
.new_kinds = &new_kinds,
.addRecord = addRecord,
.listing = listing,
@@ -650,6 +657,417 @@ const wings = names: {
break :names final;
};
+/// What the map shows of a mission: each ship and marker where its `position` places it, a ship
+/// pointing as the game turns it by its yaw, pitch and roll (`mission.recordOrientation`), its
+/// planets and the sun's and nebulae's markers as scenery, the range of each ShipReached trigger
+/// and of each flyback marker the script sets, each curve as the director's camera runs it
+/// (`executor.curves.point`), and the routes the script sends ships along (`RouteWalk`).
+fn scene(arena: Allocator, text: []const u8, known: game.Outline) Allocator.Error!game.Scene {
+ const tree = try source_parser.parse(arena, text);
+ var things: std.ArrayList(game.Thing) = .empty;
+ var ranges: std.ArrayList(game.Range) = .empty;
+ var paths: std.ArrayList([]const [3]f64) = .empty;
+ var walk: RouteWalk = .{ .arena = arena, .tree = tree, .known = known };
+ var player: ?usize = null;
+ for (known.records) |record| switch (record.section) {
+ .ships => {
+ // The first ship is the player's.
+ if (record.index == 0) player = things.items.len;
+ const scenery = if (record.given("type")) |given| isScenery(std.mem.trim(u8, given.value, "\"")) else false;
+ try walk.addThing(record, things.items.len);
+ try things.append(arena, .{
+ .section = record.section,
+ .index = record.index,
+ .kind = if (scenery) .scenery else .ship,
+ .place = vectorOf(record, "position"),
+ .heading = if (scenery) null else headingOf(record),
+ });
+ },
+ .markers => {
+ try walk.addThing(record, things.items.len);
+ try things.append(arena, .{
+ .section = record.section,
+ .index = record.index,
+ .kind = markerKind(record),
+ .place = vectorOf(record, "position"),
+ });
+ },
+ .flight_groups => if (record.group) |group| try walk.groups.put(arena, record.name, group),
+ .squads => try walk.squads.put(arena, record.name, {}),
+ .triggers => if (std.meta.stringToEnum(dte.Condition, record.condition) == .ship_reached) {
+ const subject = known.named(record.subject) orelse continue;
+ try ranges.append(arena, .{ .section = subject.section, .index = subject.index, .radius = reached_reach, .label = record.condition, .style = .trigger });
+ },
+ .curves => try paths.append(arena, try curvePath(arena, record)),
+ else => {},
+ };
+ // The flyback markers the script sets: `SetFlybackMarker(nav, 5000)`, anywhere in its code.
+ for (0..tree.nodes.len) |index| {
+ const call = callOf(tree, @intCast(index)) orelse continue;
+ if (!std.mem.eql(u8, call.callee, set_flyback_marker.name) or call.arguments.len < set_flyback_marker.params.len) continue;
+ const name = globalName(tree, call.arguments[0]) orelse continue;
+ const radius = switch (tree.get(call.arguments[1])) {
+ .number => |value| value,
+ else => continue,
+ };
+ const subject = known.named(name) orelse continue;
+ try ranges.append(arena, .{ .section = subject.section, .index = subject.index, .radius = radius, .label = "flyback", .style = .hint });
+ }
+ return .{ .things = things.items, .player = player, .ranges = ranges.items, .curves = paths.items, .routes = try walk.read(), .place_field = "position" };
+}
+
+/// How the map shows a marker: a point that marks a place on a curve, and a curve point, with the
+/// curves; a nav point, a waypoint and the other points, such as where ships jump in, always.
+fn markerKind(record: game.Record) game.ThingKind {
+ return switch (std.meta.stringToEnum(source.names.Word, record.word) orelse return .marker) {
+ .curve_point => .camera,
+ .point => if (record.given("marker") != null) .camera else .marker,
+ else => .marker,
+ };
+}
+
+/// The commands the map reads: `SetAI`, whose order sends the entity it names to its target
+/// (command `0x0B`), `SetPatrolRoute`, which sends it round a flight group's waypoints (`0x0D`),
+/// and `SetFlybackMarker`, which sets the display's flyback marker on it within a range (`0x46`).
+const set_ai = commands.table[0x0B];
+const set_patrol_route = commands.table[0x0D];
+const set_flyback_marker = commands.table[0x46];
+/// `SetAI`'s parameters: the entity it sends, its order, and its target, which may be none.
+const set_ai_entity = 0;
+const set_ai_order = for (set_ai.params, 0..) |param, index| {
+ if (source.vocabulary.of(param) == .order) break index;
+} else @compileError("SetAI takes no order");
+const set_ai_target = set_ai.params.len - 1;
+
+comptime {
+ std.debug.assert(std.mem.eql(u8, set_ai.name, "SetAI") and std.mem.eql(u8, set_patrol_route.name, "SetPatrolRoute"));
+ std.debug.assert(std.mem.eql(u8, set_flyback_marker.name, "SetFlybackMarker"));
+ std.debug.assert(set_ai.params[set_ai_target].kinds.ship and set_ai.params[set_ai_target].extra.nullable);
+ std.debug.assert(set_patrol_route.params[1].kinds.flight_group);
+}
+
+/// How an order sends a ship to its target, where it does (OpenReliant's
+/// [orders](https://github.com/OpenReliant/openreliant/blob/main/docs/engine/orders.md#the-orders)):
+/// Fly and Escort fly it there, Jump Out and Warp Out jump it there, and Jump In and Warp In bring
+/// it in there.
+const Move = enum { fly, jump, arrive };
+
+fn moveOf(order: orders.Order) ?Move {
+ return switch (order) {
+ .fly, .escort => .fly,
+ .jump_out, .jump_out_spread, .warp_out, .fixed_gate_jump_out => .jump,
+ .jump_in, .jump_in_spread, .warp_in, .fixed_gate_jump_in => .arrive,
+ else => null,
+ };
+}
+
+/// The order the argument at `index` gives: by its name, or by its number.
+fn orderOf(tree: syntax.Tree, index: syntax.Index) ?orders.Order {
+ const number: u32 = switch (tree.get(index)) {
+ .string => |name| source.vocabulary.value(.order, name) orelse return null,
+ .number => |value| if (value >= 0 and value <= std.math.maxInt(i16) and @floor(value) == value) @intFromFloat(value) else return null,
+ else => return null,
+ };
+ return @fromBackingInt(std.math.cast(i16, number) orelse return null);
+}
+
+fn globalName(tree: syntax.Tree, index: syntax.Index) ?[]const u8 {
+ return switch (tree.get(index)) {
+ .global_name => |name| name,
+ else => null,
+ };
+}
+
+/// Reads where the script sends its ships and groups (`game.Route`): each order that sends one to
+/// a marker, in the order the source gives them, through branches and triggers.
+const RouteWalk = struct {
+ arena: Allocator,
+ tree: syntax.Tree,
+ known: game.Outline,
+ /// The ships and markers of the scene, by their records' names.
+ things: std.StringHashMapUnmanaged(Placed) = .empty,
+ /// The flight groups, by name, each with its place among them (`Record.group`), and the
+ /// squads.
+ groups: std.StringHashMapUnmanaged(usize) = .empty,
+ squads: std.StringHashMapUnmanaged(void) = .empty,
+ /// Each flight group's first ship, where a group's route starts.
+ first_ships: std.AutoHashMapUnmanaged(usize, []const u8) = .empty,
+ /// The ships and groups the script brings in by a jump or a warp, by name, and the groups by
+ /// their place among the groups: such a ship waits far off until it comes in, so its route
+ /// starts where it comes in, not where it starts.
+ arriving: std.StringHashMapUnmanaged(void) = .empty,
+ arriving_groups: std.AutoHashMapUnmanaged(usize, void) = .empty,
+ /// Where the orders read so far send each ship or group.
+ whereabouts: Whereabouts = .empty,
+ /// The legs so far, by the name of the ship or group they're for.
+ legs: std.StringArrayHashMapUnmanaged(std.ArrayList(game.Leg)) = .empty,
+
+ /// A ship or a marker of the scene: its place among the scene's things, its section, and its
+ /// flight group.
+ const Placed = struct { thing: usize, section: game.Section, group: ?usize };
+
+ /// A place a route reaches: the thing, and whether the route starts there.
+ const Spot = struct { thing: usize, starts: bool };
+
+ /// Where the orders send each ship or group, by name: at a spot, or lost where the script
+ /// leaves it unknown, such as after branches that send it to different places.
+ const Whereabouts = std.StringHashMapUnmanaged(union(enum) { at: Spot, lost });
+
+ fn addThing(walk: *RouteWalk, record: game.Record, thing: usize) Allocator.Error!void {
+ try walk.things.put(walk.arena, record.name, .{ .thing = thing, .section = record.section, .group = record.group });
+ if (record.section != .ships) return;
+ const group = record.group orelse return;
+ const first = try walk.first_ships.getOrPut(walk.arena, group);
+ if (!first.found_existing) first.value_ptr.* = record.name;
+ }
+
+ /// The routes, from the whole script.
+ fn read(walk: *RouteWalk) Allocator.Error![]const game.Route {
+ for (0..walk.tree.nodes.len) |index| {
+ const found = callOf(walk.tree, @intCast(index)) orelse continue;
+ if (!std.mem.eql(u8, found.callee, set_ai.name) or found.arguments.len < set_ai.params.len) continue;
+ const given = orderOf(walk.tree, found.arguments[set_ai_order]) orelse continue;
+ if (moveOf(given) != .arrive) continue;
+ const subject = globalName(walk.tree, found.arguments[set_ai_entity]) orelse continue;
+ try walk.arriving.put(walk.arena, subject, {});
+ if (walk.groups.get(subject)) |group| try walk.arriving_groups.put(walk.arena, group, {});
+ }
+ try walk.visit(walk.tree.root);
+ var routes: std.ArrayList(game.Route) = .empty;
+ var each = walk.legs.iterator();
+ while (each.next()) |entry| {
+ const ship = walk.things.get(entry.key_ptr.*);
+ try routes.append(walk.arena, .{
+ .ship = if (ship) |placed| placed.thing else null,
+ .group = if (ship) |placed| placed.group else walk.groups.get(entry.key_ptr.*),
+ .legs = entry.value_ptr.items,
+ });
+ }
+ return routes.items;
+ }
+
+ /// Reads the orders of the code at `index`, statement by statement.
+ fn visit(walk: *RouteWalk, index: syntax.Index) Allocator.Error!void {
+ switch (walk.tree.get(index)) {
+ .block => |statements| for (statements) |statement| try walk.visit(statement),
+ .@"if" => |both| try walk.branch(both.then, both.@"else"),
+ .call_statement => |statement| try walk.visit(statement),
+ .call => |found| try walk.call(found.function, found.arguments),
+ .function => |function| try walk.visit(function.body),
+ .local, .assign => |binding| for (binding.values) |value| try walk.visit(value),
+ .@"return" => |values| for (values) |value| try walk.visit(value),
+ .table => |items| for (items) |item| try walk.visit(item),
+ .item => |item| try walk.visit(switch (item) {
+ .list => |value| value,
+ .record => |record| record.value,
+ .general => |general| general.value,
+ }),
+ else => {},
+ }
+ }
+
+ fn call(walk: *RouteWalk, function: syntax.Index, arguments: []const syntax.Index) Allocator.Error!void {
+ if (globalName(walk.tree, function)) |callee| {
+ if (std.mem.eql(u8, callee, set_ai.name)) return walk.order(arguments);
+ if (std.mem.eql(u8, callee, set_patrol_route.name)) return walk.patrol(arguments);
+ if (std.meta.stringToEnum(source.names.Word, callee) == .on) try walk.reached(arguments);
+ }
+ for (arguments) |argument| try walk.visit(argument);
+ }
+
+ /// `SetAI`: an order that sends the ship or group to a marker adds a leg from where it is.
+ fn order(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void {
+ if (arguments.len < set_ai.params.len) return;
+ const subject = walk.subjectOf(arguments[set_ai_entity]) orelse return;
+ const move = moveOf(orderOf(walk.tree, arguments[set_ai_order]) orelse return) orelse return;
+ // Sent out of the mission, or after a ship: where it is next isn't known.
+ const target = walk.markerOf(arguments[set_ai_target]) orelse return walk.whereabouts.put(walk.arena, subject, .lost);
+ const from = walk.whereIs(subject);
+ if (from) |spot| if (spot.thing == target) return;
+ switch (move) {
+ .arrive => return walk.whereabouts.put(walk.arena, subject, .{ .at = .{ .thing = target, .starts = true } }),
+ .fly, .jump => if (from) |spot| {
+ try walk.addLeg(subject, .{ .from = spot.thing, .to = target, .way = if (move == .jump) .jump else .fly, .starts = spot.starts });
+ },
+ }
+ try walk.whereabouts.put(walk.arena, subject, .{ .at = .{ .thing = target, .starts = false } });
+ }
+
+ /// `SetPatrolRoute`: the ship or group flies to the route's first waypoint, then round its
+ /// waypoints in the order the mission lists them, back to the first (OpenReliant's
+ /// `ai/formations.zig`, `nextWaypoint`). Where it is when the patrol starts isn't known, the
+ /// route starts at the first waypoint.
+ fn patrol(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void {
+ if (arguments.len < set_patrol_route.params.len) return;
+ const subject = walk.subjectOf(arguments[0]) orelse return;
+ const route = walk.groups.get(globalName(walk.tree, arguments[1]) orelse return) orelse return;
+ var at = walk.whereIs(subject);
+ var first: ?usize = null;
+ for (walk.known.records) |record| {
+ if (record.group != route or std.meta.stringToEnum(source.names.Word, record.word) != .waypoint) continue;
+ const thing = (walk.things.get(record.name) orelse continue).thing;
+ if (first == null) first = thing;
+ if (at) |spot| if (spot.thing != thing) {
+ try walk.addLeg(subject, .{ .from = spot.thing, .to = thing, .way = .fly, .starts = spot.starts });
+ };
+ const starts = at == null;
+ at = .{ .thing = thing, .starts = starts };
+ }
+ if (first) |back| if (at) |spot| if (spot.thing != back) {
+ try walk.addLeg(subject, .{ .from = spot.thing, .to = back, .way = .fly });
+ };
+ // Where the patrol has it when another order comes isn't known.
+ try walk.whereabouts.put(walk.arena, subject, .lost);
+ }
+
+ /// A ShipReached trigger: as its code runs, the ship its value names is at the marker it
+ /// watches.
+ fn reached(walk: *RouteWalk, arguments: []const syntax.Index) Allocator.Error!void {
+ if (arguments.len < 3) return;
+ const condition = stringOf(walk.tree, arguments[1]) orelse return;
+ if (std.meta.stringToEnum(dte.Condition, condition) != .ship_reached) return;
+ const marker = walk.markerOf(arguments[0]) orelse return;
+ const items = switch (walk.tree.get(arguments[2])) {
+ .table => |items| items,
+ else => return,
+ };
+ for (items) |item| switch (walk.tree.get(item)) {
+ .item => |kind| switch (kind) {
+ .record => |record| if (walk.subjectOf(record.value)) |ship| {
+ try walk.whereabouts.put(walk.arena, ship, .{ .at = .{ .thing = marker, .starts = false } });
+ },
+ else => {},
+ },
+ else => {},
+ };
+ }
+
+ /// Reads both ways of a branch from where the walk is. Where they leave a ship or a group in
+ /// different places, it could be in either: lost.
+ fn branch(walk: *RouteWalk, then: syntax.Index, otherwise: ?syntax.Index) Allocator.Error!void {
+ const before = try walk.whereabouts.clone(walk.arena);
+ try walk.visit(then);
+ const after_then = walk.whereabouts;
+ walk.whereabouts = before;
+ if (otherwise) |other| try walk.visit(other);
+ var each = after_then.iterator();
+ while (each.next()) |entry| {
+ const other = walk.whereabouts.get(entry.key_ptr.*) orelse {
+ try walk.whereabouts.put(walk.arena, entry.key_ptr.*, .lost);
+ continue;
+ };
+ if (!std.meta.eql(other, entry.value_ptr.*)) try walk.whereabouts.put(walk.arena, entry.key_ptr.*, .lost);
+ }
+ var others = walk.whereabouts.iterator();
+ while (others.next()) |entry| if (!after_then.contains(entry.key_ptr.*)) {
+ entry.value_ptr.* = .lost;
+ };
+ }
+
+ /// Where `subject` is as the walk reaches an order for it: where the orders before sent it, or
+ /// else where it starts, unless the script brings it in by a jump or a warp. Null where that
+ /// isn't known.
+ fn whereIs(walk: *const RouteWalk, subject: []const u8) ?Spot {
+ if (walk.whereabouts.get(subject)) |found| return switch (found) {
+ .at => |spot| spot,
+ .lost => null,
+ };
+ const start = walk.startOf(subject) orelse return null;
+ return .{ .thing = start, .starts = true };
+ }
+
+ /// Where `subject` starts: a ship where it is, a flight group where its first ship is; none
+ /// for a ship the script brings in by a jump or a warp, by itself or with its group, or for a
+ /// squad.
+ fn startOf(walk: *const RouteWalk, subject: []const u8) ?usize {
+ const ship = if (walk.groups.get(subject)) |group| walk.first_ships.get(group) orelse return null else subject;
+ const placed = walk.things.get(ship) orelse return null;
+ if (placed.section != .ships or walk.arriving.contains(ship)) return null;
+ if (placed.group) |group| if (walk.arriving_groups.contains(group)) return null;
+ return placed.thing;
+ }
+
+ /// The name of the ship, flight group or squad the argument at `index` names.
+ fn subjectOf(walk: *const RouteWalk, index: syntax.Index) ?[]const u8 {
+ const name = globalName(walk.tree, index) orelse return null;
+ if (walk.things.get(name)) |placed| return if (placed.section == .ships) name else null;
+ if (walk.groups.contains(name) or walk.squads.contains(name)) return name;
+ return null;
+ }
+
+ /// The thing of the marker the argument at `index` names.
+ fn markerOf(walk: *const RouteWalk, index: syntax.Index) ?usize {
+ const placed = walk.things.get(globalName(walk.tree, index) orelse return null) orelse return null;
+ return if (placed.section == .markers) placed.thing else null;
+ }
+
+ /// Adds a leg to `subject`'s route, once.
+ fn addLeg(walk: *RouteWalk, subject: []const u8, leg: game.Leg) Allocator.Error!void {
+ const found = try walk.legs.getOrPut(walk.arena, subject);
+ if (!found.found_existing) found.value_ptr.* = .empty;
+ for (found.value_ptr.items) |*known| if (known.from == leg.from and known.to == leg.to and known.way == leg.way) {
+ known.starts = known.starts or leg.starts;
+ return;
+ };
+ try found.value_ptr.append(walk.arena, leg);
+ }
+};
+
+/// Whether a ship of the type `type_name` is scenery: a planet, as the original's table of planets
+/// has it (`create.planets.original`), or the sun's or a nebula's marker.
+fn isScenery(type_name: []const u8) bool {
+ const number = source.vocabulary.value(.ship_type, type_name) orelse return false;
+ const ship_type: gameobj.Type = @fromBackingInt(std.math.cast(@typeInfo(gameobj.Type).@"enum".tag_type, number) orelse return false);
+ for (planets.original) |planet| if (planet.type == ship_type) return true;
+ return ship_type == gameobj.Type.of(.sun_marker) or ship_type == gameobj.Type.of(.nebula_marker);
+}
+
+/// How far ShipReached's triggers look around a nav point or a waypoint, as OpenReliant's mission
+/// events watch (`reached_reach`, `0x0052A5D0`).
+const reached_reach = 4000;
+
+/// The points a curve's path passes on the map.
+const curve_points = 48;
+
+/// The vector a record's field `name` gives; the origin where it gives none.
+fn vectorOf(record: game.Record, name: []const u8) [3]f64 {
+ const given = record.given(name) orelse return @splat(0);
+ return given.vector() orelse @splat(0);
+}
+
+/// The way a ship's nose points, as the game turns it by its angles. An angle left out, or unset
+/// as -1, is 0.
+fn headingOf(record: game.Record) [3]f64 {
+ var ship = std.mem.zeroes(dte.Ship);
+ ship.yaw = angleOf(record, "yaw");
+ ship.pitch = angleOf(record, "pitch");
+ ship.roll = angleOf(record, "roll");
+ const nose = math.forward(game_mission.recordOrientation(ship));
+ return .{ nose[0], nose[1], nose[2] };
+}
+
+fn angleOf(record: game.Record, name: []const u8) i16 {
+ const given = record.given(name) orelse return 0;
+ const degrees = std.fmt.parseFloat(f64, given.value) catch return 0;
+ if (degrees == -1) return 0;
+ return std.math.lossyCast(i16, degrees);
+}
+
+/// The points a curve record's path passes, from its `from` to its `to`, along its tangents.
+fn curvePath(arena: Allocator, record: game.Record) Allocator.Error![]const [3]f64 {
+ var curve = dte.Curve.blank;
+ for ([_]struct { *[3]f32, []const u8 }{ .{ &curve.from, "from" }, .{ &curve.to, "to" }, .{ &curve.leaving, "leaving" }, .{ &curve.arriving, "arriving" } }) |part| {
+ const value = vectorOf(record, part[1]);
+ part[0].* = .{ @floatCast(value[0]), @floatCast(value[1]), @floatCast(value[2]) };
+ }
+ const points = try arena.alloc([3]f64, curve_points);
+ for (points, 0..) |*point, at| {
+ const on = curves.point(curve, @as(f32, @floatFromInt(at)) / (curve_points - 1));
+ point.* = .{ on[0], on[1], on[2] };
+ }
+ return points;
+}
+
/// The records the outline's + adds. Squads, formations and curves list other records, and are
/// written in the source.
const new_kinds = [_]game.NewKind{
@@ -1026,6 +1444,121 @@ test addRecord {
try std.testing.expect(source.vocabulary.value(.condition, new_condition) != null);
}
+test scene {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const made = try outline(arena.allocator(), first_mission);
+ const shown = try scene(arena.allocator(), first_mission, made);
+ // The player, the nav point and the two Sabres, each where it starts.
+ try std.testing.expectEqual(4, shown.things.len);
+ try std.testing.expectEqual(made.named("player").?.index, shown.things[shown.player.?].index);
+ const sabre = made.named("sabre_1").?;
+ for (shown.things) |thing| if (thing.section == sabre.section and thing.index == sabre.index) {
+ try std.testing.expectEqual([3]f64{ -2000, 0, 42000 }, thing.place);
+ // Turned half round, it points back along Z.
+ try std.testing.expectApproxEqAbs(-1, thing.heading.?[2], 1e-6);
+ };
+ // The nav point's ShipReached trigger watches within 4000 of it, and the start part sets the
+ // flyback marker on it within 5000.
+ try std.testing.expectEqual(2, shown.ranges.len);
+ try std.testing.expectEqual(made.named("nav").?.index, shown.ranges[0].index);
+ try std.testing.expectEqual(4000, shown.ranges[0].radius);
+ try std.testing.expectEqual(5000, shown.ranges[1].radius);
+ try std.testing.expectEqual(game.RangeStyle.hint, shown.ranges[1].style);
+ // A planet's type is scenery; a Sabre's isn't.
+ try std.testing.expect(isScenery("neptune_hi") and !isScenery("sabre"));
+}
+
+test RouteWalk {
+ const text =
+ \\alpha = flight_group("(FG)Alpha", { wing = "player" })
+ \\raiders = flight_group("(FG)Raiders")
+ \\round = flight_group("(FG)Round")
+ \\player = ship("Player", { type = "predator", group = alpha })
+ \\wingman = ship("Wingman", { type = "predator", group = alpha, position = { 500, 0, 0 } })
+ \\raider = ship("Raider", { type = "sabre", group = raiders, position = { 9000000, 0, 0 } })
+ \\nav = nav_point("Nav 1", { position = { 0, 0, 40000 } })
+ \\entry = point("Raider in", { position = { 5000, 0, 30000 } })
+ \\target = waypoint("Raider fly to", { position = { 2000, 0, 35000 } })
+ \\out = point("Raider out", { position = { 9000, 0, 35000 } })
+ \\left = waypoint("Left", { position = { -3000, 0, 1000 } })
+ \\right = waypoint("Right", { position = { 3000, 0, 1000 } })
+ \\later = waypoint("Later", { position = { 0, 0, 9000 } })
+ \\one = waypoint("One", { group = round, position = { 0, 0, 1 } })
+ \\two = waypoint("Two", { group = round, position = { 0, 0, 2 } })
+ \\three = waypoint("Three", { group = round, position = { 0, 0, 3 } })
+ \\shot = point("Shot", { marker = { curve = path, at = 0.5 } })
+ \\start = part("(F)Start", function()
+ \\ SetAI(raider, "jump_in", 1, entry)
+ \\ SetAI(raider, "fly", 1, target)
+ \\ SetAI(alpha, "jump_out", 1, nav)
+ \\ if game.mission_success == 1 then
+ \\ SetAI(wingman, "fly", 1, left)
+ \\ else
+ \\ SetAI(wingman, "fly", 1, right)
+ \\ end
+ \\ SetAI(wingman, "fly", 1, later)
+ \\ SetPatrolRoute(raiders, round)
+ \\ return 1
+ \\end, { start = true })
+ \\on(target, "ship_reached", { reached_ship = raider }, function(reached_ship)
+ \\ SetAI(raider, "jump_out", 1, out)
+ \\ return 1
+ \\end)
+ \\
+ ;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const made = try outline(arena.allocator(), text);
+ const shown = try scene(arena.allocator(), text, made);
+ const at = struct {
+ fn thing(things: []const game.Thing, known: game.Outline, name: []const u8) usize {
+ const record = known.named(name).?;
+ for (things, 0..) |each, index| if (each.section == record.section and each.index == record.index) return index;
+ unreachable;
+ }
+ };
+ const legsOf = struct {
+ fn route(routes: []const game.Route, things: []const game.Thing, known: game.Outline, name: []const u8) []const game.Leg {
+ const record = known.named(name).?;
+ for (routes) |each| {
+ if (each.ship) |ship| if (things[ship].section == record.section and things[ship].index == record.index) return each.legs;
+ if (each.ship == null and record.section == .flight_groups and each.group == record.group) return each.legs;
+ }
+ return &.{};
+ }
+ };
+ const things = shown.things;
+ // The raider waits far off until it jumps in, so its route starts where it comes in: it flies
+ // to its waypoint, and on reaching it jumps out.
+ try std.testing.expectEqualSlices(game.Leg, &.{
+ .{ .from = at.thing(things, made, "entry"), .to = at.thing(things, made, "target"), .way = .fly, .starts = true },
+ .{ .from = at.thing(things, made, "target"), .to = at.thing(things, made, "out"), .way = .jump },
+ }, legsOf.route(shown.routes, things, made, "raider"));
+ // The player's group jumps to the nav point from where its first ship starts.
+ try std.testing.expectEqualSlices(game.Leg, &.{
+ .{ .from = at.thing(things, made, "player"), .to = at.thing(things, made, "nav"), .way = .jump, .starts = true },
+ }, legsOf.route(shown.routes, things, made, "alpha"));
+ // Either branch flies the wingman from its start; after them it could be at either, so no leg
+ // leads to the next waypoint.
+ try std.testing.expectEqualSlices(game.Leg, &.{
+ .{ .from = at.thing(things, made, "wingman"), .to = at.thing(things, made, "left"), .way = .fly, .starts = true },
+ .{ .from = at.thing(things, made, "wingman"), .to = at.thing(things, made, "right"), .way = .fly, .starts = true },
+ }, legsOf.route(shown.routes, things, made, "wingman"));
+ // The raiders' patrol route goes round its waypoints in the order they're listed, back to the
+ // first. The group comes in nowhere and starts at its first ship, the raider, which jumps in,
+ // so the route starts at the first waypoint.
+ try std.testing.expectEqualSlices(game.Leg, &.{
+ .{ .from = at.thing(things, made, "one"), .to = at.thing(things, made, "two"), .way = .fly, .starts = true },
+ .{ .from = at.thing(things, made, "two"), .to = at.thing(things, made, "three"), .way = .fly },
+ .{ .from = at.thing(things, made, "three"), .to = at.thing(things, made, "one"), .way = .fly },
+ }, legsOf.route(shown.routes, things, made, "raiders"));
+ // A point where a ship comes in is a marker; one that marks a place on a curve shows with the
+ // curves.
+ try std.testing.expectEqual(game.ThingKind.marker, things[at.thing(things, made, "entry")].kind);
+ try std.testing.expectEqual(game.ThingKind.camera, things[at.thing(things, made, "shot")].kind);
+}
+
test fieldForms {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
diff --git a/src/main.zig b/src/main.zig
index 468b0b5..6c0fb89 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -21,6 +21,7 @@ pub const ui = @import("ui.zig");
pub const document = @import("mission/document.zig");
pub const edit = @import("mission/edit.zig");
pub const forms = @import("forms.zig");
+pub const scene_view = @import("views/scene.zig");
pub const script_view = @import("views/script.zig");
pub const start_screen = @import("screens/start.zig");
pub const settings_screen = @import("screens/settings.zig");
@@ -48,7 +49,7 @@ const Options = struct {
check_missions: bool = false,
print_version: bool = false,
- const Show = enum { start, settings, palette, keys, link, problems };
+ const Show = enum { start, settings, palette, keys, link, problems, scene };
const usage =
\\Usage: openreliant-editor [] [options]
@@ -59,7 +60,7 @@ const Options = struct {
\\ --screenshot draw without a window and save a frame as a PNG file
\\ --frames the frames drawn before the picture; 10 by default
\\ --size x the window's size; 1440x900 by default
- \\ --show start, settings, keys, link, palette or problems
+ \\ --show start, settings, keys, link, palette, problems or scene
\\ --check-missions open each of the game's missions as source, build it again,
\\ and check it's the same mission
\\
@@ -137,6 +138,8 @@ pub fn main(init: std.process.Init) !u8 {
const ini_path: ?[:0]const u8 = if (editor_settings.folder) |folder| try std.fmt.allocPrintSentinel(arena, "{s}layout.ini", .{folder}, 0) else null;
var window = try Window.open("OpenReliant Editor", options.width, options.height, if (ini_path) |path| path.ptr else null, options.screenshot != null);
defer window.close();
+ ui.plot.create();
+ defer ui.plot.destroy();
ui.fonts.load();
ui.theme.apply(window.scale);
ui.gui.scale = window.scale;
@@ -164,6 +167,7 @@ pub fn main(init: std.process.Init) !u8 {
},
.palette => editor.palette.show(.commands),
.problems => editor.showPanel(.problems),
+ .scene => editor.run(.view_2),
};
var frame: u32 = 0;
diff --git a/src/mission/document.zig b/src/mission/document.zig
index 720461a..6513fc3 100644
--- a/src/mission/document.zig
+++ b/src/mission/document.zig
@@ -65,6 +65,8 @@ pub const Document = struct {
good: Check,
/// Whether the outline is from older source than the text's.
outline_stale: bool = false,
+ /// A number that changes whenever `good` does.
+ outline_generation: usize = 0,
built: ?Build = null,
/// The listing of the last build's script, in its own arena.
listing: ?[]const u8 = null,
@@ -78,6 +80,10 @@ pub const Document = struct {
/// from. Its strings are in `queue_arena`.
queued: ?Queued = null,
queue_arena: std.heap.ArenaAllocator,
+ /// What the map shows of the text (`scene`), in its own arena, with the text's version and the
+ /// outline's generation it was read from.
+ scene_read: ?struct { version: usize, generation: usize, scene: game.Scene } = null,
+ scene_arena: std.heap.ArenaAllocator,
/// Opens the source file at `path`.
pub fn open(gpa: Allocator, io: Io, path: []const u8, profile: *const game.Profile, language: ?text_editor.Language) !*Document {
@@ -104,6 +110,7 @@ pub const Document = struct {
.good = .{ .arena = .init(gpa) },
.listing_arena = .init(gpa),
.queue_arena = .init(gpa),
+ .scene_arena = .init(gpa),
};
document.recheck() catch {};
return document;
@@ -115,6 +122,7 @@ pub const Document = struct {
document.good.deinit();
document.listing_arena.deinit();
document.queue_arena.deinit();
+ document.scene_arena.deinit();
document.gpa.free(document.path);
document.gpa.destroy(document);
}
@@ -133,6 +141,21 @@ pub const Document = struct {
return document.good.outline;
}
+ /// What the map shows of the mission (`Profile.scene`), read again only once the text or its
+ /// outline changed, since reading it parses the whole script.
+ pub fn scene(document: *Document) Allocator.Error!game.Scene {
+ const version = document.editor.version();
+ if (document.scene_read) |read| {
+ if (read.version == version and read.generation == document.outline_generation) return read.scene;
+ }
+ document.scene_read = null;
+ _ = document.scene_arena.reset(.retain_capacity);
+ const arena = document.scene_arena.allocator();
+ const made = try document.profile.scene(arena, try arena.dupe(u8, document.editor.text()), document.outline());
+ document.scene_read = .{ .version = version, .generation = document.outline_generation, .scene = made };
+ return made;
+ }
+
/// The build of the text as last checked, with the map of its script's statements; null while
/// the text has problems.
pub fn mission(document: *const Document) ?game.Mission {
@@ -186,6 +209,7 @@ pub const Document = struct {
good.outline = try copyOutline(good.arena.allocator(), check.outline);
document.good.deinit();
document.good = good;
+ document.outline_generation +%= 1;
document.outline_stale = false;
} else {
document.outline_stale = true;
diff --git a/src/panels.zig b/src/panels.zig
index 23bc179..6025c2b 100644
--- a/src/panels.zig
+++ b/src/panels.zig
@@ -15,6 +15,7 @@ const commands = @import("commands.zig");
const game = @import("game.zig");
const Document = @import("mission/document.zig").Document;
const forms = @import("forms.zig");
+const scene_view = @import("views/scene.zig");
const script_view = @import("views/script.zig");
const ui = @import("ui.zig");
const fonts = ui.fonts;
@@ -36,7 +37,10 @@ pub fn draw(app: *App) void {
switch (panel) {
.mission => outline(app),
.find => find(app),
- .source => script_view.draw(app),
+ .source => switch (app.view) {
+ .scene => scene_view.draw(app),
+ .script, .blocks => script_view.draw(app),
+ },
.threads => threads(app),
.breakpoints => breakpoints(app),
.output => output(app),
@@ -737,12 +741,7 @@ fn liveScene() void {
fn inspector(app: *App) void {
const document = app.document orelse return waiting(.sliders_horizontal, "No mission open", null);
const known = document.outline();
- const chosen = app.selection orelse blk: {
- // Without a selection, the record the cursor is in.
- const record = known.at(@intCast(document.editor.cursor().line)) orelse return waiting(.sliders_horizontal, "Select a record in the outline or the source", null);
- break :blk app_mod.Selection{ .section = record.section, .line = record.first_line };
- };
- const record = known.find(chosen.section, chosen.line) orelse return waiting(.sliders_horizontal, "Select a record in the outline or the source", null);
+ const record = app.inspected() orelse return waiting(.sliders_horizontal, "Select a record in the outline, the source or the map", null);
gui.pushVar2(c.ImGuiStyleVar_WindowPadding, .{ .x = px(12), .y = px(14) });
// The forms take the panel's whole width, as the design's do: it scrolls without a bar.
_ = c.ImGui_BeginChild("inspector", .{ .x = 0, .y = 0 }, c.ImGuiChildFlags_AlwaysUseWindowPadding, c.ImGuiWindowFlags_NoScrollbar);
diff --git a/src/platform/c.h b/src/platform/c.h
index 4d8092c..9ae2e1d 100644
--- a/src/platform/c.h
+++ b/src/platform/c.h
@@ -1,6 +1,6 @@
/* The C interfaces the editor uses, which the build translates into one Zig module: SDL3, Dear
* ImGui through Dear Bindings with its SDL3 and SDL_GPU backends, the few internal calls the
- * editor makes, and the text editor's shim. */
+ * editor makes, and the shims of the text editor and of ImPlot. */
#include
#include "dcimgui.h"
@@ -8,4 +8,5 @@
#include "dcimgui_impl_sdlgpu3.h"
#include "extras.h"
+#include "plot_shim.h"
#include "shim.h"
diff --git a/src/screens/settings.zig b/src/screens/settings.zig
index 4103acb..2a785fb 100644
--- a/src/screens/settings.zig
+++ b/src/screens/settings.zig
@@ -186,7 +186,7 @@ fn linkPage(app: *App) void {
defer row.end();
const labels = [_][]const u8{ "Off", "1 Hz", "4 Hz" };
row.centre(choice_height);
- if (choice("state rate", &labels, @backingInt(link.state_rate))) |chosen| {
+ if (widgets.choice("state rate", &labels, @backingInt(link.state_rate), .{})) |chosen| {
link.state_rate = @fromBackingInt(@intCast(chosen));
app.settings.save(app.io);
}
@@ -310,38 +310,6 @@ fn portField(app: *App) void {
}
}
-/// The design's choice of a few, the settings' `.seg`: each option as wide as its label, the one
-/// chosen lit. The option clicked, if one is.
-fn choice(id: []const u8, options: []const []const u8, current: usize) ?usize {
- gui.pushId(id);
- defer gui.popId();
- gui.sans(.regular, fonts.size.ui);
- defer gui.popFont();
- var width = px(3) * 2 + px(2) * @as(f32, @floatFromInt(options.len - 1));
- for (options) |option| width += gui.textSize(option).x + px(12) * 2;
- const height = px(30);
- const at = gui.cursor();
- const list = gui.drawList();
- gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.field, 5);
- gui.outline(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.border, 5);
- var chosen: ?usize = null;
- var x = at.x + px(3);
- for (options, 0..) |option, index| {
- const item_width = gui.textSize(option).x + px(12) * 2;
- gui.setCursor(.{ .x = x, .y = at.y + px(3) });
- gui.pushIdInt(index);
- if (c.ImGui_InvisibleButton("option", .{ .x = item_width, .y = px(24) }, 0)) chosen = index;
- gui.popId();
- const on = index == current;
- if (on) gui.rect(list, .{ .x = x, .y = at.y + px(3) }, .{ .x = x + item_width, .y = at.y + px(27) }, theme.border, 3);
- gui.drawText(list, .{ .x = x + px(12), .y = gui.textTop(at.y + height / 2) }, if (on) theme.white else theme.text_muted, option);
- x += item_width + px(2);
- }
- gui.setCursor(at);
- c.ImGui_Dummy(.{ .x = width, .y = height });
- return chosen;
-}
-
/// 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);
diff --git a/src/shell.zig b/src/shell.zig
index 16fc910..18f1a40 100644
--- a/src/shell.zig
+++ b/src/shell.zig
@@ -12,6 +12,7 @@ const commands = @import("commands.zig");
const Command = commands.Command;
const keys = @import("keys.zig");
const panels = @import("panels.zig");
+const scene_view = @import("views/scene.zig");
const ui = @import("ui.zig");
const version = @import("version.zig");
const fonts = ui.fonts;
@@ -538,7 +539,9 @@ fn statusBar(app: *App) void {
x += gui.textSize(text).x + gap;
}
const cursor = document.editor.cursor();
- for ([_][]const u8{ document.file, gui.print("Ln {d}, Col {d}", .{ cursor.line + 1, cursor.index + 1 }), keyHints(app) }) |part| {
+ // The Scene view shows the map and the place under the pointer instead of the cursor.
+ const where = if (app.view == .scene) scene_view.status() else gui.print("Ln {d}, Col {d}", .{ cursor.line + 1, cursor.index + 1 });
+ for ([_][]const u8{ document.file, where, keyHints(app) }) |part| {
gui.drawText(list, .{ .x = x, .y = text_y }, theme.text_muted, part);
x += gui.textSize(part).x + gap;
}
diff --git a/src/ui.zig b/src/ui.zig
index e2672ae..5c939b0 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -1,11 +1,12 @@
//! The editor's interface kit on Dear ImGui: the design's colours, fonts and icons, the widgets
-//! the screens are made of, and the text editor.
+//! the screens are made of, the text editor, and the map's plot.
const std = @import("std");
pub const fonts = @import("ui/fonts.zig");
pub const gui = @import("ui/gui.zig");
pub const icons = @import("ui/icons.zig");
+pub const plot = @import("ui/plot.zig");
pub const text_editor = @import("ui/text_editor.zig");
pub const theme = @import("ui/theme.zig");
pub const widgets = @import("ui/widgets.zig");
diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig
index 7e1fd45..36c1943 100644
--- a/src/ui/fonts.zig
+++ b/src/ui/fonts.zig
@@ -127,11 +127,15 @@ pub fn iconFont() *c.ImFont {
var icon_font: *c.ImFont = undefined;
-/// A font's height above and below its baseline, in ems, from its `hhea` and `head` tables.
+/// A font's height above and below its baseline, and its capitals' height, in ems, from its `hhea`,
+/// `head` and `OS/2` tables.
pub const Metrics = struct {
/// The height above the baseline and below it, as a stylesheet lays out a line of text.
ascent: f32,
descent: f32,
+ /// The capitals' height above the baseline, which the figures share in the editor's fonts; 0
+ /// where the font doesn't give it.
+ cap_height: f32,
/// How much larger ImGui's size of the font is than its em. ImGui sizes a font by its ascent
/// less its descent as the file gives them, a stylesheet by its em, so a font pushed at the
/// design's size times this draws as large as in the design.
@@ -143,6 +147,7 @@ pub const Metrics = struct {
var units: u16 = 0;
var ascent: i16 = 0;
var descent: i16 = 0;
+ var cap_height: i16 = 0;
for (0..tables) |index| {
const record = 12 + index * 16;
const offset = std.mem.readInt(u32, data[record + 8 ..][0..4], .big);
@@ -151,6 +156,10 @@ pub const Metrics = struct {
ascent = std.mem.readInt(i16, data[offset + 4 ..][0..2], .big);
descent = std.mem.readInt(i16, data[offset + 6 ..][0..2], .big);
}
+ // `sCapHeight` came in the table's second version.
+ if (std.mem.eql(u8, data[record..][0..4], "OS/2") and std.mem.readInt(u16, data[offset..][0..2], .big) >= 2) {
+ cap_height = std.mem.readInt(i16, data[offset + 88 ..][0..2], .big);
+ }
}
const em_units: f32 = @floatFromInt(units);
return .{
@@ -158,6 +167,7 @@ pub const Metrics = struct {
// Newtown gives its descent as a positive number; browsers take it as below the
// baseline all the same.
.descent = @as(f32, @floatFromInt(@abs(descent))) / em_units,
+ .cap_height = @as(f32, @floatFromInt(cap_height)) / em_units,
.em = @as(f32, @floatFromInt(@as(i32, ascent) - descent)) / em_units,
};
}
@@ -186,6 +196,9 @@ test Metrics {
try std.testing.expectEqual(13, Family.sans.metrics().baselineInLine(13, 13 * line_height));
try std.testing.expectApproxEqAbs(0.19, Family.brand.metrics().descent, 1e-6);
try std.testing.expectEqual(16, Family.brand.metrics().baselineInLine(16, 16 * line_height));
+ // Plex's capitals and figures are 0.698 of its em high, JetBrains Mono's 0.73.
+ try std.testing.expectApproxEqAbs(0.698, Family.sans.metrics().cap_height, 1e-6);
+ try std.testing.expectApproxEqAbs(0.73, Family.mono.metrics().cap_height, 1e-6);
}
test face {
diff --git a/src/ui/gui.zig b/src/ui/gui.zig
index 6c64614..d2847c9 100644
--- a/src/ui/gui.zig
+++ b/src/ui/gui.zig
@@ -80,6 +80,23 @@ pub fn textTopIn(middle: f32, line_pixels: ?f32) f32 {
return @round(middle - line_height / 2 + face.family.metrics().baselineInLine(size, line_height)) - ascent();
}
+/// Where a line of text in the current font goes for its capitals and figures to sit in the middle
+/// of a box whose middle is at `middle`, such as a count in a badge, which has no small letters to
+/// centre. The baseline goes on a whole pixel, since ImGui draws text from whole pixels.
+pub fn capitalsTop(middle: f32) f32 {
+ return @round(middle + capitals() / 2) - ascent();
+}
+
+/// How high the current font's capitals and figures are; for a font that isn't one of the faces,
+/// as high as Plex's would be at its size.
+pub fn capitals() f32 {
+ const face = fonts.current() orelse {
+ const plex = fonts.Family.sans.metrics();
+ return plex.cap_height / plex.em * c.ImGui_GetFontSize();
+ };
+ return face.family.metrics().cap_height * px(face.size);
+}
+
/// How far below the top of a line of the current font its baseline is, as ImGui draws it.
pub fn ascent() f32 {
return c.or_imgui_font_ascent();
@@ -200,6 +217,11 @@ pub fn popVar(count: c_int) void {
c.ImGui_PopStyleVarEx(count);
}
+/// Where the last item ends at its right.
+pub fn lastItemRight() f32 {
+ return c.ImGui_GetItemRectMax().x;
+}
+
pub fn cursor() Vec2 {
return c.ImGui_GetCursorScreenPos();
}
diff --git a/src/ui/icons.zig b/src/ui/icons.zig
index efeab6f..8b1eae3 100644
--- a/src/ui/icons.zig
+++ b/src/ui/icons.zig
@@ -7,9 +7,13 @@ const std = @import("std");
pub const Icon = enum(u21) {
archive = 0xe041,
+ arrow_down = 0xe042,
arrow_down_to_dot = 0xe44d,
arrow_left = 0xe048,
+ arrow_right = 0xe049,
arrow_right_to_line = 0xe459,
+ arrow_up = 0xe04a,
+ arrow_up_right = 0xe04d,
audio_waveform = 0xe55b,
blocks = 0xe4fa,
book = 0xe05e,
@@ -26,6 +30,7 @@ pub const Icon = enum(u21) {
chevron_up = 0xe070,
circle = 0xe076,
circle_alert = 0xe077,
+ circle_dashed = 0xe4b0,
circle_dot = 0xe345,
circle_help = 0xe082,
clipboard_paste = 0xe3e8,
@@ -73,6 +78,7 @@ pub const Icon = enum(u21) {
pencil = 0xe1f9,
play = 0xe13c,
plus = 0xe13d,
+ radio = 0xe142,
redo_2 = 0xe2a0,
redo_dot = 0xe450,
refresh_cw = 0xe145,
@@ -80,10 +86,12 @@ pub const Icon = enum(u21) {
route = 0xe53e,
save = 0xe14d,
save_all = 0xe40f,
+ scan = 0xe257,
scissors = 0xe14e,
search = 0xe151,
settings = 0xe154,
sliders_horizontal = 0xe29a,
+ spline = 0xe38b,
square = 0xe167,
step_forward = 0xe3ea,
terminal = 0xe181,
diff --git a/src/ui/plot.zig b/src/ui/plot.zig
new file mode 100644
index 0000000..d0e8d34
--- /dev/null
+++ b/src/ui/plot.zig
@@ -0,0 +1,84 @@
+//! ImPlot through its C shim (`plot/plot_shim.h`): the plot the Scene view's map draws on, with
+//! equal axes, which the pointer pans, and points it drags. The map zooms itself
+//! (`views/scene.zig`).
+
+const std = @import("std");
+const c = @import("c");
+
+const theme = @import("theme.zig");
+
+pub const Vec2 = c.ImVec2;
+pub const Rect = c.OrPlotRect;
+
+/// Makes ImPlot's context, once Dear ImGui's is made, on the map's ground. The map draws its own
+/// grid (`views/scene/drawing.zig`).
+pub fn create() void {
+ c.or_plot_create();
+ c.or_plot_style(theme.map);
+}
+
+pub fn destroy() void {
+ c.or_plot_destroy();
+}
+
+/// Begins a plot `size` pixels across and down. With `shown`, its axes show it from this frame
+/// on. False where it isn't drawn; `end` ends it otherwise.
+pub fn begin(id: [:0]const u8, size: Vec2, shown: ?Rect) bool {
+ return c.or_plot_begin(id, size.x, size.y, shown != null, shown orelse std.mem.zeroes(Rect));
+}
+
+pub fn end() void {
+ c.or_plot_end();
+}
+
+/// What the axes show.
+pub fn limits() Rect {
+ return c.or_plot_limits();
+}
+
+/// The plot's area on the screen: its top left and bottom right corners.
+pub fn area() [2]Vec2 {
+ var rect: [4]f32 = undefined;
+ c.or_plot_area(&rect);
+ return .{ .{ .x = rect[0], .y = rect[1] }, .{ .x = rect[2], .y = rect[3] } };
+}
+
+/// The place on the screen of `at` on the plot.
+pub fn toPixels(at: [2]f64) Vec2 {
+ var pixels: [2]f32 = undefined;
+ c.or_plot_to_pixels(at[0], at[1], &pixels);
+ return .{ .x = pixels[0], .y = pixels[1] };
+}
+
+/// The place on the plot of `at` on the screen.
+pub fn fromPixels(at: Vec2) [2]f64 {
+ var place: [2]f64 = undefined;
+ c.or_plot_from_pixels(at.x, at.y, &place);
+ return place;
+}
+
+/// The draw list over the plot, clipped to its area until `popClip`.
+pub fn drawList() *c.ImDrawList {
+ c.or_plot_push_clip();
+ return @ptrCast(@alignCast(c.or_plot_draw_list().?));
+}
+
+pub fn popClip() void {
+ c.or_plot_pop_clip();
+}
+
+/// What the pointer did to a point (`dragPoint`).
+pub const Drag = struct {
+ clicked: bool = false,
+ hovered: bool = false,
+ /// Whether the pointer holds it.
+ held: bool = false,
+};
+
+/// A point `radius` pixels around `at`, which the pointer can click and drag; a drag moves `at`.
+/// The caller draws it.
+pub fn dragPoint(id: usize, at: *[2]f64, radius: f32) Drag {
+ var drag: Drag = .{};
+ _ = c.or_plot_drag_point(@intCast(id), &at[0], &at[1], radius, &drag.clicked, &drag.hovered, &drag.held);
+ return drag;
+}
diff --git a/src/ui/plot/plot_shim.cpp b/src/ui/plot/plot_shim.cpp
new file mode 100644
index 0000000..d4d1854
--- /dev/null
+++ b/src/ui/plot/plot_shim.cpp
@@ -0,0 +1,77 @@
+// ImPlot through the editor's C interface (`plot_shim.h`).
+
+#include "plot_shim.h"
+
+#include "imgui.h"
+#include "implot.h"
+
+extern "C" {
+
+void or_plot_create(void) { ImPlot::CreateContext(); }
+
+void or_plot_destroy(void) { ImPlot::DestroyContext(); }
+
+void or_plot_style(unsigned int background) {
+ ImPlotStyle &style = ImPlot::GetStyle();
+ style.PlotPadding = ImVec2(0, 0);
+ style.PlotBorderSize = 0;
+ style.Colors[ImPlotCol_FrameBg] = ImVec4(0, 0, 0, 0);
+ style.Colors[ImPlotCol_PlotBg] = ImGui::ColorConvertU32ToFloat4(background);
+ // The editor zooms around the pointer itself, over the points too, and fits on a double click
+ // with its own fit: ImPlot's zoom does nothing, and its fit waits for a middle double click.
+ ImPlotInputMap &input = ImPlot::GetInputMap();
+ input.ZoomRate = 0;
+ input.Fit = ImGuiMouseButton_Middle;
+}
+
+bool or_plot_begin(const char *id, float width, float height, bool set, OrPlotRect limits) {
+ if (!ImPlot::BeginPlot(id, ImVec2(width, height), ImPlotFlags_CanvasOnly | ImPlotFlags_Equal)) return false;
+ const ImPlotAxisFlags axes = ImPlotAxisFlags_NoDecorations | ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight |
+ ImPlotAxisFlags_NoSideSwitch;
+ ImPlot::SetupAxes(nullptr, nullptr, axes, axes);
+ if (set) ImPlot::SetupAxesLimits(limits.x_min, limits.x_max, limits.y_min, limits.y_max, ImPlotCond_Always);
+ // The axes settle now, so that the plot's places are known before anything is drawn.
+ ImPlot::SetupFinish();
+ return true;
+}
+
+void or_plot_end(void) { ImPlot::EndPlot(); }
+
+OrPlotRect or_plot_limits(void) {
+ const ImPlotRect limits = ImPlot::GetPlotLimits();
+ return OrPlotRect{limits.X.Min, limits.X.Max, limits.Y.Min, limits.Y.Max};
+}
+
+void or_plot_area(float rect[4]) {
+ const ImVec2 at = ImPlot::GetPlotPos();
+ const ImVec2 size = ImPlot::GetPlotSize();
+ rect[0] = at.x;
+ rect[1] = at.y;
+ rect[2] = at.x + size.x;
+ rect[3] = at.y + size.y;
+}
+
+void or_plot_to_pixels(double x, double y, float pixels[2]) {
+ const ImVec2 at = ImPlot::PlotToPixels(x, y);
+ pixels[0] = at.x;
+ pixels[1] = at.y;
+}
+
+void or_plot_from_pixels(float x, float y, double place[2]) {
+ const ImPlotPoint at = ImPlot::PixelsToPlot(x, y);
+ place[0] = at.x;
+ place[1] = at.y;
+}
+
+void *or_plot_draw_list(void) { return ImPlot::GetPlotDrawList(); }
+
+void or_plot_push_clip(void) { ImPlot::PushPlotClipRect(); }
+
+void or_plot_pop_clip(void) { ImPlot::PopPlotClipRect(); }
+
+bool or_plot_drag_point(int id, double *x, double *y, float radius, bool *clicked, bool *hovered, bool *held) {
+ // Drawn clear: the caller draws the marker.
+ return ImPlot::DragPoint(id, x, y, ImVec4(0, 0, 0, 0), radius, ImPlotDragToolFlags_NoFit, clicked, hovered, held);
+}
+
+} // extern "C"
diff --git a/src/ui/plot/plot_shim.h b/src/ui/plot/plot_shim.h
new file mode 100644
index 0000000..c992f71
--- /dev/null
+++ b/src/ui/plot/plot_shim.h
@@ -0,0 +1,48 @@
+/* ImPlot, which the Scene view's map draws with, through a C interface for the editor's Zig code
+ * (`plot.zig`): a plot with equal axes, what its axes show, its places on the screen, and points
+ * to drag. Places on the plot are doubles; on the screen, ImGui's pixels. */
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* The part of the plot its axes show. */
+typedef struct OrPlotRect {
+ double x_min, x_max, y_min, y_max;
+} OrPlotRect;
+
+void or_plot_create(void);
+void or_plot_destroy(void);
+
+/* The plots' look, their background an ImGui packed colour, with no padding and no border; and their
+ * input, where the pointer pans them while the wheel and double clicks are left to the editor. */
+void or_plot_style(unsigned int background);
+
+/* Begins a plot `width` by `height` pixels, with equal axes and nothing else: no frame, grid,
+ * labels, ticks, menus or box zoom. With `set`, the axes show `limits` from this frame on. False
+ * where it isn't drawn; otherwise `or_plot_end` ends it. */
+bool or_plot_begin(const char *id, float width, float height, bool set, OrPlotRect limits);
+void or_plot_end(void);
+
+/* What the axes show, the plot's area on the screen, and places between the two. */
+OrPlotRect or_plot_limits(void);
+void or_plot_area(float rect[4]);
+void or_plot_to_pixels(double x, double y, float pixels[2]);
+void or_plot_from_pixels(float x, float y, double place[2]);
+
+/* The draw list over the plot, clipped to its area between `or_plot_push_clip` and
+ * `or_plot_pop_clip`. */
+void *or_plot_draw_list(void);
+void or_plot_push_clip(void);
+void or_plot_pop_clip(void);
+
+/* A point the pointer can drag, `radius` pixels around (`x`, `y`), which the caller draws: true
+ * while a drag moves it. */
+bool or_plot_drag_point(int id, double *x, double *y, float radius, bool *clicked, bool *hovered, bool *held);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/ui/theme.zig b/src/ui/theme.zig
index 9f41020..6a8f61d 100644
--- a/src/ui/theme.zig
+++ b/src/ui/theme.zig
@@ -22,6 +22,18 @@ pub fn fade(color: Color, opacity: f32) Color {
return (color & 0x00ff_ffff) | a << 24;
}
+/// `color` mixed with white, `amount` of the way from 0 to 1: the lighter text of a flight group's
+/// colour.
+pub fn lighten(color: Color, amount: f32) Color {
+ var result: Color = color & 0xff00_0000;
+ for ([_]u5{ 0, 8, 16 }) |shift| {
+ const value: f32 = @floatFromInt((color >> shift) & 0xff);
+ const mixed: u32 = @intFromFloat(value + (255 - value) * std.math.clamp(amount, 0, 1) + 0.5);
+ result |= mixed << shift;
+ }
+ return result;
+}
+
pub fn vec4(color: Color) c.ImVec4 {
return .{
.x = @as(f32, @floatFromInt(color & 0xff)) / 255,
@@ -46,6 +58,13 @@ pub const bar = rgb(0x0c0f15);
pub const toolbar = rgb(0x0e1118);
/// Code, the source views and the inspector's code.
pub const code = rgb(0x0b0d12);
+/// The map's ground and its grid: a line every few cells, and the cells' lines between them.
+pub const map = rgb(0x0a0c11);
+pub const map_grid = rgb(0x202839);
+pub const map_grid_minor = rgb(0x131824);
+/// A trigger's range's label on the map, and a curve, in the dim text's colour.
+pub const range_text = rgb(0xf4b27a);
+pub const curve = text_dim;
/// The line under the source's tabs that says where the cursor is.
pub const breadcrumbs = rgb(0x0d1017);
/// Fields, search boxes, and switches' tracks.
@@ -266,3 +285,10 @@ test rgb {
try std.testing.expectEqual(@as(Color, 0xff2c8af0), rgb(0xf08a2c));
try std.testing.expectEqual(@as(Color, 0x242c8af0), fade(rgb(0xf08a2c), 0.14));
}
+
+test lighten {
+ // The player's blue, nearly half way to white: the design's light blue.
+ try std.testing.expectEqual(rgb(0xa8d1ff), lighten(rgb(0x5aa9ff), 0.47));
+ try std.testing.expectEqual(rgb(0x5aa9ff), lighten(rgb(0x5aa9ff), 0));
+ try std.testing.expectEqual(rgb(0xffffff), lighten(rgb(0x5aa9ff), 1));
+}
diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig
index ba31dac..d8fdc50 100644
--- a/src/ui/widgets.zig
+++ b/src/ui/widgets.zig
@@ -430,6 +430,31 @@ pub const PanelTab = struct {
}
};
+/// A panel's own tab bar, as the source panel's: `tabs` side by side from its left on the bar's
+/// colour, with its line under them. The tab clicked, if one is.
+pub fn panelTabs(tabs: []const PanelTab) ?usize {
+ const at = gui.cursor();
+ const width = gui.available().x;
+ const height = px(tab_bar_height);
+ const list = gui.drawList();
+ gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.bar, 0);
+ gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.tab_divider, 0);
+ var clicked: ?usize = null;
+ var x = at.x;
+ for (tabs, 0..) |tab, index| {
+ const tab_width = tab.width();
+ gui.setCursor(.{ .x = x, .y = at.y });
+ gui.pushIdInt(index);
+ if (c.ImGui_InvisibleButton("tab", .{ .x = tab_width, .y = height - px(1) }, 0)) clicked = index;
+ gui.popId();
+ tab.draw(list, .{ .x = x, .y = at.y }, .{ .x = x + tab_width, .y = at.y + height - px(1) }, gui.hovered());
+ if (tab.dot) gui.tooltip("Changed since saved");
+ x += tab_width;
+ }
+ gui.setCursor(.{ .x = at.x, .y = at.y + height });
+ return clicked;
+}
+
pub const ViewTab = struct {
label: []const u8,
icon: Icon,
@@ -699,11 +724,13 @@ pub const Frame = struct {
mark: RowMark = .none,
chevron: bool = true,
disabled: ?Disabled = null,
+ /// Its width; by default the space left on the line.
+ width: ?f32 = null,
};
/// The design's `.frame`: a field that opens a list, as the inspector's.
pub fn frame(id: []const u8, shown: Frame) bool {
- const width = gui.available().x;
+ const width = shown.width orelse gui.available().x;
const height = px(26);
const at = gui.cursor();
const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = @max(width, 1), .y = height }, 0) and shown.disabled == null;
@@ -763,7 +790,7 @@ pub fn pick(id: []const u8, shown: Frame, choices: []const Choice) ?usize {
gui.pushId(id);
defer gui.popId();
const at = gui.cursor();
- const width = gui.available().x;
+ const width = shown.width orelse gui.available().x;
if (frame("frame", shown)) _ = c.ImGui_OpenPopup("choices", 0);
c.ImGui_SetNextWindowPos(.{ .x = at.x, .y = at.y + px(26) + px(2) }, c.ImGuiCond_Always);
c.ImGui_SetNextWindowSizeConstraints(.{ .x = width, .y = 0 }, .{ .x = @max(width, px(240)), .y = px(320) }, null, null);
@@ -783,18 +810,18 @@ pub fn pick(id: []const u8, shown: Frame, choices: []const Choice) ?usize {
}
const term = std.mem.sliceTo(&Find.text, 0);
var chosen: ?usize = null;
- for (choices, 0..) |choice, index| {
- if (finding and term.len > 0 and std.ascii.findIgnoreCase(choice.label, term) == null) continue;
+ for (choices, 0..) |item, index| {
+ if (finding and term.len > 0 and std.ascii.findIgnoreCase(item.label, term) == null) continue;
gui.pushIdInt(index);
defer gui.popId();
- if (choiceRow(choice)) chosen = index;
+ if (choiceRow(item)) chosen = index;
}
if (chosen != null) c.ImGui_CloseCurrentPopup();
return chosen;
}
/// A row of a list to choose from, as `pick`'s. True when clicked.
-pub fn choiceRow(choice: Choice) bool {
+pub fn choiceRow(item: Choice) bool {
const width = gui.available().x;
const height = px(26);
const at = gui.cursor();
@@ -802,12 +829,12 @@ pub fn choiceRow(choice: Choice) bool {
const list = gui.drawList();
if (gui.hovered()) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.row_hover, 3);
const middle = at.y + height / 2;
- var x = drawMark(list, at.x + px(8), middle, choice.mark);
- if (choice.mono) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui);
- gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, if (choice.dim) theme.text_dim else theme.text, choice.label);
- x += gui.textSize(choice.label).x + px(7);
+ var x = drawMark(list, at.x + px(8), middle, item.mark);
+ if (item.mono) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui);
+ gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, if (item.dim) theme.text_dim else theme.text, item.label);
+ x += gui.textSize(item.label).x + px(7);
gui.popFont();
- if (choice.detail) |detail| {
+ if (item.detail) |detail| {
gui.mono(.regular, fonts.size.ui);
gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, detail);
gui.popFont();
@@ -920,6 +947,73 @@ pub fn popBareInput() void {
gui.popColor(4);
}
+/// How `choice` draws: the box's height, the space each side of a label, the labels' size, and the
+/// corners' radius. The defaults are the settings' `.seg`.
+pub const ChoiceLook = struct {
+ height: f32 = 30,
+ padding: f32 = 12,
+ font: f32 = fonts.size.ui,
+ rounding: f32 = 5,
+};
+
+/// The design's choice of a few, as the settings' `.seg` and the map's views: each option as wide
+/// as its label, the one chosen lit. The option clicked, if one is.
+pub fn choice(id: []const u8, options: []const []const u8, current: usize, look: ChoiceLook) ?usize {
+ gui.pushId(id);
+ defer gui.popId();
+ gui.sans(.regular, look.font);
+ defer gui.popFont();
+ // The options sit 3 pixels inside the box's line, 2 pixels apart.
+ const inset = px(3);
+ var width = inset * 2 + px(2) * @as(f32, @floatFromInt(options.len - 1));
+ for (options) |option| width += gui.textSize(option).x + px(look.padding) * 2;
+ const height = px(look.height);
+ const at = gui.cursor();
+ const list = gui.drawList();
+ gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.field, look.rounding);
+ gui.outline(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.border, look.rounding);
+ var chosen: ?usize = null;
+ var x = at.x + inset;
+ for (options, 0..) |option, index| {
+ const item_width = gui.textSize(option).x + px(look.padding) * 2;
+ gui.setCursor(.{ .x = x, .y = at.y + inset });
+ gui.pushIdInt(index);
+ if (c.ImGui_InvisibleButton("option", .{ .x = item_width, .y = height - inset * 2 }, 0)) chosen = index;
+ gui.popId();
+ const on = index == current;
+ if (on) gui.rect(list, .{ .x = x, .y = at.y + inset }, .{ .x = x + item_width, .y = at.y + height - inset }, theme.border, 3);
+ gui.drawText(list, .{ .x = x + px(look.padding), .y = gui.textTop(at.y + height / 2) }, if (on) theme.white else theme.text_muted, option);
+ x += item_width + px(2);
+ }
+ gui.setCursor(at);
+ c.ImGui_Dummy(.{ .x = width, .y = height });
+ return chosen;
+}
+
+/// 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();
+ const icon_size = fonts.size.icon_small;
+ const width = px(9) + px(icon_size) + px(6) + gui.textSize(label).x + px(9);
+ 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 list = gui.drawList();
+ const max = gui.vec2(at.x + width, at.y + height);
+ const lit = on and 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 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);
+ return clicked;
+}
+
/// The design's segmented switch, such as a trigger's Repeats: Once, Always, Counted.
pub fn segmented(id: []const u8, options: []const []const u8, current: usize, disabled: ?Disabled) ?usize {
gui.pushId(id);
diff --git a/src/views/scene.zig b/src/views/scene.zig
new file mode 100644
index 0000000..a3e6925
--- /dev/null
+++ b/src/views/scene.zig
@@ -0,0 +1,857 @@
+//! The Scene view: the mission's map, from above, from the side or from the front, with its ships
+//! and markers where they start in their flight groups' colours, the ranges its triggers watch, its
+//! curves, and the routes its script sends ships along (`game.Scene`). Things that would overlap on
+//! the screen show as one mark, a cluster, which opens with a click, so that a mission spread over
+//! areas far apart shows whole. The pointer pans and zooms the map. A ship or a marker dragged
+//! moves where it starts, which rewrites its place in the source as one step of Undo, and what the
+//! map selects is what the outline and the inspector show. `scene/drawing.zig` draws the map's
+//! parts.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const c = @import("c");
+
+const app_mod = @import("../app.zig");
+const App = app_mod.App;
+const commands = @import("../commands.zig");
+const drawing = @import("scene/drawing.zig");
+const Document = @import("../mission/document.zig").Document;
+const game = @import("../game.zig");
+const source_edit = @import("../mission/edit.zig");
+const panels = @import("../panels.zig");
+const ui = @import("../ui.zig");
+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;
+
+/// How the map looks at the mission.
+pub const Projection = enum {
+ top,
+ side,
+ front,
+
+ /// The map's place of `place`, in the game's coordinates, where Y points down: its horizontal
+ /// coordinate, then its vertical one, which points up.
+ pub fn project(projection: Projection, place: [3]f64) [2]f64 {
+ return switch (projection) {
+ .top => .{ place[0], place[2] },
+ .side => .{ place[2], -place[1] },
+ .front => .{ place[0], -place[1] },
+ };
+ }
+
+ /// The game's place of `at` on the map, with the coordinate the map doesn't show kept from
+ /// `kept`.
+ pub fn unproject(projection: Projection, at: [2]f64, kept: [3]f64) [3]f64 {
+ return switch (projection) {
+ .top => .{ at[0], kept[1], at[1] },
+ .side => .{ kept[0], -at[1], at[0] },
+ .front => .{ at[0], -at[1], kept[2] },
+ };
+ }
+
+ /// The game's coordinates the map shows, across and up, by their index in a place.
+ fn axes(projection: Projection) [2]usize {
+ return switch (projection) {
+ .top => .{ 0, 2 },
+ .side => .{ 2, 1 },
+ .front => .{ 0, 1 },
+ };
+ }
+
+ /// The note in the map's corner, which says which way its axes point.
+ fn note(projection: Projection) []const u8 {
+ return switch (projection) {
+ .top => "X right · Z up",
+ .side => "Z right · Y down",
+ .front => "X right · Y down",
+ };
+ }
+
+ fn label(projection: Projection) []const u8 {
+ return switch (projection) {
+ .top => "Top",
+ .side => "Side",
+ .front => "Front",
+ };
+ }
+};
+
+/// A record the map places, by its section and its place among the section's.
+const Ref = struct {
+ section: game.Section,
+ index: usize,
+
+ fn of(thing: game.Thing) Ref {
+ return .{ .section = thing.section, .index = thing.index };
+ }
+
+ fn eql(ref: Ref, other: Ref) bool {
+ return ref.section == other.section and ref.index == other.index;
+ }
+};
+
+/// What the map keeps from one frame to the next.
+const Map = struct {
+ var projection: Projection = .top;
+ var ranges: bool = true;
+ var routes: bool = true;
+ var curves: bool = false;
+ /// Whether the axes fit the mission on the next frame: as the map first shows, as Fit asks,
+ /// and as the projection changes.
+ var fit: bool = true;
+ /// The record the map keeps in its middle.
+ var follow: ?Ref = null;
+ /// The record being dragged, and where it is now.
+ var dragging: ?struct { ref: Ref, place: [3]f64 } = null;
+ /// What the axes showed last, whose size Follow keeps, and where the map was on the screen.
+ var shown: ?plot.Rect = null;
+ var area: ?[2]Vec2 = null;
+ /// The mission the map last fitted, by its path: one opened anew shows whole.
+ var fitted_for: u64 = 0;
+ /// The places a click on a cluster opens on the next frame, and when the last one did, so that
+ /// the second click of a double click doesn't fit the mission again.
+ var zoom_to: ?[2][2]f64 = null;
+ var zoomed_at: f64 = -1;
+ /// The first thing of the cluster whose list is open.
+ var listed: ?Ref = null;
+ /// The place on the map under the pointer, for the status bar.
+ var pointer: ?[2]f64 = null;
+};
+
+/// The pixels around a marker's middle that the pointer takes it by.
+const grab_radius = 11;
+/// 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;
+const player_size = 20;
+const camera_size = 8;
+const scenery_size = 14;
+/// How close on the screen, in pixels, things draw as one cluster.
+const cluster_radius = 14;
+/// The least the map shows across, in the game's units, as it fits the mission and as it opens a
+/// cluster.
+const least_fit = 4000;
+const least_opened = 400;
+/// How many of a cluster's things its tooltip names, and the least width, in design pixels, of the
+/// 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.
+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.
+const zoom_step = 1.25;
+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;
+
+pub fn draw(app: *App) void {
+ const document = app.document orelse return;
+ if (widgets.panelTabs(&.{
+ .{ .label = "Map", .icon = .map, .icon_color = theme.accent, .on = true },
+ .{ .label = document.file, .icon = .file_code, .dot = document.dirty() },
+ })) |index| if (index == 1) app.run(.view_1);
+ const scene = document.scene() catch return;
+ toolbar(document, scene);
+ map(app, document, scene);
+}
+
+/// The status bar's part while the map shows: the projection, and the place under the pointer.
+pub fn status() []const u8 {
+ const projection = Map.projection;
+ const at = Map.pointer orelse return gui.print("Map · {s}", .{lower(projection.label())});
+ const names = axisNames(projection);
+ return gui.print("Map · {s} · {s} {s} · {s} {s}", .{ lower(projection.label()), names[0], drawing.distance(at[0]), names[1], drawing.distance(projectedValue(projection, at, 1)) });
+}
+
+/// The names of the game's coordinates across and up the map: `x` and `z` from above.
+fn axisNames(projection: Projection) [2][]const u8 {
+ const names = [_][]const u8{ "x", "y", "z" };
+ const axes = projection.axes();
+ return .{ names[axes[0]], names[axes[1]] };
+}
+
+/// The game's coordinate `which` of a place on the map, 0 across and 1 up: up is Y's opposite on
+/// the side and the front.
+fn projectedValue(projection: Projection, at: [2]f64, which: usize) f64 {
+ if (which == 1 and projection != .top) return -at[1];
+ return at[which];
+}
+
+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.
+fn toolbar(document: *Document, scene: game.Scene) void {
+ const at = gui.cursor();
+ const width = gui.available().x;
+ const height = px(36);
+ const list = gui.drawList();
+ gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.toolbar, 0);
+ gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.divider, 0);
+ const middle = at.y + height / 2;
+ var x = at.x + px(10);
+
+ gui.setCursor(.{ .x = x, .y = middle - px(13) });
+ const projections = [_][]const u8{ Projection.top.label(), Projection.side.label(), Projection.front.label() };
+ if (widgets.choice("projection", &projections, @backingInt(Map.projection), .{ .height = 26, .padding = 9, .font = fonts.size.small, .rounding = 4 })) |chosen| {
+ if (chosen != @backingInt(Map.projection)) {
+ Map.projection = @fromBackingInt(@as(u2, @intCast(chosen)));
+ Map.fit = true;
+ }
+ }
+ x = gui.lastItemRight() + px(8);
+ 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 },
+ };
+ for (chips) |chip| {
+ 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.*;
+ }
+ x = gui.lastItemRight() + px(6);
+ }
+
+ // 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) });
+ 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();
+ 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 {
+ const known = document.outline();
+ const allocator = gui.frameAllocator();
+ var choices: std.ArrayList(widgets.Choice) = .empty;
+ var refs: std.ArrayList(?Ref) = .empty;
+ choices.append(allocator, .{ .label = "none", .dim = true }) catch return;
+ refs.append(allocator, null) catch return;
+ var current: ?*const game.Record = null;
+ for (scene.things) |thing| {
+ if (thing.section != .ships) continue;
+ const record = known.nth(thing.section, thing.index) orelse continue;
+ choices.append(allocator, .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } } }) catch return;
+ refs.append(allocator, Ref.of(thing)) catch return;
+ if (Map.follow) |followed| if (followed.eql(Ref.of(thing))) {
+ current = record;
+ };
+ }
+ const frame: widgets.Frame = if (current) |record|
+ .{ .label = record.title, .mark = .{ .icon = .{ .icon = .navigation_2, .color = panels.groupColor(record.*) } }, .width = width }
+ else
+ .{ .label = "none", .dim_label = true, .width = width };
+ if (widgets.pick("follow", frame, choices.items)) |index| Map.follow = refs.items[index];
+}
+
+/// The map: the grid, the ranges and the curves, then the ships and markers, gathered into one
+/// mark where they would overlap, each named where its name has room, and over them the legend,
+/// the scale and the axes' note. The pointer pans the map and zooms it with the wheel, selects and
+/// drags a thing, and opens a cluster with a click; a double click fits the mission.
+fn map(app: *App, document: *Document, scene: game.Scene) void {
+ const size = gui.available();
+ if (size.x < 1 or size.y < 1) return;
+ const known = document.outline();
+ const projection = Map.projection;
+ const opened = std.hash.Wyhash.hash(0, document.path);
+ if (Map.fitted_for != opened) {
+ Map.fitted_for = opened;
+ Map.fit = true;
+ }
+ var wanted: ?plot.Rect = if (Map.fit) fitted(scene, projection, size) else if (Map.zoom_to) |bounds| framed(bounds, size, least_opened) else wheeled();
+ Map.fit = false;
+ Map.zoom_to = null;
+ if (Map.follow) |followed| if (thingOf(scene, followed)) |thing| if (wanted orelse Map.shown) |around| {
+ wanted = centredOn(around, projection.project(placeOf(thing)));
+ };
+ if (!plot.begin("##map", size, wanted)) return;
+ defer plot.end();
+ Map.shown = plot.limits();
+ Map.area = plot.area();
+ const list = plot.drawList();
+ defer plot.popClip();
+ const corners = Map.area.?;
+ const shown = Map.shown.?;
+ const scale = (corners[1].x - corners[0].x) / @as(f32, @floatCast(shown.x_max - shown.x_min));
+ const cell = drawing.cellOf(scale);
+ drawing.grid(list, shown, cell);
+ const rings: []const Ring = if (Map.ranges) drawRanges(list, scene, projection, scale, corners) else &.{};
+ if (Map.curves) for (scene.curves) |path| drawCurve(list, projection, path);
+
+ const inspected = app.inspected();
+ const selected: ?Ref = if (inspected) |record| refOf(scene, record.*) else null;
+ const gathered = gather(scene, projection, selected) catch return;
+ const lines: []const [2]Vec2 = if (Map.routes) drawRoutes(list, scene, gathered, projection, if (inspected) |record| record.* else null, corners) else &.{};
+ const pointer = pointerOnMap();
+ var hovered: ?usize = null;
+ for (gathered.marks, 0..) |*mark, which| {
+ const thing = scene.things[mark.first];
+ const record = known.nth(thing.section, thing.index) orelse continue;
+ const color = panels.groupColor(record.*);
+ if (mark.count > 1) {
+ drawing.cluster(list, mark.middle, mark.count, color);
+ if (pointer) |at| if (drawing.isInside(at, mark.box())) {
+ hovered = which;
+ };
+ continue;
+ }
+ const ref = Ref.of(thing);
+ const dragged = if (Map.dragging) |drag| drag.ref.eql(ref) else false;
+ var at = projection.project(placeOf(thing));
+ const drag = plot.dragPoint(mark.first, &at, px(grab_radius));
+ if (drag.hovered or drag.held) hovered = which;
+ if (drag.clicked) app.select(record.*);
+ if (drag.held) {
+ if (c.ImGui_GetIO().*.KeyShift) at = .{ @round(at[0] / snap) * snap, @round(at[1] / snap) * snap };
+ Map.dragging = .{ .ref = ref, .place = projection.unproject(at, thing.place) };
+ } else if (dragged) {
+ // Let go: the place goes into the source, where it moved.
+ const moved = Map.dragging.?.place;
+ Map.dragging = null;
+ if (!std.meta.eql(moved, thing.place)) moveTo(document, scene, record.*, moved);
+ }
+ mark.middle = plot.toPixels(at);
+ if (drag.hovered or drag.held) gui.dot(list, mark.middle, mark.size / 2 + 6, theme.fade(theme.text, 0.08));
+ drawing.thing(list, mark.middle, thing.kind, if (thing.heading) |heading| projection.project(heading) else null, mark.size, color);
+ }
+
+ var labels: drawing.Labels = .{ .corners = corners, .lines = lines };
+ for (gathered.marks) |mark| labels.take(mark.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 (hovered) |which| {
+ const mark = gathered.marks[which];
+ if (mark.count > 1) {
+ c.ImGui_SetMouseCursor(c.ImGuiMouseCursor_Hand);
+ gui.tooltipNow(memberNames(scene, known, gathered, which, size));
+ if (isClick(mark.middle, px(drawing.cluster_size) / 2)) openMark(scene, mark, size);
+ } else if (!named[which]) {
+ if (known.nth(scene.things[mark.first].section, scene.things[mark.first].index)) |record| gui.tooltipNow(record.title);
+ }
+ }
+ listMembers(app, scene, known, gathered);
+ // A click on an empty part of the map clears the selection.
+ if (hovered == null) if (pointer) |at| if (isClick(at, px(4))) {
+ app.selection = null;
+ };
+ // A double click away from the things fits the mission again.
+ 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.*);
+ }
+ Map.pointer = if (pointer) |at| plot.fromPixels(at) else null;
+}
+
+/// Where the pointer is, while it's over the map and not over something in front of it.
+fn pointerOnMap() ?Vec2 {
+ if (!c.ImGui_IsWindowHovered(0)) return null;
+ const mouse = c.ImGui_GetMousePos();
+ return if (drawing.isInside(mouse, Map.area orelse return null)) mouse else null;
+}
+
+/// Whether the left button has just been let go after a press within `radius` of `middle`, with
+/// no drag between.
+fn isClick(middle: Vec2, radius: f32) bool {
+ if (!c.ImGui_IsMouseReleased(0)) return false;
+ const io = c.ImGui_GetIO();
+ const delta = c.ImGui_GetMouseDragDelta(0, -1);
+ return delta.x == 0 and delta.y == 0 and drawing.distanceTo(io.*.MouseClickedPos[0], middle) <= radius;
+}
+
+/// Whether zooming in to a cluster's things, on a map `size` big, shows them apart.
+fn opensApart(mark: Mark, size: Vec2) bool {
+ const opened = framed(.{ mark.low, mark.high }, size, least_opened);
+ const scale = size.x / (opened.x_max - opened.x_min);
+ return @max(mark.high[0] - mark.low[0], mark.high[1] - mark.low[1]) * scale >= px(cluster_radius);
+}
+
+/// Opens a cluster: zooms in to its things where that shows them apart, or lists them where they
+/// lie too close together.
+fn openMark(scene: game.Scene, mark: Mark, size: Vec2) void {
+ if (opensApart(mark, size)) {
+ Map.zoom_to = .{ mark.low, mark.high };
+ Map.zoomed_at = c.ImGui_GetTime();
+ } else {
+ Map.listed = Ref.of(scene.things[mark.first]);
+ _ = c.ImGui_OpenPopup("members", 0);
+ }
+}
+
+/// The list of a cluster's things that lie too close together to show apart. A click on one
+/// selects it.
+fn listMembers(app: *App, scene: game.Scene, known: game.Outline, gathered: Gathered) void {
+ if (!c.ImGui_BeginPopup("members", c.ImGuiWindowFlags_NoMove)) return;
+ defer c.ImGui_EndPopup();
+ const listed = Map.listed orelse return c.ImGui_CloseCurrentPopup();
+ const which = for (gathered.marks, 0..) |mark, index| {
+ if (Ref.of(scene.things[mark.first]).eql(listed)) break index;
+ } else return c.ImGui_CloseCurrentPopup();
+ c.ImGui_Dummy(.{ .x = px(list_width), .y = 0 });
+ for (scene.things, gathered.owners, 0..) |thing, owner, index| {
+ if (owner != which) continue;
+ const record = known.nth(thing.section, thing.index) orelse continue;
+ gui.pushIdInt(index);
+ defer gui.popId();
+ const mark: widgets.RowMark = .{ .icon = .{ .icon = kindIcon(thing.kind), .color = panels.groupColor(record.*) } };
+ if (widgets.choiceRow(.{ .label = record.title, .mark = mark })) {
+ app.select(record.*);
+ c.ImGui_CloseCurrentPopup();
+ }
+ }
+}
+
+fn kindIcon(kind: game.ThingKind) Icon {
+ return switch (kind) {
+ .ship => .navigation_2,
+ .marker => .diamond,
+ .camera => .circle_dot,
+ .scenery => .circle,
+ };
+}
+
+/// The names of a cluster's things, for its tooltip: the first `most_listed`, how many
+/// more, and what a click does on a map `size` big.
+fn memberNames(scene: game.Scene, known: game.Outline, gathered: Gathered, which: usize, size: Vec2) []const u8 {
+ var text: std.Io.Writer.Allocating = .init(gui.frameAllocator());
+ const writer = &text.writer;
+ var count: usize = 0;
+ for (scene.things, gathered.owners) |thing, owner| {
+ if (owner != which) continue;
+ count += 1;
+ if (count > most_listed) continue;
+ const record = known.nth(thing.section, thing.index) orelse continue;
+ writer.print("{s}{s}", .{ if (count > 1) "\n" else "", record.title }) catch return "";
+ }
+ if (count > most_listed) writer.print("\n{d} more", .{count - most_listed}) catch return "";
+ writer.writeAll(if (opensApart(gathered.marks[which], size)) "\nClick to zoom in" else "\nClick to choose one") catch return "";
+ return text.written();
+}
+
+/// What the map draws for a thing, or for several that would overlap on the screen.
+const Mark = struct {
+ /// The thing it's for, the most important where it's for several, by its place in the scene.
+ first: usize,
+ count: usize,
+ middle: Vec2,
+ /// The map's places its things span, which a click on a cluster opens.
+ low: [2]f64,
+ high: [2]f64,
+ /// Its marker's height and width, in design pixels: a cluster is wider for a count of
+ /// three figures.
+ size: f32,
+ width: f32,
+
+ /// Where its marker covers the map.
+ fn box(mark: Mark) [2]Vec2 {
+ const half: Vec2 = .{ .x = px(mark.width) / 2, .y = px(mark.size) / 2 };
+ return .{ .{ .x = mark.middle.x - half.x, .y = mark.middle.y - half.y }, .{ .x = mark.middle.x + half.x, .y = mark.middle.y + half.y } };
+ }
+};
+
+/// The map's marks, and the mark each thing of the scene is in, by its place among the marks; a
+/// camera point, while the curves are hidden, is in none.
+const Gathered = struct {
+ marks: []Mark,
+ owners: []const usize,
+};
+
+/// The things the map shows as marks, the most important first: the selected thing, the player,
+/// the ships, the markers, the camera's points where the curves show, and the scenery. A thing
+/// within `cluster_radius` of a mark on the screen joins it, but for the selected thing and the
+/// one being dragged, which stay apart.
+fn gather(scene: game.Scene, projection: Projection, selected: ?Ref) Allocator.Error!Gathered {
+ const allocator = gui.frameAllocator();
+ var order: std.ArrayList(usize) = .empty;
+ for (scene.things, 0..) |thing, index| {
+ if (thing.kind == .camera and !Map.curves) continue;
+ try order.append(allocator, index);
+ }
+ const Order = struct {
+ scene: game.Scene,
+ selected: ?Ref,
+ fn lessThan(context: @This(), a: usize, b: usize) bool {
+ const first = importance(context.scene, a, context.selected);
+ const second = importance(context.scene, b, context.selected);
+ return if (first != second) first < second else a < b;
+ }
+ };
+ std.mem.sort(usize, order.items, Order{ .scene = scene, .selected = selected }, Order.lessThan);
+ const owners = try allocator.alloc(usize, scene.things.len);
+ @memset(owners, std.math.maxInt(usize));
+ var marks: std.ArrayList(Mark) = .empty;
+ things: for (order.items) |index| {
+ const thing = scene.things[index];
+ const at = projection.project(placeOf(thing));
+ const middle = plot.toPixels(at);
+ if (!isApart(Ref.of(thing), selected)) for (marks.items, 0..) |*mark, which| {
+ if (isApart(Ref.of(scene.things[mark.first]), selected)) continue;
+ if (drawing.distanceTo(mark.middle, middle) > px(cluster_radius)) continue;
+ mark.count += 1;
+ mark.size = drawing.cluster_size;
+ mark.width = drawing.clusterWidth(mark.count);
+ const point: @Vector(2, f64) = at;
+ const low: @Vector(2, f64) = mark.low;
+ const high: @Vector(2, f64) = mark.high;
+ mark.low = @min(low, point);
+ mark.high = @max(high, point);
+ owners[index] = which;
+ continue :things;
+ };
+ owners[index] = marks.items.len;
+ const size = sizeOf(scene, index);
+ try marks.append(allocator, .{ .first = index, .count = 1, .middle = middle, .low = at, .high = at, .size = size, .width = size });
+ }
+ return .{ .marks = marks.items, .owners = owners };
+}
+
+/// How important the thing at `index` in the scene is to the map: 0 is the most important.
+fn importance(scene: game.Scene, index: usize, selected: ?Ref) u8 {
+ const thing = scene.things[index];
+ if (isApart(Ref.of(thing), selected)) return 0;
+ if (scene.player == index) return 1;
+ return switch (thing.kind) {
+ .ship => 2,
+ .marker => 3,
+ .camera => 4,
+ .scenery => 5,
+ };
+}
+
+/// Whether a thing stays apart from the others: it's selected, or being dragged.
+fn isApart(ref: Ref, selected: ?Ref) bool {
+ if (selected) |chosen| if (chosen.eql(ref)) return true;
+ if (Map.dragging) |drag| if (drag.ref.eql(ref)) return true;
+ return false;
+}
+
+fn sizeOf(scene: game.Scene, index: usize) f32 {
+ if (scene.player == index) return player_size;
+ return switch (scene.things[index].kind) {
+ .ship, .marker => marker_size,
+ .camera => camera_size,
+ .scenery => scenery_size,
+ };
+}
+
+/// The scene's thing for `record`, where the map places it.
+fn refOf(scene: game.Scene, record: game.Record) ?Ref {
+ const ref: Ref = .{ .section = record.section, .index = record.index };
+ return if (thingOf(scene, ref) != null) ref else null;
+}
+
+/// The axes the wheel asks for, zoomed by `zoom_step` a notch around the place under the pointer,
+/// where the pointer is over the map; null otherwise.
+fn wheeled() ?plot.Rect {
+ const wheel = c.ImGui_GetIO().*.MouseWheel;
+ if (wheel == 0) return null;
+ const mouse = pointerOnMap() orelse return null;
+ const area = Map.area.?;
+ const last = Map.shown orelse return null;
+ const across: f64 = (mouse.x - area[0].x) / (area[1].x - area[0].x);
+ const down: f64 = (mouse.y - area[0].y) / (area[1].y - area[0].y);
+ const at: [2]f64 = .{ last.x_min + across * (last.x_max - last.x_min), last.y_max - down * (last.y_max - last.y_min) };
+ const width = last.x_max - last.x_min;
+ const factor = std.math.clamp(std.math.pow(f64, zoom_step, -wheel), least_across / width, most_across / width);
+ return .{
+ .x_min = at[0] + (last.x_min - at[0]) * factor,
+ .x_max = at[0] + (last.x_max - at[0]) * factor,
+ .y_min = at[1] + (last.y_min - at[1]) * factor,
+ .y_max = at[1] + (last.y_max - at[1]) * factor,
+ };
+}
+
+/// Puts where `record` starts at `place`, in its table's place field, as one step of Undo, once
+/// the frame is drawn.
+fn moveTo(document: *Document, scene: game.Scene, record: game.Record, place: [3]f64) void {
+ const value = source_edit.vector(gui.frameAllocator(), &place) catch return;
+ document.queue(.{ .edit = .{ .section = record.section, .index = record.index, .edit = .{ .field = .{ .name = scene.place_field, .value = value } } } });
+}
+
+/// The scene's thing for a record.
+fn thingOf(scene: game.Scene, ref: Ref) ?game.Thing {
+ return scene.things[indexOf(scene, ref) orelse return null];
+}
+
+/// Where the scene lists a record's thing.
+fn indexOf(scene: game.Scene, ref: Ref) ?usize {
+ for (scene.things, 0..) |thing, index| if (Ref.of(thing).eql(ref)) return index;
+ return null;
+}
+
+/// Where a thing is now: where a drag has it, or where it starts.
+fn placeOf(thing: game.Thing) [3]f64 {
+ if (Map.dragging) |drag| if (drag.ref.eql(Ref.of(thing))) return drag.place;
+ return thing.place;
+}
+
+/// The axes that show the mission: its ships and markers, and the camera's points where the
+/// curves show, but not its scenery, which lies far off; every thing where that's all it has.
+fn fitted(scene: game.Scene, projection: Projection, size: Vec2) plot.Rect {
+ var low: @Vector(2, f64) = @splat(std.math.inf(f64));
+ var high: @Vector(2, f64) = @splat(-std.math.inf(f64));
+ for ([_]bool{ false, true }) |everything| {
+ for (scene.things) |thing| {
+ if (!everything and (thing.kind == .scenery or (thing.kind == .camera and !Map.curves))) continue;
+ const at: @Vector(2, f64) = projection.project(thing.place);
+ low = @min(low, at);
+ high = @max(high, at);
+ }
+ if (low[0] <= high[0]) break;
+ }
+ if (low[0] > high[0]) return framed(.{ @splat(0), @splat(0) }, size, least_fit);
+ return framed(.{ low, high }, size, least_fit);
+}
+
+/// The axes that show `bounds`, with a margin of a tenth each side and at least `least` across and
+/// up, at the map's aspect.
+fn framed(bounds: [2][2]f64, size: Vec2, least: f64) plot.Rect {
+ const across = @max(bounds[1][0] - bounds[0][0], least) * 1.2;
+ const up = @max(bounds[1][1] - bounds[0][1], least) * 1.2;
+ const middle: [2]f64 = .{ (bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2 };
+ // Equal axes: the side that's too short for the map's shape grows.
+ const aspect: f64 = size.x / size.y;
+ const width = @max(across, up * aspect);
+ const height = width / aspect;
+ return .{ .x_min = middle[0] - width / 2, .x_max = middle[0] + width / 2, .y_min = middle[1] - height / 2, .y_max = middle[1] + height / 2 };
+}
+
+/// `last` moved to have `middle` in its middle.
+fn centredOn(last: plot.Rect, middle: [2]f64) plot.Rect {
+ const half_across = (last.x_max - last.x_min) / 2;
+ const half_up = (last.y_max - last.y_min) / 2;
+ return .{ .x_min = middle[0] - half_across, .x_max = middle[0] + half_across, .y_min = middle[1] - half_up, .y_max = middle[1] + half_up };
+}
+
+/// Each mark's name, the most important first: right of its marker, else left, below or above,
+/// wherever it covers no mark and no name placed before it. Returns which marks have their name
+/// shown; the others show it as the pointer comes over them.
+fn nameMarks(list: *c.ImDrawList, labels: *drawing.Labels, scene: game.Scene, known: game.Outline, marks: []const Mark) []const bool {
+ const named = gui.frameAllocator().alloc(bool, marks.len) catch return &.{};
+ @memset(named, false);
+ gui.sans(.regular, fonts.size.smaller);
+ defer gui.popFont();
+ const height = c.ImGui_GetFontSize();
+ for (marks, named) |mark, *shown| {
+ 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) {
+ .ship, .marker => theme.text,
+ .camera, .scenery => theme.text_dim,
+ };
+ const width = gui.textSize(text).x;
+ const box = mark.box();
+ const gap = px(5);
+ const spots = [_]Vec2{
+ .{ .x = box[1].x + gap, .y = mark.middle.y - height / 2 },
+ .{ .x = box[0].x - gap - width, .y = mark.middle.y - height / 2 },
+ .{ .x = mark.middle.x - width / 2, .y = box[1].y + px(2) },
+ .{ .x = mark.middle.x - width / 2, .y = box[0].y - px(2) - height },
+ };
+ const at = labels.place(&spots, .{ .x = width, .y = height }) orelse continue;
+ drawing.label(list, at, .{ .x = width, .y = height }, color, text);
+ shown.* = true;
+ }
+ return named;
+}
+
+/// A ring the map draws, for its label.
+const Ring = struct {
+ middle: Vec2,
+ radius: f32,
+ range: game.Range,
+};
+
+/// The ranges, each ring once however many triggers watch it: a trigger's as a dashed ring with a
+/// faint fill, a hint's as a dotted one. Returns the rings drawn, for their labels.
+fn drawRanges(list: *c.ImDrawList, scene: game.Scene, projection: Projection, scale: f32, corners: [2]Vec2) []const Ring {
+ var rings: std.ArrayList(Ring) = .empty;
+ ranges: for (scene.ranges, 0..) |range, index| {
+ for (scene.ranges[0..index]) |before| {
+ if (before.section == range.section and before.index == range.index and before.radius == range.radius and before.style == range.style) continue :ranges;
+ }
+ const subject = thingOf(scene, .{ .section = range.section, .index = range.index }) orelse continue;
+ const middle = plot.toPixels(projection.project(placeOf(subject)));
+ const radius = @as(f32, @floatCast(range.radius)) * scale;
+ const reach = drawing.reachOf(middle, corners);
+ // A ring smaller than a few pixels, or one the map lies outside, shows nothing; one the
+ // map lies inside shows only its fill.
+ if (radius < px(4) or radius < reach[0]) continue;
+ const shows = radius <= reach[1];
+ switch (range.style) {
+ .trigger => {
+ const fill = theme.fade(theme.accent, 0.035);
+ if (shows) c.ImDrawList_AddCircleFilled(list, middle, radius, fill, 0) else gui.rect(list, corners[0], corners[1], fill, 0);
+ if (shows) drawing.dashedCircle(list, middle, radius, theme.fade(theme.accent, 0.55), 5, 4, corners);
+ },
+ .hint => if (shows) drawing.dashedCircle(list, middle, radius, theme.fade(theme.text_menu, 0.3), 1.5, 3, corners),
+ }
+ if (shows) rings.append(gui.frameAllocator(), .{ .middle = middle, .radius = radius, .range = range }) catch break;
+ }
+ return rings.items;
+}
+
+/// 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);
+ defer gui.popFont();
+ const text = gui.print("{s} · {s}", .{ ring.range.label, drawing.distance(ring.range.radius) });
+ const size = gui.textSize(text);
+ if (size.x + px(40) > ring.radius * 2) return;
+ const left = ring.middle.x - size.x / 2;
+ const spots = [_]Vec2{
+ .{ .x = left, .y = ring.middle.y - ring.radius + px(6) },
+ .{ .x = left, .y = ring.middle.y + ring.radius - px(6) - size.y },
+ };
+ const at = labels.place(&spots, size) orelse return;
+ const color = switch (ring.range.style) {
+ .trigger => theme.range_text,
+ .hint => theme.text_muted,
+ };
+ drawing.label(list, at, size, color, text);
+}
+
+fn drawCurve(list: *c.ImDrawList, projection: Projection, path: []const [3]f64) void {
+ for (path) |place| c.ImDrawList_PathLineTo(list, plot.toPixels(projection.project(place)));
+ c.ImDrawList_PathStroke(list, theme.curve, px(1.4), 0);
+}
+
+/// The routes, under the marks: each leg from mark to mark, dotted where the ship flies and dashed
+/// where it jumps, in its group's colour, with an arrowhead where it goes and a ring where its
+/// route starts. A leg within one cluster shows nothing, and a leg several routes share shows once.
+/// With a ship, a group or a marker selected, the routes it's on show brighter and the others
+/// faint. Returns the legs drawn, as lines on the screen.
+fn drawRoutes(list: *c.ImDrawList, scene: game.Scene, gathered: Gathered, projection: Projection, inspected: ?game.Record, corners: [2]Vec2) []const [2]Vec2 {
+ var lines: std.ArrayList([2]Vec2) = .empty;
+ const selected: ?usize = if (inspected) |record| indexOf(scene, .{ .section = record.section, .index = record.index }) else null;
+ // A ship's group, or a group itself: the routes of the group show too.
+ const selected_group: ?usize = if (inspected) |record| switch (record.section) {
+ .ships, .flight_groups => record.group,
+ else => null,
+ } else null;
+ 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;
+ 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 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;
+ if (seen.found_existing) continue;
+ const line = drawing.leg(list, from, to, .{ .way = leg.way, .starts = leg.starts, .color = color }, corners) orelse continue;
+ lines.append(gui.frameAllocator(), line) catch return lines.items;
+ }
+ }
+ return lines.items;
+}
+
+/// 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 {
+ if (selected) |thing| {
+ if (route.ship == thing or leg.from == thing or leg.to == thing) return true;
+ }
+ if (route.ship == null) if (route.group) |group| if (selected_group == group) return true;
+ return false;
+}
+
+/// Where a leg meets the thing at `thing` in the scene: the mark it's in, or where it is.
+fn endOf(scene: game.Scene, gathered: Gathered, projection: Projection, thing: usize) drawing.End {
+ const owner = gathered.owners[thing];
+ if (owner < gathered.marks.len) {
+ const mark = gathered.marks[owner];
+ return .{ .middle = mark.middle, .reach = px(@max(mark.size, mark.width)) / 2, .mark = owner };
+ }
+ 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 {
+ 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;
+ 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];
+ const far = @sqrt(across * across + up * up);
+ // 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 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 }
+ 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);
+}
+
+test Projection {
+ const place: [3]f64 = .{ 1, 2, 3 };
+ for (std.enums.values(Projection)) |projection| {
+ const at = projection.project(place);
+ try std.testing.expectEqual(place, projection.unproject(at, place));
+ }
+ // From above, X runs across and Z up; from the side and the front, up is Y's opposite.
+ try std.testing.expectEqual([2]f64{ 1, 3 }, Projection.top.project(place));
+ try std.testing.expectEqual([2]f64{ 3, -2 }, Projection.side.project(place));
+}
diff --git a/src/views/scene/drawing.zig b/src/views/scene/drawing.zig
new file mode 100644
index 0000000..44ed64f
--- /dev/null
+++ b/src/views/scene/drawing.zig
@@ -0,0 +1,475 @@
+//! How the Scene view's map draws its parts on the screen: the grid, the rings, the routes' legs,
+//! the ships' and markers' marks, the names, and the notes over the map. Nothing here knows the
+//! map's state; the view (`views/scene.zig`) says what goes where.
+
+const std = @import("std");
+const c = @import("c");
+
+const game = @import("../../game.zig");
+const ui = @import("../../ui.zig");
+const fonts = ui.fonts;
+const gui = ui.gui;
+const plot = ui.plot;
+const theme = ui.theme;
+const Color = theme.Color;
+const Vec2 = gui.Vec2;
+const px = gui.px;
+
+/// The least spacing of the grid's cells, in pixels: each is the smallest of 1, 2 or 5 times a
+/// power of ten that is at least this wide. A line every `major_every` cells is brighter.
+const least_cell = 40;
+const major_every = 5;
+/// A cluster's height, the size of its count, and the room around the count, in design pixels. A
+/// cluster is the mark the map draws for several things that would overlap on the screen.
+pub const cluster_size = 20;
+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;
+
+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));
+}
+
+/// The point `pixels` from `from` along the unit vector `way`.
+pub fn along(from: Vec2, way: Vec2, pixels: f32) Vec2 {
+ return .{ .x = from.x + way.x * pixels, .y = from.y + way.y * pixels };
+}
+
+/// Whether `point` is in the box between `corners`, edges included.
+pub fn isInside(point: Vec2, corners: [2]Vec2) bool {
+ return point.x >= corners[0].x and point.x <= corners[1].x and point.y >= corners[0].y and point.y <= corners[1].y;
+}
+
+/// The part of the line from `from` to `to` inside `corners`, as distances along it from `from`;
+/// null where none of it is.
+pub fn clipAlong(from: Vec2, to: Vec2, corners: [2]Vec2) ?[2]f32 {
+ const length = distanceTo(from, to);
+ if (length == 0) return null;
+ const delta: Vec2 = .{ .x = to.x - from.x, .y = to.y - from.y };
+ var first: f32 = 0;
+ var last: f32 = 1;
+ // Each edge cuts off the part of the line beyond it (Liang and Barsky's clipping).
+ for ([_][2]f32{
+ .{ -delta.x, from.x - corners[0].x },
+ .{ delta.x, corners[1].x - from.x },
+ .{ -delta.y, from.y - corners[0].y },
+ .{ delta.y, corners[1].y - from.y },
+ }) |edge| {
+ const toward, const room = edge;
+ if (toward == 0) {
+ if (room < 0) return null;
+ continue;
+ }
+ const cut = room / toward;
+ if (toward < 0) first = @max(first, cut) else last = @min(last, cut);
+ }
+ if (first > last) return null;
+ return .{ first * length, last * length };
+}
+
+/// How near and how far the map's area is from `middle`, in pixels: a ring shows on the map only
+/// with a radius between the two.
+pub fn reachOf(middle: Vec2, corners: [2]Vec2) [2]f32 {
+ const nearest: Vec2 = .{ .x = std.math.clamp(middle.x, corners[0].x, corners[1].x), .y = std.math.clamp(middle.y, corners[0].y, corners[1].y) };
+ var farthest: f32 = 0;
+ for ([_]f32{ corners[0].x, corners[1].x }) |x| for ([_]f32{ corners[0].y, corners[1].y }) |y| {
+ farthest = @max(farthest, distanceTo(middle, .{ .x = x, .y = y }));
+ };
+ return .{ distanceTo(middle, nearest), farthest };
+}
+
+/// The grid's cell for `scale` pixels to the game's unit: 1, 2 or 5 times a power of ten, the least
+/// at least `least_cell` pixels wide.
+pub fn cellOf(scale: f32) f64 {
+ if (scale <= 0) return 1000;
+ const least: f64 = least_cell / @as(f64, scale);
+ var power = std.math.pow(f64, 10, @floor(std.math.log10(least)));
+ while (true) : (power *= 10) {
+ for ([_]f64{ 1, 2, 5 }) |step| if (step * power >= least) return step * power;
+ }
+}
+
+/// The grid over the part of the map `shown`, a line every `cell` of the game's units.
+pub fn grid(list: *c.ImDrawList, shown: plot.Rect, cell: f64) void {
+ const corners = plot.area();
+ var x = @ceil(shown.x_min / cell) * cell;
+ while (x <= shown.x_max) : (x += cell) {
+ const at = plot.toPixels(.{ x, 0 }).x;
+ const color = if (isMajor(x, cell)) theme.map_grid else theme.map_grid_minor;
+ c.ImDrawList_AddLineEx(list, .{ .x = at, .y = corners[0].y }, .{ .x = at, .y = corners[1].y }, color, px(1));
+ }
+ var y = @ceil(shown.y_min / cell) * cell;
+ while (y <= shown.y_max) : (y += cell) {
+ const at = plot.toPixels(.{ 0, y }).y;
+ const color = if (isMajor(y, cell)) theme.map_grid else theme.map_grid_minor;
+ c.ImDrawList_AddLineEx(list, .{ .x = corners[0].x, .y = at }, .{ .x = corners[1].x, .y = at }, color, px(1));
+ }
+}
+
+fn isMajor(value: f64, cell: f64) bool {
+ const cells = @round(value / cell);
+ return @mod(cells, major_every) == 0;
+}
+
+/// A circle of dashes `dash` pixels long, `gap` pixels apart, drawn only where it can show on the
+/// map, so that a ring far larger than the map costs no more than one that fits it.
+pub fn dashedCircle(list: *c.ImDrawList, middle: Vec2, radius: f32, color: Color, dash: f32, gap: f32, corners: [2]Vec2) void {
+ var from: f32 = 0;
+ var to: f32 = 2 * std.math.pi;
+ if (!isInside(middle, corners)) {
+ // From outside the map, its corners lie within half a turn of the way to its middle.
+ const towards = std.math.atan2((corners[0].y + corners[1].y) / 2 - middle.y, (corners[0].x + corners[1].x) / 2 - middle.x);
+ var least: f32 = 0;
+ var most: f32 = 0;
+ for ([_]f32{ corners[0].x, corners[1].x }) |x| for ([_]f32{ corners[0].y, corners[1].y }) |y| {
+ var turn = std.math.atan2(y - middle.y, x - middle.x) - towards;
+ if (turn > std.math.pi) turn -= 2 * std.math.pi;
+ if (turn < -std.math.pi) turn += 2 * std.math.pi;
+ least = @min(least, turn);
+ most = @max(most, turn);
+ };
+ from = towards + least;
+ to = towards + most;
+ }
+ const step = px(dash + gap) / radius;
+ const share = dash / (dash + gap);
+ var angle = @floor(from / step) * step;
+ while (angle < to) : (angle += step) {
+ c.ImDrawList_PathArcTo(list, middle, radius, angle, angle + step * share, 2);
+ c.ImDrawList_PathStroke(list, color, px(1), 0);
+ }
+}
+
+/// The places taken on the map by its marks and the names placed so far, so that a name goes where
+/// it covers neither, and the routes' legs, which a name keeps off where it can.
+pub const Labels = struct {
+ corners: [2]Vec2,
+ taken: std.ArrayList([2]Vec2) = .empty,
+ lines: []const [2]Vec2 = &.{},
+
+ /// Takes the first of `spots` where a name `size` big fits, clear of the legs where one is,
+ /// and returns it, if one does.
+ pub fn place(labels: *Labels, spots: []const Vec2, size: Vec2) ?Vec2 {
+ for ([_]bool{ true, false }) |clear_of_lines| for (spots) |spot| {
+ const box: [2]Vec2 = .{ spot, .{ .x = spot.x + size.x, .y = spot.y + size.y } };
+ if (!isInside(box[0], labels.corners) or !isInside(box[1], labels.corners) or labels.covers(box)) continue;
+ if (clear_of_lines and labels.crossesLine(box)) continue;
+ labels.take(box);
+ return spot;
+ };
+ return null;
+ }
+
+ pub fn take(labels: *Labels, box: [2]Vec2) void {
+ labels.taken.append(gui.frameAllocator(), box) catch {};
+ }
+
+ fn covers(labels: Labels, box: [2]Vec2) bool {
+ for (labels.taken.items) |other| {
+ if (box[0].x < other[1].x and box[1].x > other[0].x and box[0].y < other[1].y and box[1].y > other[0].y) return true;
+ }
+ return false;
+ }
+
+ fn crossesLine(labels: Labels, box: [2]Vec2) bool {
+ for (labels.lines) |line| if (clipAlong(line[0], line[1], box) != null) return true;
+ return false;
+ }
+};
+
+/// A name on the map, `size` big at `at`, on a backing of the map's colour that the lines and rings
+/// pass under, with a dark edge.
+pub fn label(list: *c.ImDrawList, at: Vec2, size: Vec2, color: Color, text: []const u8) void {
+ const pad: Vec2 = .{ .x = px(3), .y = px(1) };
+ gui.rect(list, .{ .x = at.x - pad.x, .y = at.y - pad.y }, .{ .x = at.x + size.x + pad.x, .y = at.y + size.y + pad.y }, theme.fade(theme.map, 0.82), 3);
+ for ([_][2]f32{ .{ -1, 0 }, .{ 1, 0 }, .{ 0, -1 }, .{ 0, 1 } }) |offset| {
+ gui.drawText(list, .{ .x = at.x + offset[0], .y = at.y + offset[1] }, theme.ground, text);
+ }
+ gui.drawText(list, at, color, text);
+}
+
+/// What the map draws for a thing at `middle`, `size` design pixels across: a ship's arrow,
+/// pointing the way `heading` goes on the map, outlined in its group's colour on the map's ground,
+/// as where it starts; a marker's diamond, filled; a camera point's small ring; or a planet's or
+/// the sun's faint disc.
+pub fn thing(list: *c.ImDrawList, middle: Vec2, kind: game.ThingKind, heading: ?[2]f64, size: f32, color: Color) void {
+ const scale = px(size) / 24;
+ switch (kind) {
+ .ship => {
+ // The design's arrow, in a box of 24 with its middle at 12, 12, pointing up.
+ const shape = [_][2]f32{ .{ 0, -10 }, .{ 8, 9 }, .{ 0, 4.5 }, .{ -8, 9 } };
+ const way = heading orelse .{ 0, 1 };
+ const angle: f32 = if (@abs(way[0]) + @abs(way[1]) < 1e-6) 0 else @floatCast(std.math.atan2(way[0], way[1]));
+ var points: [4]Vec2 = undefined;
+ for (shape, &points) |point, *made| made.* = turned(middle, point, scale, angle);
+ // Filled in two halves, since it isn't convex, then outlined.
+ c.ImDrawList_AddTriangleFilled(list, points[0], points[1], points[2], theme.map);
+ c.ImDrawList_AddTriangleFilled(list, points[0], points[2], points[3], theme.map);
+ c.ImDrawList_AddPolyline(list, &points, points.len, color, px(1.8), c.ImDrawFlags_Closed);
+ },
+ .marker => {
+ const shape = [_][2]f32{ .{ 0, -10 }, .{ 10, 0 }, .{ 0, 10 }, .{ -10, 0 } };
+ var points: [4]Vec2 = undefined;
+ for (shape, &points) |point, *made| made.* = turned(middle, point, scale, 0);
+ c.ImDrawList_AddConvexPolyFilled(list, &points, points.len, color);
+ c.ImDrawList_AddPolyline(list, &points, points.len, theme.ground, px(1.2), c.ImDrawFlags_Closed);
+ },
+ .camera => {
+ c.ImDrawList_AddCircleFilled(list, middle, px(size) / 2, theme.map, 0);
+ c.ImDrawList_AddCircleEx(list, middle, px(size) / 2, theme.curve, 0, px(1.4));
+ },
+ .scenery => {
+ c.ImDrawList_AddCircleFilled(list, middle, px(size) / 2, theme.fade(theme.text_dim, 0.18), 0);
+ c.ImDrawList_AddCircleEx(list, middle, px(size) / 2, theme.fade(theme.text_dim, 0.7), 0, px(1.2));
+ },
+ }
+}
+
+/// `point` of a shape around `middle`, scaled, and turned clockwise by `angle` radians.
+fn turned(middle: Vec2, point: [2]f32, scale: f32, angle: f32) Vec2 {
+ const x = point[0] * scale;
+ const y = point[1] * scale;
+ return .{ .x = middle.x + x * @cos(angle) - y * @sin(angle), .y = middle.y + x * @sin(angle) + y * @cos(angle) };
+}
+
+/// A cluster of `count` things: a ring in `color` with the count in its middle, or a pill where the
+/// count is too wide for the ring (`clusterWidth`).
+pub fn cluster(list: *c.ImDrawList, middle: Vec2, count: usize, color: Color) void {
+ const width = clusterWidth(count);
+ gui.sans(.semibold, cluster_figures);
+ defer gui.popFont();
+ const text = gui.print("{d}", .{count});
+ const text_width = gui.textSize(text).x;
+ // ImGui draws text from whole points, so the count goes there, and the ring around it.
+ const at: Vec2 = .{ .x = @round(middle.x - text_width / 2), .y = gui.capitalsTop(middle.y) };
+ const centre: Vec2 = .{ .x = at.x + text_width / 2, .y = at.y + gui.ascent() - gui.capitals() / 2 };
+ const half: Vec2 = .{ .x = px(width) / 2, .y = px(cluster_size) / 2 };
+ const line = px(1.6);
+ if (width > cluster_size) {
+ const min: Vec2 = .{ .x = centre.x - half.x, .y = centre.y - half.y };
+ const max: Vec2 = .{ .x = centre.x + half.x, .y = centre.y + half.y };
+ gui.rect(list, min, max, theme.map, cluster_size / 2);
+ c.ImDrawList_AddRectEx(list, .{ .x = min.x + line / 2, .y = min.y + line / 2 }, .{ .x = max.x - line / 2, .y = max.y - line / 2 }, color, half.y - line / 2, line, 0);
+ } else {
+ c.ImDrawList_AddCircleFilled(list, centre, half.y, theme.map, 0);
+ c.ImDrawList_AddCircleEx(list, centre, half.y - line / 2, color, 0, line);
+ }
+ gui.drawText(list, at, color, text);
+}
+
+/// How wide a cluster of `count` things is, in design pixels: a ring, or wider for a count of three
+/// figures.
+pub fn clusterWidth(count: usize) f32 {
+ gui.sans(.semibold, cluster_figures);
+ defer gui.popFont();
+ const figures = gui.textSize(gui.print("{d}", .{count})).x / px(1);
+ return @max(cluster_size, figures + 2 * cluster_padding);
+}
+
+/// The selection's corners around `middle`, `half` pixels out.
+pub fn brackets(list: *c.ImDrawList, middle: Vec2, half: f32) void {
+ const long = px(8);
+ for ([_][2]f32{ .{ -1, -1 }, .{ 1, -1 }, .{ -1, 1 }, .{ 1, 1 } }) |corner| {
+ const tip: Vec2 = .{ .x = middle.x + corner[0] * half, .y = middle.y + corner[1] * half };
+ var points = [_]Vec2{
+ .{ .x = tip.x, .y = tip.y - corner[1] * long },
+ tip,
+ .{ .x = tip.x - corner[0] * long, .y = tip.y },
+ };
+ c.ImDrawList_AddPolyline(list, &points, points.len, theme.accent, px(2), 0);
+ }
+}
+
+/// 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]) });
+ 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 };
+ gui.rect(list, min, max, theme.fade(theme.panel, 0.94), 4);
+ gui.outline(list, min, max, theme.border_strong, 4);
+ 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.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.popFont();
+}
+
+/// Where a leg meets a thing on the screen: the middle of the mark it's in, and how far out from
+/// it the mark reaches, or where it is where it's in no mark.
+pub const End = struct {
+ middle: Vec2,
+ reach: f32 = 0,
+ mark: ?usize = null,
+
+ /// A number for the end: its mark's, or else its thing's, `thing_index`. The two never clash.
+ pub fn key(end: End, thing_index: usize) usize {
+ return if (end.mark) |mark| mark * 2 else thing_index * 2 + 1;
+ }
+};
+
+pub const LegLook = struct {
+ way: game.Way,
+ starts: bool,
+ color: Color,
+};
+
+/// A leg from `from` to `to`, clear of both marks: dots for a flight, dashes for a jump, an
+/// arrowhead at its end, and a ring at its start where its route starts there. A leg to or from a
+/// place the map doesn't show is a stub `stub_length` long that points the way, and one between
+/// two such places shows nothing. Only the part inside `corners` is drawn. Returns the line drawn,
+/// if any.
+pub fn leg(list: *c.ImDrawList, from: End, to: End, look: LegLook, corners: [2]Vec2) ?[2]Vec2 {
+ const from_shows = isInside(from.middle, corners);
+ const to_shows = isInside(to.middle, corners);
+ if (!from_shows and !to_shows) return null;
+ const length = distanceTo(from.middle, to.middle);
+ var start_at = from.reach + px(3);
+ var end_at = length - to.reach - px(3);
+ if (end_at - start_at < px(6)) return null;
+ if (!to_shows) end_at = @min(end_at, start_at + px(stub_length));
+ if (!from_shows) start_at = @max(start_at, end_at - px(stub_length));
+ const way: Vec2 = .{ .x = (to.middle.x - from.middle.x) / length, .y = (to.middle.y - from.middle.y) / length };
+ const start = along(from.middle, way, start_at);
+ const end = along(from.middle, way, end_at);
+ const shown = clipAlong(start, end, corners) orelse return null;
+ const period = px(switch (look.way) {
+ .fly => 5,
+ .jump => 10,
+ });
+ var at = @floor(shown[0] / period) * period;
+ while (at < shown[1]) : (at += period) {
+ switch (look.way) {
+ .fly => if (at >= shown[0]) c.ImDrawList_AddCircleFilled(list, along(start, way, at), px(1), look.color, 4),
+ .jump => {
+ const dash_start = @max(at, shown[0]);
+ const dash_end = @min(at + px(6), shown[1]);
+ if (dash_end > dash_start) c.ImDrawList_AddLineEx(list, along(start, way, dash_start), along(start, way, dash_end), look.color, px(1.5));
+ },
+ }
+ }
+ if (isInside(end, corners)) {
+ const back = along(end, way, -px(7));
+ const side: Vec2 = .{ .x = -way.y * px(3.5), .y = way.x * px(3.5) };
+ c.ImDrawList_AddTriangleFilled(list, end, .{ .x = back.x + side.x, .y = back.y + side.y }, .{ .x = back.x - side.x, .y = back.y - side.y }, look.color);
+ }
+ if (look.starts and from_shows and isInside(start, corners)) gui.ring(list, along(start, way, px(3)), 3, 1.4, look.color);
+ 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;
+ 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)) };
+ 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);
+ 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 }),
+ }
+ gui.drawText(list, .{ .x = left + sample + px(7), .y = gui.textTop(middle) }, theme.text_menu, text);
+ }
+}
+
+/// 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) } };
+ c.ImDrawList_AddPolyline(list, &points, points.len, theme.text_muted, px(1), 0);
+ gui.sans(.regular, 11);
+ defer gui.popFont();
+ gui.drawText(list, .{ .x = corner.x, .y = bottom - px(9) - c.ImGui_GetFontSize() }, theme.text_muted, distance(cell));
+}
+
+/// A distance as the map writes it: `4k` for 4000, `1.5k`, `2.5M` for 2,500,000, or `500`.
+pub fn distance(value: f64) []const u8 {
+ const size = @abs(value);
+ for ([_]struct { f64, []const u8 }{ .{ 1e6, "M" }, .{ 1e3, "k" } }) |unit| {
+ if (size < unit[0]) continue;
+ const count = value / unit[0];
+ if (@round(count) == count or size >= 100 * unit[0]) return gui.print("{d}{s}", .{ @round(count), unit[1] });
+ return gui.print("{d:.1}{s}", .{ count, unit[1] });
+ }
+ return gui.print("{d}", .{@round(value)});
+}
+
+test cellOf {
+ // 54 pixels to a thousand: the cell is a thousand; zoomed out ten times, ten thousand.
+ try std.testing.expectEqual(1000, cellOf(54.0 / 1000.0));
+ try std.testing.expectEqual(10_000, cellOf(5.4 / 1000.0));
+ try std.testing.expectEqual(2000, cellOf(30.0 / 1000.0));
+}
+
+test distance {
+ try std.testing.expectEqualStrings("4k", distance(4000));
+ try std.testing.expectEqualStrings("1.5k", distance(1500));
+ try std.testing.expectEqualStrings("500", distance(500));
+ try std.testing.expectEqualStrings("-2k", distance(-2000));
+ try std.testing.expectEqualStrings("2.5M", distance(2_500_000));
+ try std.testing.expectEqualStrings("150M", distance(150_000_000));
+}
+
+test clipAlong {
+ const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 100, .y = 100 } };
+ // A line across the map from outside it on the left to outside it on the right shows inside
+ // it, 50 to 150 pixels along.
+ try std.testing.expectEqual([2]f32{ 50, 150 }, clipAlong(.{ .x = -50, .y = 50 }, .{ .x = 150, .y = 50 }, corners).?);
+ // One wholly inside shows whole, and one beside the map not at all.
+ try std.testing.expectEqual([2]f32{ 0, 10 }, clipAlong(.{ .x = 10, .y = 10 }, .{ .x = 20, .y = 10 }, corners).?);
+ try std.testing.expectEqual(null, clipAlong(.{ .x = -50, .y = -10 }, .{ .x = 150, .y = -10 }, corners));
+}
+
+test Labels {
+ defer gui.newFrame();
+ const corners: [2]Vec2 = .{ .{ .x = 0, .y = 0 }, .{ .x = 200, .y = 200 } };
+ // A leg leaves a mark at 100, 100 to the right.
+ var labels: Labels = .{ .corners = corners, .lines = &.{.{ .{ .x = 110, .y = 100 }, .{ .x = 190, .y = 100 } }} };
+ const size: Vec2 = .{ .x = 40, .y = 10 };
+ const right: Vec2 = .{ .x = 110, .y = 95 };
+ const left: Vec2 = .{ .x = 50, .y = 95 };
+ // The name goes left, clear of the leg, though right comes first.
+ try std.testing.expectEqual(left, labels.place(&.{ right, left }, size).?);
+ // With the left taken, the next covers the leg rather than going without.
+ try std.testing.expectEqual(right, labels.place(&.{ right, left }, size).?);
+ // With both taken, the third goes without.
+ try std.testing.expectEqual(null, labels.place(&.{ right, left }, size));
+}
+
+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.
+ try std.testing.expectEqual([2]f32{ 0, 500 }, reachOf(.{ .x = 0, .y = 0 }, corners));
+ // From 100 pixels left of the map's left edge.
+ const reach = reachOf(.{ .x = -100, .y = 0 }, corners);
+ try std.testing.expectEqual(100, reach[0]);
+ try std.testing.expectApproxEqAbs(@sqrt(400.0 * 400.0 + 400.0 * 400.0), reach[1], 1e-3);
+}
diff --git a/src/views/script.zig b/src/views/script.zig
index 9831b63..92df513 100644
--- a/src/views/script.zig
+++ b/src/views/script.zig
@@ -72,30 +72,16 @@ pub fn draw(app: *App) void {
}
}
-/// The panel's own tab bar, as the docked panels' (`widgets.PanelTab`): the source file, and its
+/// The panel's own tab bar, as the docked panels' (`widgets.panelTabs`): the source file, and its
/// script listing.
fn tabs(app: *App, document: *Document) void {
- const at = gui.cursor();
- const width = gui.available().x;
- const height = px(widgets.tab_bar_height);
- const list = gui.drawList();
- gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.bar, 0);
- gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.tab_divider, 0);
- var x = at.x;
- for (std.enums.values(app_mod.SourceTab)) |which| {
- const on = app.source_tab == which;
- const shown: widgets.PanelTab = switch (which) {
- .source => .{ .label = document.file, .icon = .file_code, .icon_color = theme.accent, .dot = document.dirty(), .on = on },
- .listing => .{ .label = "Script listing", .icon = .list, .detail = if (document.built) |built| built.file else "not built", .on = on },
- };
- const tab_width = shown.width();
- gui.setCursor(.{ .x = x, .y = at.y });
- if (c.ImGui_InvisibleButton(@tagName(which), .{ .x = tab_width, .y = height - px(1) }, 0)) app.source_tab = which;
- shown.draw(list, .{ .x = x, .y = at.y }, .{ .x = x + tab_width, .y = at.y + height - px(1) }, gui.hovered());
- if (shown.dot) gui.tooltip("Changed since saved");
- x += tab_width;
- }
- gui.setCursor(.{ .x = at.x, .y = at.y + height });
+ const which = std.enums.values(app_mod.SourceTab);
+ var shown: [which.len]widgets.PanelTab = undefined;
+ for (which, &shown) |tab, *made| made.* = switch (tab) {
+ .source => .{ .label = document.file, .icon = .file_code, .icon_color = theme.accent, .dot = document.dirty(), .on = app.source_tab == tab },
+ .listing => .{ .label = "Script listing", .icon = .list, .detail = if (document.built) |built| built.file else "not built", .on = app.source_tab == tab },
+ };
+ if (widgets.panelTabs(&shown)) |index| app.source_tab = which[index];
}
/// The line under the tabs: the file, the record the cursor is in, and its statement, with the