diff --git a/README.md b/README.md
index 1c741be..19a217f 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,10 @@ 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. 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
+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.
The plan is OpenReliant's [#1126](https://github.com/OpenReliant/openreliant/issues/1126).
diff --git a/design/images/keys-macos.png b/design/images/keys-macos.png
index aa3b1c6..e03601c 100644
Binary files a/design/images/keys-macos.png and b/design/images/keys-macos.png differ
diff --git a/design/images/keys.png b/design/images/keys.png
index a78d1ff..0fe0031 100644
Binary files a/design/images/keys.png and b/design/images/keys.png differ
diff --git a/design/images/settings-link.png b/design/images/settings-link.png
index 2df49a3..f963431 100644
Binary files a/design/images/settings-link.png and b/design/images/settings-link.png differ
diff --git a/design/screens/keys-macos.html b/design/screens/keys-macos.html
index 0ef5fc5..a1f6d85 100644
--- a/design/screens/keys-macos.html
+++ b/design/screens/keys-macos.html
@@ -22,6 +22,8 @@
.panel{background:#10131b;display:flex;flex-direction:column;min-width:0;border:1px solid #1a1f2b;border-radius:3px;overflow:hidden}
.nav{display:flex;align-items:center;gap:9px;width:100%;height:30px;padding:0 12px;border:0;border-radius:4px;background:none;color:#c9cfdb;font:inherit;font-size:13px;text-align:left;cursor:pointer}
.nav:hover{background:#171b26}
+.back{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:4px;background:none;color:#c9cfdb;cursor:pointer}
+.back:hover{background:#171b26;color:#ffffff}
.nav-on,.nav-on:hover{background:#262d3d;color:#ffffff}
.sect{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#8a93a6}
.kr{display:grid;grid-template-columns:minmax(150px,1.4fr) minmax(130px,1fr) minmax(130px,1fr) 40px;align-items:center;min-height:32px;padding:0 14px;border-bottom:1px solid #161b26;font-size:12.5px;color:#d4d8e1}
@@ -58,7 +60,10 @@
-Settings
+
General
Appearance
diff --git a/design/screens/keys.html b/design/screens/keys.html
index ef11424..e48bf4d 100644
--- a/design/screens/keys.html
+++ b/design/screens/keys.html
@@ -22,6 +22,8 @@
.panel{background:#10131b;display:flex;flex-direction:column;min-width:0;border:1px solid #1a1f2b;border-radius:3px;overflow:hidden}
.nav{display:flex;align-items:center;gap:9px;width:100%;height:30px;padding:0 12px;border:0;border-radius:4px;background:none;color:#c9cfdb;font:inherit;font-size:13px;text-align:left;cursor:pointer}
.nav:hover{background:#171b26}
+.back{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:4px;background:none;color:#c9cfdb;cursor:pointer}
+.back:hover{background:#171b26;color:#ffffff}
.nav-on,.nav-on:hover{background:#262d3d;color:#ffffff}
.sect{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#8a93a6}
.kr{display:grid;grid-template-columns:minmax(150px,1.4fr) minmax(130px,1fr) minmax(130px,1fr) 40px;align-items:center;min-height:32px;padding:0 14px;border-bottom:1px solid #161b26;font-size:12.5px;color:#d4d8e1}
@@ -58,7 +60,10 @@
-Settings
+
General
Appearance
diff --git a/design/screens/settings-link.html b/design/screens/settings-link.html
index ab3c142..7a033f7 100644
--- a/design/screens/settings-link.html
+++ b/design/screens/settings-link.html
@@ -22,6 +22,8 @@
.panel{background:#10131b;display:flex;flex-direction:column;min-width:0;border:1px solid #1a1f2b;border-radius:3px;overflow:hidden}
.nav{display:flex;align-items:center;gap:9px;width:100%;height:30px;padding:0 12px;border:0;border-radius:4px;background:none;color:#c9cfdb;font:inherit;font-size:13px;text-align:left;cursor:pointer}
.nav:hover{background:#171b26}
+.back{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:4px;background:none;color:#c9cfdb;cursor:pointer}
+.back:hover{background:#171b26;color:#ffffff}
.nav-on,.nav-on:hover{background:#262d3d;color:#ffffff}
.sect{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#8a93a6}
.kr{display:grid;grid-template-columns:minmax(150px,1.4fr) minmax(130px,1fr) minmax(130px,1fr) 40px;align-items:center;min-height:32px;padding:0 14px;border-bottom:1px solid #161b26;font-size:12.5px;color:#d4d8e1}
@@ -70,7 +72,10 @@
-Settings
+
General
Appearance
diff --git a/docs/architecture.md b/docs/architecture.md
index c06f102..8353907 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -15,12 +15,14 @@ own library, which `build.zig.zon` pins, so it reads every mission exactly as th
| [`game.zig`](../src/game.zig) | What the editor asks of a game: the profile |
| [`game/starlancer.zig`](../src/game/starlancer.zig) | StarLancer's profile, on OpenReliant's tables and compiler |
| [`project.zig`](../src/project.zig) | A mod folder and its missions, and the mods of a game folder |
-| [`mission/document.zig`](../src/mission/document.zig) | An open mission: its source, its checks, its build and its breakpoints |
+| [`mission/document.zig`](../src/mission/document.zig) | An open mission: its source, its checks, its build and its breakpoints, and the changes the interface asks for |
+| [`mission/edit.zig`](../src/mission/edit.zig) | Changes to spans of mission source: a field of a record's table set, added or taken out, an argument replaced |
| [`link/connection.zig`](../src/link/connection.zig) | The editor link's connection to the game, on a task of its own |
| [`debug/session.zig`](../src/debug/session.zig) | The debugger: the link's state, the game's mission matched with the build, the holds, the state and the actions |
| [`debug/breakpoints.zig`](../src/debug/breakpoints.zig) | Where each breakpoint holds the script, and each source file's breakpoints kept in the settings folder |
| [`shell.zig`](../src/shell.zig) | The workspace's menu bar, toolbar, dock space and status bar |
| [`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 |
| [`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 |
@@ -37,7 +39,8 @@ profile, a `game.Profile`:
- the words of mission source, from the game's own tables: the language's keywords, mission
source's declarations, the game's commands and conditions with their descriptions and
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 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;
- 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;
@@ -60,6 +63,23 @@ and builds it in memory, and the problems show in the gutter, under the text and
panel. While the source doesn't parse, the outline is the last one that did. Build writes the
mission file beside the source and keeps its script's listing.
+## Editing records
+
+The inspector shows the selected record as a form ([Editing records](editing.md)). The profile gives
+the fields each record's table can give, from OpenReliant's tables of the language
+(`dte.source.language`), each with its kind, which picks its control: a list of the mission's
+records or of the game's values, X, Y and Z for a vector, a box for a number, a checkbox, or the
+value's source for the rest. A few hints lay out a ship's place as the design does, under a heading
+with its angles on one row.
+
+A change, from a form, the outline's + or a Problems card's fix, is a change to one span of the
+source, made as one step of the editor's undo. OpenReliant's syntax tree gives where each node
+starts; `mission/edit.zig` reads the text from there for where the value ends, and sets, adds or
+takes out a field in the table's own layout: on its line, or on a line of its own where the items
+are each on one. While drawing, the interface only queues the change (`Document.queue`). The app
+makes it once the frame is drawn, since making it checks the text again, which replaces the outline
+the frame drew from.
+
## The debugger
The debugger links to the game over OpenReliant's editor link ([Debugging a mission](debugging.md)).
@@ -90,8 +110,6 @@ Each shows in the interface, greyed out, with its issue:
([#22](https://github.com/OpenReliant/openreliant-editor/issues/22));
- the guided first mission ([#6](https://github.com/OpenReliant/openreliant-editor/issues/6));
- new mods from templates ([#7](https://github.com/OpenReliant/openreliant-editor/issues/7));
-- adding and editing records with forms
- ([#8](https://github.com/OpenReliant/openreliant-editor/issues/8));
- opening `.hog` mods ([#9](https://github.com/OpenReliant/openreliant-editor/issues/9));
- the General and Appearance settings
([#10](https://github.com/OpenReliant/openreliant-editor/issues/10)).
diff --git a/docs/editing.md b/docs/editing.md
new file mode 100644
index 0000000..f534ae1
--- /dev/null
+++ b/docs/editing.md
@@ -0,0 +1,34 @@
+# Editing records
+
+The inspector shows the record selected in the outline, or the one the source's cursor is in, as a
+form. Each change is written into the source at once, as one step of Undo, and the source keeps its
+own layout. The form can't change the source while it has problems: fix them first.
+
+## The forms
+
+- **Name** is the display name the game shows. **Declared as** is the name the source declares it
+ under, which the script uses.
+- A field that names a record, such as a ship's **Group**, lists the mission's records it can name.
+ One that names a value of the game, such as a ship's **Type** or **Pilot**, lists the game's
+ values, with a box to find one in a long list. `none` takes the field out of the table.
+- A ship's or a marker's **Position** has a box for X, Y and Z, and its **Angles** a box for the
+ yaw, the pitch and the roll, in degrees. Y points down.
+- A number takes effect with Enter or by leaving its box. Escape puts back the value.
+- A field the form can't show as a list or a number, such as a ship's **Launch**, shows its source,
+ which you can type.
+- A trigger's form sets what it **Watches**, its **Condition**, and the values of the condition's
+ events it checks, how it **Repeats** and whether it's **Deferred**.
+
+Each field's label tells what the field is when you rest the pointer on it.
+
+## Adding records
+
+The outline's + button, or Mission · Add a record, lists the records it can add: a ship, a nav
+point, a waypoint, a flight group, a part, a trigger and a global. The new record goes after the
+last one of its kind, under a name no record has, such as `ship_1`, and the form opens on it. A new
+trigger watches nothing until you choose what it watches.
+
+A name the source uses without declaring it shows in the Problems panel with **Declare**, which adds
+a record under that name: choose what kind it is.
+
+Squads, formations and curves list other records, and are written in the source.
diff --git a/src/app.zig b/src/app.zig
index db8daa4..45ef76f 100644
--- a/src/app.zig
+++ b/src/app.zig
@@ -170,6 +170,11 @@ pub const App = struct {
project: ?Project = null,
document: ?*Document = null,
selection: ?Selection = null,
+ /// Whether the menu of records to add opens on the next frame, as + and Add a record ask, and
+ /// the name a Problems card's Declare gives the new record, `declare_name[0..declare_length]`.
+ add_menu: bool = false,
+ declare_name: [128]u8 = undefined,
+ declare_length: ?usize = null,
/// The word whose reference the Reference panel shows.
reference_word: ?[]const u8 = null,
reference_buffer: [64]u8 = undefined,
@@ -278,6 +283,7 @@ pub const App = struct {
palette.draw(app);
app.toasts.draw(app.window);
app.drawCloseConfirmation();
+ app.makeQueued();
}
/// The shortcuts of the actions, run while the editor's windows have the keyboard.
@@ -311,7 +317,7 @@ 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) {
- .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, .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,
.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,
@@ -380,7 +386,8 @@ pub const App = struct {
/// Runs `command`.
pub fn run(app: *App, command: Command) void {
switch (command) {
- .new_mod, .open_hog, .switch_mode, .layout_guided, .add_record => {},
+ .new_mod, .open_hog, .switch_mode, .layout_guided => {},
+ .add_record => app.askToAdd(null),
.open_file => app.chooseFile(),
.edit_game_mission => app.palette.show(.game_missions),
.@"continue" => app.session.release(.run_on),
@@ -969,6 +976,47 @@ pub const App = struct {
if (document.outline().find(selected.section, selected.line) == null) app.selection = null;
}
+ /// Opens the menu of records to add, which declares the record chosen under `name` where one
+ /// is given, as a Problems card's Declare asks.
+ pub fn askToAdd(app: *App, name: ?[]const u8) void {
+ app.add_menu = true;
+ app.declare_length = null;
+ const given = name orelse return;
+ if (given.len > app.declare_name.len) return;
+ @memcpy(app.declare_name[0..given.len], given);
+ app.declare_length = given.len;
+ }
+
+ /// The name the menu of records to add declares the new one under, where a Declare asked.
+ pub fn declaring(app: *const App) ?[]const u8 {
+ return app.declare_name[0 .. app.declare_length orelse return null];
+ }
+
+ /// Makes the change to the source that the interface asked for during the frame
+ /// (`Document.queue`), now that nothing drawn holds on to the outline it replaces: says where
+ /// it can't be made, and selects a record added.
+ fn makeQueued(app: *App) void {
+ const document = app.document orelse return;
+ const asked = document.queued orelse return;
+ const made = document.flush() catch {
+ app.toasts.push(.failure, "Can't change the source", "Out of memory");
+ return;
+ };
+ const reason = if (document.outline_stale) "Fix the source's problems first" else switch (asked) {
+ .add => "The name is taken",
+ else => "The declaration has no place for it",
+ };
+ switch (made) {
+ .none => {},
+ .edited => |done| if (!done) app.toasts.push(.info, "Can't change it here", reason),
+ .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);
+ },
+ }
+ }
+
pub fn select(app: *App, record: game.Record) void {
app.selection = .{ .section = record.section, .line = record.first_line };
}
diff --git a/src/commands.zig b/src/commands.zig
index 7f366f3..ba26772 100644
--- a/src/commands.zig
+++ b/src/commands.zig
@@ -203,7 +203,6 @@ pub const plans = struct {
pub const live_changes: Plan = .{ .issue = 22 };
pub const guided: Plan = .{ .issue = 6 };
pub const templates: Plan = .{ .issue = 7 };
- pub const records: Plan = .{ .issue = 8 };
pub const hogs: Plan = .{ .issue = 9 };
pub const settings: Plan = .{ .issue = 10 };
pub const live_scene: Plan = .{ .issue = 21 };
@@ -249,7 +248,7 @@ pub fn info(command: Command) Info {
.full_screen => .{ .label = "Full screen", .icon = .maximize, .when = .not_held, .keys = defaults(&.{"F11"}, &.{"Ctrl+Cmd+F"}), .key_group = .general },
.build => .{ .label = "Build", .icon = .hammer, .keys = defaults(&.{"Ctrl+B"}, &.{"Cmd+B"}), .key_group = .build_and_play },
- .add_record => .{ .label = "Add a record", .icon = .plus, .planned = plans.records },
+ .add_record => .{ .label = "Add a record", .icon = .plus },
.show_source => .{ .label = "Mission source", .icon = .file_code },
.show_listing => .{ .label = "Script listing", .icon = .list },
diff --git a/src/forms.zig b/src/forms.zig
new file mode 100644
index 0000000..a928c07
--- /dev/null
+++ b/src/forms.zig
@@ -0,0 +1,329 @@
+//! The inspector's forms: a record's display name and the fields of its table, each with the
+//! control its kind takes (`game.FieldKind`), a global's starting value, and a trigger's subject,
+//! condition and the values it checks. A change goes into the source through the game's profile,
+//! as one step of the editor's undo, once the frame is drawn (`Document.queue`).
+
+const std = @import("std");
+
+const App = @import("app.zig").App;
+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 theme = ui.theme;
+const widgets = ui.widgets;
+const px = gui.px;
+
+/// The space between the boxes of one row, such as a vector's.
+const row_gap = 4;
+/// The space between a field's label and its control.
+const label_gap = 8;
+
+/// The form of a record but a trigger: its display name, the name it's declared under, a global's
+/// starting value, and its table's fields, with the Scene design's labels.
+pub fn record(app: *App, document: *Document, known: game.Outline, shown: game.Record, off: ?widgets.Disabled) void {
+ const form: Form = .{ .app = app, .document = document, .known = known, .shown = shown, .off = off, .label_width = 84 };
+ widgets.sectionHeading(panels.capitalNoun(shown.section));
+ gui.space(9);
+ if (shown.section != .curves and !std.mem.eql(u8, shown.word, "handler")) {
+ form.label("Name", false);
+ if (widgets.textField("name", shown.display, .{ .disabled = off })) |typed| form.apply(.{ .display = typed });
+ }
+ form.label("Declared as", false);
+ _ = widgets.frame("declared", .{ .label = shown.name, .mono_label = true, .chevron = false });
+ if (shown.section == .globals) {
+ form.label("Value", false);
+ if (widgets.textField("value", shown.detail, .{ .number = true, .disabled = off })) |typed| {
+ if (form.numberText(typed)) |value| form.apply(.{ .value = value });
+ }
+ }
+ const specs = document.profile.fields(shown);
+ var index: usize = 0;
+ while (index < specs.len) {
+ const spec = specs[index];
+ if (spec.heading) |heading| {
+ gui.space(9);
+ widgets.sectionHeading(heading);
+ gui.space(9);
+ }
+ if (spec.row) |row| {
+ // The fields that share the row, as a ship's angles do.
+ var end = index + 1;
+ while (end < specs.len and specs[end].row != null and std.mem.eql(u8, specs[end].row.?, row)) end += 1;
+ form.numberRow(row, specs[index..end]);
+ index = end;
+ continue;
+ }
+ form.field(spec, spec.label orelse labelOf(spec.name), false);
+ index += 1;
+ }
+}
+
+/// A trigger's form: what it watches, its condition and the values it checks, how it repeats,
+/// and whether it's deferred, with the Script design's labels, wide enough for the values' names.
+pub fn trigger(app: *App, document: *Document, known: game.Outline, shown: game.Record, off: ?widgets.Disabled) void {
+ const form: Form = .{ .app = app, .document = document, .known = known, .shown = shown, .off = off, .label_width = 96 };
+ widgets.sectionHeading("Trigger");
+ gui.space(9);
+ form.label("Watches", false);
+ const subject = known.named(shown.subject);
+ if (form.pickRecord("watches", document.profile.fieldKind("Entity").record, true, if (subject) |found| found.* else null, shown.subject)) |chosen| {
+ form.apply(.{ .subject = chosen orelse "nil" });
+ }
+
+ form.label("Condition", false);
+ const allocator = gui.frameAllocator();
+ var conditions: std.ArrayList(widgets.Choice) = .empty;
+ for (app.language.words) |word| if (word.kind == .condition) {
+ conditions.append(allocator, .{ .label = word.name, .mono = true }) catch break;
+ };
+ if (widgets.pick("condition", .{ .label = shown.condition, .mono_label = true, .disabled = off }, conditions.items)) |index| {
+ form.apply(.{ .condition = conditions.items[index].label });
+ }
+ if (app.language.find(shown.condition)) |word| {
+ form.note(gui.print("{s} · {s}", .{ word.source, word.about }));
+ // The values the condition's events carry, which the trigger can check.
+ for (word.parameters) |parameter| {
+ const spec: game.FieldForm = .{ .name = parameter.name, .about = parameter.label, .kind = document.profile.fieldKind(parameter.type) };
+ form.field(spec, parameter.name, true);
+ }
+ }
+
+ const repeats = fieldOf(shown, "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) {
+ const value: ?[]const u8 = switch (chosen) {
+ 0 => null,
+ 1 => "\"always\"",
+ else => "{ counted = 1 }",
+ };
+ if (value != null or repeats != null) form.set("repeats", value);
+ };
+ 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 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");
+}
+
+/// What a form works on, and how it lays out its labels.
+const Form = struct {
+ app: *App,
+ document: *Document,
+ known: game.Outline,
+ shown: game.Record,
+ off: ?widgets.Disabled,
+ /// The width of its labels, in design pixels.
+ label_width: f32,
+
+ fn label(form: Form, text: []const u8, mono: bool) void {
+ widgets.fieldLabelWidth(text, mono, form.label_width);
+ }
+
+ /// A line of text under the controls, as a condition's description or the angles' unit.
+ fn note(form: Form, text: []const u8) void {
+ const left = gui.cursor().x;
+ gui.setCursor(.{ .x = left + px(form.label_width + label_gap), .y = gui.cursor().y });
+ widgets.caption(text, theme.text_dim, fonts.size.small, .regular);
+ gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(3) });
+ }
+
+ /// A field's row: its label, in code's letters for a name the script uses, and the control its
+ /// kind takes.
+ fn field(form: Form, spec: game.FieldForm, text: []const u8, mono: bool) void {
+ form.label(text, mono);
+ gui.tooltip(spec.about);
+ gui.pushId(spec.name);
+ defer gui.popId();
+ const given = fieldOf(form.shown, 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;
+ const current = if (target) |found| found.* else null;
+ if (form.pickRecord("record", sections, spec.optional, current, if (given) |found| found.value else "none")) |chosen| {
+ if (chosen != null or given != null) form.set(spec.name, chosen);
+ }
+ },
+ .value => |type_name| {
+ var names: std.ArrayList([]const u8) = .empty;
+ for (form.app.language.words) |word| if (word.kind == .value and std.mem.eql(u8, word.type, type_name)) {
+ names.append(gui.frameAllocator(), word.name) catch break;
+ };
+ form.pickName(spec, given, names.items);
+ },
+ .strings => |names| form.pickName(spec, given, names),
+ .vector => form.vector(spec, given),
+ .number => if (widgets.textField("number", if (given) |found| found.value else "", .{ .number = true, .hint = if (spec.optional) "none" else null, .disabled = form.off })) |typed| {
+ if (std.mem.trim(u8, typed, " ").len == 0 and spec.optional) {
+ if (given != null) form.set(spec.name, null);
+ } else if (form.numberText(typed)) |value| form.set(spec.name, value);
+ },
+ .boolean => {
+ const checked = if (given) |found| std.mem.eql(u8, found.value, "true") else false;
+ if (widgets.checkbox("boolean", "", checked, form.off)) form.set(spec.name, if (checked) null else "true");
+ },
+ .source => if (widgets.textField("source", if (given) |found| found.source else "", .{ .mono = true, .hint = "none", .disabled = form.off })) |typed| {
+ const trimmed = std.mem.trim(u8, typed, " ");
+ if (trimmed.len == 0) {
+ if (given != null) form.set(spec.name, null);
+ } else form.set(spec.name, trimmed);
+ },
+ }
+ }
+
+ /// 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);
+ const tags = [3]widgets.Tag{
+ .{ .letter = "X", .color = theme.axis_x },
+ .{ .letter = "Y", .color = theme.axis_y },
+ .{ .letter = "Z", .color = theme.axis_z },
+ };
+ const at = gui.cursor();
+ const each = (gui.available().x - px(row_gap) * 2) / 3;
+ for (tags, 0..) |tag, axis| {
+ gui.setCursor(.{ .x = at.x + @as(f32, @floatFromInt(axis)) * (each + px(row_gap)), .y = at.y });
+ gui.pushIdInt(axis);
+ defer gui.popId();
+ const typed = widgets.textField("axis", gui.print("{d}", .{values[axis]}), .{ .number = true, .tag = tag, .width = each, .disabled = form.off }) orelse continue;
+ values[axis] = std.fmt.parseFloat(f64, std.mem.trim(u8, typed, " ")) catch {
+ form.app.toasts.push(.info, "Not a number", typed);
+ continue;
+ };
+ form.set(spec.name, source_edit.vector(gui.frameAllocator(), &values) catch return);
+ }
+ }
+
+ /// Number fields that share a row, each with its letter, as a ship's angles, and the last
+ /// one's note under them. A field left out shows 0.
+ fn numberRow(form: Form, text: []const u8, specs: []const game.FieldForm) void {
+ form.label(text, false);
+ gui.tooltip(specs[0].about);
+ const at = gui.cursor();
+ const count: f32 = @floatFromInt(specs.len);
+ const each = (gui.available().x - px(row_gap) * (count - 1)) / count;
+ for (specs, 0..) |spec, place| {
+ 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 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);
+ }
+ }
+ if (specs[specs.len - 1].note) |shown| form.note(shown);
+ }
+
+ /// The list of a field that takes one of `names`, such as a ship type or a wing, written as a
+ /// string, with `none` first where it can be left out.
+ fn pickName(form: Form, spec: game.FieldForm, given: ?game.Field, names: []const []const u8) void {
+ const allocator = gui.frameAllocator();
+ var choices: std.ArrayList(widgets.Choice) = .empty;
+ if (spec.optional) choices.append(allocator, .{ .label = "none", .dim = true }) catch return;
+ for (names) |name| choices.append(allocator, .{ .label = name, .mono = true }) catch return;
+ const frame: widgets.Frame = if (given) |found|
+ .{ .label = std.mem.trim(u8, found.value, "\""), .mono_label = true, .disabled = form.off }
+ else
+ .{ .label = "none", .dim_label = true, .disabled = form.off };
+ const index = widgets.pick("name", frame, choices.items) orelse return;
+ if (spec.optional and index == 0) {
+ if (given != null) form.set(spec.name, null);
+ return;
+ }
+ form.set(spec.name, source_edit.string(allocator, choices.items[index].label) catch return);
+ }
+
+ /// The list of the records of `sections`, with `none` first where the value can be left out:
+ /// the name of the record chosen, or null for `none`, once one is chosen. `current` is the
+ /// record the value names; `value`, the value as the source writes it, shows where it names
+ /// none.
+ fn pickRecord(form: Form, id: []const u8, sections: []const game.Section, optional: bool, current: ?game.Record, value: []const u8) ??[]const u8 {
+ const allocator = gui.frameAllocator();
+ var choices: std.ArrayList(widgets.Choice) = .empty;
+ var names: std.ArrayList(?[]const u8) = .empty;
+ if (optional) {
+ choices.append(allocator, .{ .label = "none", .dim = true }) catch return null;
+ names.append(allocator, null) catch return null;
+ }
+ for (form.known.records) |candidate| {
+ if (candidate.name.len == 0 or std.mem.findScalar(game.Section, sections, candidate.section) == null) continue;
+ choices.append(allocator, .{ .label = candidate.title, .detail = candidate.name, .mark = markOf(candidate) }) catch return null;
+ names.append(allocator, candidate.name) catch return null;
+ }
+ const frame: widgets.Frame = if (current) |found|
+ .{ .label = found.title, .detail = found.name, .mark = markOf(found), .disabled = form.off }
+ else if (std.mem.eql(u8, value, "none") or std.mem.eql(u8, value, "nil"))
+ .{ .label = "none", .dim_label = true, .disabled = form.off }
+ else
+ .{ .label = value, .mono_label = true, .disabled = form.off };
+ const index = widgets.pick(id, frame, choices.items) orelse return null;
+ return names.items[index];
+ }
+
+ /// Sets the field `name` of the record's table to `value`, or takes it out with null.
+ fn set(form: Form, name: []const u8, value: ?[]const u8) void {
+ form.apply(.{ .field = .{ .name = name, .value = value } });
+ }
+
+ /// Asks for `edit` to the record, which the app makes once the frame is drawn.
+ fn apply(form: Form, edit: game.Edit) void {
+ form.document.queue(.{ .edit = .{ .section = form.shown.section, .index = form.shown.index, .edit = edit } });
+ }
+
+ /// `typed` as mission source writes a number, or null where it isn't one, which a toast says.
+ fn numberText(form: Form, typed: []const u8) ?[]const u8 {
+ const value = std.fmt.parseFloat(f64, std.mem.trim(u8, typed, " ")) catch {
+ form.app.toasts.push(.info, "Not a number", typed);
+ return null;
+ };
+ return source_edit.number(gui.frameAllocator(), value) catch null;
+ }
+};
+
+/// A record's mark in a list: a flight group's colour, or its section's icon in its group's.
+fn markOf(shown: game.Record) widgets.RowMark {
+ if (shown.section == .flight_groups) return .{ .swatch = panels.groupColor(shown) };
+ 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;
+ std.mem.replaceScalar(u8, made, '_', ' ');
+ if (made.len > 0) made[0] = std.ascii.toUpper(made[0]);
+ 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 e56cdfa..ab32d23 100644
--- a/src/game.zig
+++ b/src/game.zig
@@ -37,6 +37,21 @@ pub const Profile = struct {
build: *const fn (arena: Allocator, text: []const u8) Allocator.Error!Built,
/// The records the source declares, for the outline, with the errors Luau's parser finds.
outline: *const fn (arena: Allocator, text: []const u8) Allocator.Error!Outline,
+ /// The change to the source `text` that makes `edit` to `record`, as the outline of `text`
+ /// read it; null where the declaration has no place for it, such as a field taken out that
+ /// isn't there.
+ edit: *const fn (arena: Allocator, text: []const u8, record: Record, edit: Edit) Allocator.Error!?Change,
+ /// The fields a record's table can give, from the game's tables, for the inspector's form.
+ fields: *const fn (record: Record) []const FieldForm,
+ /// 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,
+ /// 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
+ /// the last record of its section: under `name` where one is given, or else a name no record
+ /// has. Null where `name` is taken.
+ addRecord: *const fn (arena: Allocator, text: []const u8, known: Outline, kind: NewKind, name: ?[]const u8) Allocator.Error!?Added,
/// A built mission's script, as a listing of its instructions.
listing: *const fn (arena: Allocator, mission: []const u8) Allocator.Error![]const u8,
/// The number a mission file's name gives the mission: 90 for `mission90.dte`.
@@ -138,6 +153,83 @@ pub const Play = struct {
link: ?u16 = null,
};
+/// A change the inspector or the map makes to a record of mission source.
+pub const Edit = union(enum) {
+ /// A field of its table set to a value as the source writes it, or taken out with null.
+ field: struct { name: []const u8, value: ?[]const u8 },
+ /// Its display name.
+ display: []const u8,
+ /// A trigger's subject, as the source writes it: a record's name, or `nil`.
+ subject: []const u8,
+ /// A trigger's condition, by its name.
+ condition: []const u8,
+ /// A global's starting value, as the source writes it.
+ value: []const u8,
+};
+
+/// A field a record's table can give, for the inspector's form.
+pub const FieldForm = struct {
+ name: []const u8,
+ /// What it is, in the language's words.
+ about: []const u8,
+ kind: FieldKind,
+ /// Whether the table can leave it out, which `none` does.
+ optional: bool = true,
+ /// The label the form shows; by default its name, as `Formation point` for `formation_point`.
+ label: ?[]const u8 = null,
+ /// A heading the form shows before it, such as `Place`.
+ heading: ?[]const u8 = null,
+ /// For a field shown on one row with the next ones of the same row: the row's label, such as
+ /// `Angles`, and the field's letter in it, such as `Y` for the yaw.
+ row: ?[]const u8 = null,
+ letter: []const u8 = "",
+ /// A note under its row, such as the angles' unit.
+ note: ?[]const u8 = null,
+};
+
+/// The kind of value a field takes, which picks its control in the inspector.
+pub const FieldKind = union(enum) {
+ /// A record of the mission, of one of these sections, by its name.
+ record: []const Section,
+ /// A name of the game's values of this type, such as `ShipType`: the language's words of
+ /// kind `value` whose type it is.
+ value: []const u8,
+ /// One of a few strings.
+ strings: []const []const u8,
+ /// A place or a direction: `{ x, y, z }`.
+ vector,
+ number,
+ boolean,
+ /// Anything else, which the inspector shows and takes as source.
+ source,
+};
+
+/// A kind of record the outline's + adds.
+pub const NewKind = struct {
+ /// The word that declares it, such as `ship`.
+ word: []const u8,
+ /// What the menu calls it, such as `Ship`.
+ label: []const u8,
+ section: Section,
+ /// Whether it's declared under a name the script uses, which a trigger isn't, so that
+ /// Declare can't offer it.
+ named: bool = true,
+};
+
+/// A record declared: the change to the source, and the line its declaration starts on once the
+/// change is made.
+pub const Added = struct {
+ change: Change,
+ line: u32,
+};
+
+/// A change to mission source: the bytes from `start` to `end` replaced by `text`.
+pub const Change = struct {
+ start: usize,
+ end: usize,
+ text: []const u8,
+};
+
/// A place in a file, counting lines and columns from 0.
pub const Place = struct {
line: u32,
@@ -363,6 +455,14 @@ pub const Outline = struct {
return best;
}
+ /// The record of `section` at `index` among its section's.
+ pub fn nth(outline: Outline, section: Section, index: usize) ?*const Record {
+ for (outline.records) |*record| {
+ if (record.section == section and record.index == index) return record;
+ }
+ return null;
+ }
+
/// The record of `section` whose declaration starts on `line`.
pub fn find(outline: Outline, section: Section, line: u32) ?*const Record {
for (outline.records) |*record| {
@@ -383,6 +483,8 @@ pub const Outline = struct {
/// A field of a record's table: `wing = "player"`.
pub const Field = struct {
name: []const u8,
+ /// The value's source text, as it is.
+ source: []const u8 = "",
/// The value as the source writes it, for a name, a string, a number or a boolean; "…" for
/// anything longer.
value: []const u8,
@@ -392,6 +494,8 @@ pub const Field = struct {
pub const Record = struct {
section: Section,
+ /// The word that declares it, such as `ship` or `nav_point`, or `on` for a trigger.
+ word: []const u8 = "",
/// The name the source declares it under, such as `nav`; empty for a trigger, which has none.
name: []const u8,
/// What the outline shows: the record's display name, a flight group's without its tag, or a
diff --git a/src/game/starlancer.zig b/src/game/starlancer.zig
index f673ce4..07b46e6 100644
--- a/src/game/starlancer.zig
+++ b/src/game/starlancer.zig
@@ -17,6 +17,7 @@ const vm = openreliant.engine.vm;
const mission_editor = openreliant.engine.game.mission.editor;
const game = @import("../game.zig");
+const source_edit = @import("../mission/edit.zig");
pub const profile: game.Profile = .{
.name = "StarLancer",
@@ -27,6 +28,11 @@ pub const profile: game.Profile = .{
.language = language,
.build = build,
.outline = outline,
+ .edit = edit,
+ .fields = fieldForms,
+ .fieldKind = fieldKind,
+ .new_kinds = &new_kinds,
+ .addRecord = addRecord,
.listing = listing,
.missionNumber = missionNumber,
// The game's missions don't use 90 and on, as the guide to writing missions says.
@@ -325,7 +331,7 @@ fn outline(arena: Allocator, text: []const u8) Allocator.Error!game.Outline {
else => continue,
};
const call = callOf(tree, assign.values[0]) orelse continue;
- var record = try recordOf(arena, tree, call, name, &groups) orelse continue;
+ var record = try recordOf(arena, text, tree, call, name, &groups) orelse continue;
record.first_line = first;
record.last_line = last;
record.column = tree.at(statement).column;
@@ -348,14 +354,15 @@ fn outline(arena: Allocator, text: []const u8) Allocator.Error!game.Outline {
.string => |string| string,
else => "?",
};
- const fields: ?syntax.Index = if (call.arguments.len > 2 and tree.get(call.arguments[2]) == .table) call.arguments[2] else null;
+ const fields = tableOf(tree, call);
try records.append(arena, .{
.section = .triggers,
+ .word = "on",
.name = "",
.subject = subject,
.title = try arena.print("{s} · {s}", .{ subject, condition }),
.condition = condition,
- .fields = if (fields) |table| try fieldsOf(arena, tree, table) else &.{},
+ .fields = if (fields) |table| try fieldsOf(arena, text, tree, table) else &.{},
.first_line = first,
.last_line = last,
.column = tree.at(statement).column,
@@ -373,8 +380,9 @@ fn outline(arena: Allocator, text: []const u8) Allocator.Error!game.Outline {
return .{ .name = mission_name, .records = try records.toOwnedSlice(arena), .errors = errors };
}
-/// The fields of the table at `table`, with their simple values as the source writes them.
-fn fieldsOf(arena: Allocator, tree: syntax.Tree, table: syntax.Index) Allocator.Error![]const game.Field {
+/// The fields of the table at `table` of `text`, with their simple values as the source writes
+/// them, and each value's own text.
+fn fieldsOf(arena: Allocator, text: []const u8, tree: syntax.Tree, table: syntax.Index) Allocator.Error![]const game.Field {
const items = switch (tree.get(table)) {
.table => |items| items,
else => return &.{},
@@ -384,8 +392,10 @@ fn fieldsOf(arena: Allocator, tree: syntax.Tree, table: syntax.Index) Allocator.
.item => |shown| switch (shown) {
.record => |record| {
const value = tree.get(record.value);
+ const start, const end = source_edit.span(tree, text, record.value);
try found.append(arena, .{
.name = record.key,
+ .source = text[start..end],
.value = switch (value) {
.global_name => |name| name,
.string => |string| try arena.print("\"{s}\"", .{string}),
@@ -405,6 +415,16 @@ fn fieldsOf(arena: Allocator, tree: syntax.Tree, table: syntax.Index) Allocator.
return found.toOwnedSlice(arena);
}
+/// The table of fields of a declaration's call: a trigger's third argument where it's a table, or
+/// another declaration's first table.
+fn tableOf(tree: syntax.Tree, call: Call) ?syntax.Index {
+ if (std.mem.eql(u8, call.callee, "on")) {
+ return if (call.arguments.len > 2 and tree.get(call.arguments[2]) == .table) call.arguments[2] else null;
+ }
+ for (call.arguments) |argument| if (tree.get(argument) == .table) return argument;
+ return null;
+}
+
/// A call by a name: the name, and its arguments.
const Call = struct {
callee: []const u8,
@@ -425,16 +445,14 @@ fn callOf(tree: syntax.Tree, index: syntax.Index) ?Call {
/// The record `name = constructor(...)` declares, without its lines, or null for a call that
/// declares none.
-fn recordOf(arena: Allocator, tree: syntax.Tree, call: Call, name: []const u8, groups: *const std.StringHashMapUnmanaged(usize)) Allocator.Error!?game.Record {
+fn recordOf(arena: Allocator, text: []const u8, tree: syntax.Tree, call: Call, name: []const u8, groups: *const std.StringHashMapUnmanaged(usize)) Allocator.Error!?game.Record {
const word = std.meta.stringToEnum(source.names.Word, call.callee) orelse return null;
const display = if (call.arguments.len > 0) stringOf(tree, call.arguments[0]) else null;
- const fields: ?syntax.Index = for (call.arguments) |argument| {
- if (tree.get(argument) == .table) break argument;
- } else null;
- var record: game.Record = .{ .section = .ships, .name = name, .title = display orelse name, .display = display orelse "", .first_line = 0, .last_line = 0 };
+ const fields = tableOf(tree, call);
+ var record: game.Record = .{ .section = .ships, .word = call.callee, .name = name, .title = display orelse name, .display = display orelse "", .first_line = 0, .last_line = 0 };
if (fields) |table| {
if (globalField(tree, table, "group")) |group| record.group = groups.get(group);
- record.fields = try fieldsOf(arena, tree, table);
+ record.fields = try fieldsOf(arena, text, tree, table);
}
switch (word) {
.flight_group => {
@@ -487,6 +505,206 @@ fn recordOf(arena: Allocator, tree: syntax.Tree, call: Call, name: []const u8, g
return record;
}
+/// The change to `text` that makes `change` to `record`, through the declaration on the record's
+/// first line: its table's field, its display name, a trigger's subject or condition, or a
+/// global's value. A record without a table gets one where its declaration takes it: a trigger's
+/// after its condition, a part's after its code, and the others' after their display name.
+fn edit(arena: Allocator, text: []const u8, record: game.Record, change: game.Edit) Allocator.Error!?game.Change {
+ const tree = try source_parser.parse(arena, text);
+ const found = declarationAt(tree, record.first_line) orelse return null;
+ const arguments = found.call.arguments;
+ const trigger = std.mem.eql(u8, found.call.callee, "on");
+ switch (change) {
+ .field => |set| {
+ if (tableOf(tree, found.call)) |at| {
+ const value = set.value orelse return source_edit.removeField(tree, text, at, set.name);
+ return try source_edit.setField(arena, tree, text, at, set.name, value);
+ }
+ const value = set.value orelse return null;
+ if (std.mem.eql(u8, found.call.callee, "part")) {
+ return try source_edit.appendArgument(arena, tree, text, found.node, try arena.print("{{ {s} = {s} }}", .{ set.name, value }));
+ }
+ const after: usize = if (trigger) 1 else 0;
+ if (arguments.len <= after) return null;
+ return try source_edit.addTable(arena, tree, text, arguments[after], set.name, value);
+ },
+ .display => |name| {
+ if (trigger or arguments.len == 0 or tree.get(arguments[0]) != .string) return null;
+ return source_edit.replace(tree, text, arguments[0], try source_edit.string(arena, name));
+ },
+ .subject => |subject| {
+ if (!trigger or arguments.len == 0) return null;
+ return source_edit.replace(tree, text, arguments[0], subject);
+ },
+ .condition => |condition| {
+ if (!trigger or arguments.len < 2) return null;
+ return source_edit.replace(tree, text, arguments[1], try source_edit.string(arena, condition));
+ },
+ .value => |value| {
+ if (!std.mem.eql(u8, found.call.callee, "global") or arguments.len < 2) return null;
+ return source_edit.replace(tree, text, arguments[1], value);
+ },
+ }
+}
+
+/// The call that declares a record on `line`: `name = word(...)` or `on(...)`, with its node.
+fn declarationAt(tree: syntax.Tree, line: u32) ?struct { node: syntax.Index, call: Call } {
+ const statements = switch (tree.get(tree.root)) {
+ .block => |list| list,
+ else => return null,
+ };
+ for (statements) |statement| {
+ if (tree.at(statement).line != line) continue;
+ const node = switch (tree.get(statement)) {
+ .assign => |assign| if (assign.values.len == 1) assign.values[0] else return null,
+ .call_statement => |call| call,
+ else => return null,
+ };
+ return .{ .node = node, .call = callOf(tree, node) orelse return null };
+ }
+ return null;
+}
+
+/// The fields of a record's table, from OpenReliant's tables of the language.
+fn fieldForms(record: game.Record) []const game.FieldForm {
+ const word = std.meta.stringToEnum(source.names.Word, record.word) orelse return &.{};
+ return switch (word) {
+ .flight_group => &forms.flight_group,
+ .ship => &forms.ship,
+ .nav_point, .waypoint, .curve_point => &forms.marker,
+ .point => &forms.point,
+ .curve => &forms.curve,
+ .part => &forms.part,
+ .on => &forms.trigger,
+ else => &.{},
+ };
+}
+
+const forms = struct {
+ const flight_group = formsOf(source.language.flight_group_fields);
+ const ship = formsOf(source.language.ship_fields);
+ const marker = formsOf(source.language.marker_fields);
+ const point = formsOf(source.language.point_fields);
+ const curve = formsOf(source.language.curve_fields);
+ const part = formsOf(source.language.part_fields);
+ const trigger = formsOf(source.language.trigger_fields);
+};
+
+fn formsOf(comptime table: source.language.Table) [table.fields.len]game.FieldForm {
+ var made: [table.fields.len]game.FieldForm = undefined;
+ for (&made, table.fields) |*form, from| {
+ form.* = .{ .name = from.name, .about = from.about, .kind = fieldKind(from.type), .optional = std.mem.endsWith(u8, from.type, "?") };
+ for (hints) |hint| if (std.mem.eql(u8, hint.name, from.name)) {
+ form.label = hint.label;
+ form.heading = hint.heading;
+ form.row = hint.row;
+ form.letter = hint.letter;
+ form.note = hint.note;
+ };
+ }
+ return made;
+}
+
+/// How the inspector lays out a ship's and a marker's place: under a heading, with its angles on
+/// one row, and a short label for its place in a formation.
+const hints = [_]struct { name: []const u8, label: ?[]const u8 = null, heading: ?[]const u8 = null, row: ?[]const u8 = null, letter: []const u8 = "", note: ?[]const u8 = null }{
+ .{ .name = "formation_point", .label = "Formation" },
+ .{ .name = "position", .heading = "Place" },
+ .{ .name = "yaw", .row = "Angles", .letter = "Y" },
+ .{ .name = "pitch", .row = "Angles", .letter = "P" },
+ .{ .name = "roll", .row = "Angles", .letter = "R", .note = "degrees · Y down" },
+};
+
+/// The kind of value a type of mission source takes, as the language's tables write it: a record,
+/// a name of the game's values, a wing, a vector, a number, a boolean, or source for the rest.
+fn fieldKind(type_name: []const u8) game.FieldKind {
+ const bare = std.mem.trimEnd(u8, type_name, "?");
+ for (record_types) |entry| if (std.mem.eql(u8, bare, entry[0])) return .{ .record = entry[1] };
+ if (std.mem.eql(u8, bare, "Vector")) return .vector;
+ if (std.mem.eql(u8, bare, "number")) return .number;
+ if (std.mem.eql(u8, bare, "boolean")) return .boolean;
+ if (std.mem.eql(u8, bare, "Wing")) return .{ .strings = &wings };
+ inline for (comptime std.enums.values(source.vocabulary.Kind)) |kind| {
+ if (std.mem.eql(u8, bare, comptime kind.typeName())) return .{ .value = comptime kind.typeName() };
+ }
+ return .source;
+}
+
+/// The language's types of the mission's records, and the outline's sections that hold them.
+const record_types = [_]struct { []const u8, []const game.Section }{
+ .{ "FlightGroup", &.{.flight_groups} },
+ .{ source.language.mission_ship, &.{ .ships, .markers } },
+ .{ "Entity", &.{ .ships, .markers, .flight_groups, .squads } },
+ .{ "Squad", &.{.squads} },
+ .{ "Formation", &.{.formations} },
+ .{ "Curve", &.{.curves} },
+ .{ "Part", &.{.parts} },
+};
+
+/// A flight group's wings, as mission source names them.
+const wings = names: {
+ const all = @typeInfo(dte.FlightGroup.Wing).@"enum".field_names;
+ var list: [all.len][]const u8 = undefined;
+ for (&list, all) |*name, from| name.* = from;
+ const final = list;
+ break :names final;
+};
+
+/// The records the outline's + adds. Squads, formations and curves list other records, and are
+/// written in the source.
+const new_kinds = [_]game.NewKind{
+ .{ .word = "ship", .label = "Ship", .section = .ships },
+ .{ .word = "nav_point", .label = "Nav point", .section = .markers },
+ .{ .word = "waypoint", .label = "Waypoint", .section = .markers },
+ .{ .word = "flight_group", .label = "Flight group", .section = .flight_groups },
+ .{ .word = "part", .label = "Part", .section = .parts },
+ .{ .word = "on", .label = "Trigger", .section = .triggers, .named = false },
+ .{ .word = "global", .label = "Global", .section = .globals },
+};
+
+/// The condition a new trigger starts with: every ship has it. It watches nothing until a subject
+/// is chosen.
+const new_condition = "destroyed";
+
+/// Declares a record of `kind` after the last record of its section, or of the sections before
+/// it, or at the end of the file: `ship_1 = ship("Ship 1", { type = "predator" })` with the
+/// game's first ship type, a part and a trigger with code that does nothing yet, and StarLancer's
+/// tags on the display names of flight groups, parts and globals.
+fn addRecord(arena: Allocator, text: []const u8, known: game.Outline, kind: game.NewKind, given: ?[]const u8) Allocator.Error!?game.Added {
+ const stem = if (std.mem.eql(u8, kind.word, "nav_point")) "nav" else if (std.mem.eql(u8, kind.word, "flight_group")) "group" else kind.word;
+ var number: usize = 1;
+ const name = given orelse while (true) : (number += 1) {
+ const candidate = try arena.print("{s}_{d}", .{ stem, number });
+ if (known.named(candidate) == null) break candidate;
+ };
+ if (given != null and known.named(name) != null) return null;
+ const shown = if (given != null) name else try arena.print("{s} {d}", .{ kind.label, number });
+ const declaration = switch (std.meta.stringToEnum(source.names.Word, kind.word) orelse return null) {
+ .ship => try arena.print("{s} = ship({s}, {{ type = \"{s}\" }})", .{ name, try source_edit.string(arena, shown), source.vocabulary.name(.ship_type, 0) orelse "predator" }),
+ .flight_group => try arena.print("{s} = flight_group({s})", .{ name, try source_edit.string(arena, try arena.print("(FG){s}", .{shown})) }),
+ .part => try arena.print("{s} = part({s}, function()\n return 1\nend)", .{ name, try source_edit.string(arena, try arena.print("(F){s}", .{shown})) }),
+ .on => "on(nil, \"" ++ new_condition ++ "\", function()\n return 1\nend)",
+ .global => try arena.print("{s} = global({s}, 0)", .{ name, try source_edit.string(arena, try arena.print("(GV){s}", .{name})) }),
+ else => try arena.print("{s} = {s}({s})", .{ name, kind.word, try source_edit.string(arena, shown) }),
+ };
+ // After the last record of its section, or of the nearest section before it.
+ var anchor: ?game.Record = null;
+ for (std.enums.values(game.Section)) |section| {
+ for (known.records) |record| if (record.section == section) {
+ anchor = record;
+ };
+ if (section == kind.section) break;
+ }
+ if (anchor) |after| {
+ const start = source_edit.offset(text, .{ .line = after.last_line, .column = 0 });
+ const end = std.mem.findScalarPos(u8, text, start, '\n') orelse text.len;
+ return .{ .change = .{ .start = end, .end = end, .text = try arena.print("\n{s}", .{declaration}) }, .line = after.last_line + 1 };
+ }
+ const lines: u32 = @intCast(std.mem.count(u8, text, "\n"));
+ if (text.len == 0 or text[text.len - 1] == '\n') return .{ .change = .{ .start = text.len, .end = text.len, .text = try arena.print("{s}\n", .{declaration}) }, .line = lines };
+ return .{ .change = .{ .start = text.len, .end = text.len, .text = try arena.print("\n{s}\n", .{declaration}) }, .line = lines + 1 };
+}
+
/// A list of up to four numbers as the source writes it, such as a place, `{ 0, 0, 40000 }`; "…"
/// for any other table.
fn vectorText(arena: Allocator, tree: syntax.Tree, items: []const syntax.Index) Allocator.Error![]const u8 {
@@ -747,6 +965,80 @@ pub const first_mission =
\\
;
+test edit {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const made = try outline(arena, first_mission);
+ const Case = struct { record: []const u8, edit: game.Edit, from: []const u8, to: []const u8 };
+ const cases = [_]Case{
+ // A field replaced, in a table of a line to an item.
+ .{ .record = "sabre_1", .edit = .{ .field = .{ .name = "position", .value = "{ -1500, 0, 41000 }" } }, .from = " position = { -2000, 0, 42000 },\n yaw = 180,\n})\nsabre_2", .to = " position = { -1500, 0, 41000 },\n yaw = 180,\n})\nsabre_2" },
+ // Added to a table on one line.
+ .{ .record = "player", .edit = .{ .field = .{ .name = "pilot", .value = "\"bandit_tigers_leader\"" } }, .from = "{ type = \"predator\", group = alpha }", .to = "{ type = \"predator\", group = alpha, pilot = \"bandit_tigers_leader\" }" },
+ // A table where there was none.
+ .{ .record = "navs", .edit = .{ .field = .{ .name = "wing", .value = "\"second\"" } }, .from = "flight_group(\"(FG)Nav points\")", .to = "flight_group(\"(FG)Nav points\", { wing = \"second\" })" },
+ // The display name.
+ .{ .record = "nav", .edit = .{ .display = "Nav \"A\"" }, .from = "nav_point(\"Nav 1\",", .to = "nav_point(\"Nav \\\"A\\\"\"," },
+ // A part's field taken out.
+ .{ .record = "start", .edit = .{ .field = .{ .name = "start", .value = null } }, .from = "end, { start = true })", .to = "end, {})" },
+ };
+ for (cases) |case| {
+ const record = made.named(case.record).?;
+ const change = (try edit(arena, first_mission, record.*, case.edit)).?;
+ const changed = try source_edit.testing.applied(arena, first_mission, change);
+ try std.testing.expectEqualStrings(try std.mem.replaceOwned(u8, arena, first_mission, case.from, case.to), changed);
+ // The mission still builds.
+ try std.testing.expect(try build(arena, changed) == .mission);
+ }
+ // A trigger's condition, and a table for the one that has none, before its code.
+ const destroyed = made.records[made.records.len - 1];
+ const repeats = (try edit(arena, first_mission, destroyed, .{ .field = .{ .name = "repeats", .value = "\"always\"" } })).?;
+ try std.testing.expect(std.mem.find(u8, try source_edit.testing.applied(arena, first_mission, repeats), "on(enemies, \"destroyed\", { repeats = \"always\" }, function(killer, victim)") != null);
+ const reached = made.records[made.records.len - 2];
+ const condition = (try edit(arena, first_mission, reached, .{ .condition = "destroyed" })).?;
+ try std.testing.expect(std.mem.find(u8, try source_edit.testing.applied(arena, first_mission, condition), "on(nav, \"destroyed\", {") != null);
+ // A field taken out that isn't there changes nothing.
+ try std.testing.expectEqual(null, try edit(arena, first_mission, made.named("navs").?.*, .{ .field = .{ .name = "wing", .value = null } }));
+}
+
+test addRecord {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const made = try outline(arena, first_mission);
+ // A ship goes after the last ship, under the first free name, and the mission still builds.
+ const ship = (try addRecord(arena, first_mission, made, new_kinds[0], null)).?;
+ const with_ship = try source_edit.testing.applied(arena, first_mission, ship.change);
+ try std.testing.expect(std.mem.find(u8, with_ship, " yaw = 180,\n})\nship_1 = ship(\"Ship 1\", { type = \"predator\" })\n") != null);
+ const again = try outline(arena, with_ship);
+ try std.testing.expectEqualStrings("ship_1", again.find(.ships, ship.line).?.name);
+ try std.testing.expect(try build(arena, with_ship) == .mission);
+ // A name the source uses without declaring it, as a global; a name taken is refused.
+ const global = (try addRecord(arena, first_mission, made, new_kinds[6], "convoykilled")).?;
+ try std.testing.expect(std.mem.find(u8, try source_edit.testing.applied(arena, first_mission, global.change), "convoykilled = global(\"(GV)convoykilled\", 0)") != null);
+ try std.testing.expectEqual(null, try addRecord(arena, first_mission, made, new_kinds[0], "player"));
+ // Each kind builds.
+ for (new_kinds) |kind| {
+ const added = (try addRecord(arena, first_mission, made, kind, null)).?;
+ try std.testing.expect(try build(arena, try source_edit.testing.applied(arena, first_mission, added.change)) == .mission);
+ }
+ try std.testing.expect(source.vocabulary.value(.condition, new_condition) != null);
+}
+
+test fieldForms {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const made = try outline(arena.allocator(), first_mission);
+ const ship_forms = fieldForms(made.named("sabre_1").?.*);
+ try std.testing.expectEqualStrings("type", ship_forms[0].name);
+ try std.testing.expectEqualStrings("ShipType", ship_forms[0].kind.value);
+ try std.testing.expectEqual(game.Section.flight_groups, ship_forms[1].kind.record[0]);
+ try std.testing.expect(fieldKind("Vector?") == .vector);
+ try std.testing.expectEqualStrings("player", fieldKind("Wing?").strings[0]);
+ try std.testing.expect(fieldKind("Launch?") == .source);
+}
+
test "the first mission's outline" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
diff --git a/src/main.zig b/src/main.zig
index d965629..468b0b5 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -19,6 +19,8 @@ pub const settings = @import("settings.zig");
pub const shell = @import("shell.zig");
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 script_view = @import("views/script.zig");
pub const start_screen = @import("screens/start.zig");
pub const settings_screen = @import("screens/settings.zig");
diff --git a/src/mission/document.zig b/src/mission/document.zig
index d5c31f5..720461a 100644
--- a/src/mission/document.zig
+++ b/src/mission/document.zig
@@ -73,6 +73,11 @@ pub const Document = struct {
listing_fresh: bool = false,
/// The line where the game holds the script, counting from 0, which the text tints.
held_line: ?u32 = null,
+ /// The change to the text that the interface asked for during a frame, made once the frame is
+ /// drawn (`flush`): making it checks the text again, which replaces the outline the frame drew
+ /// from. Its strings are in `queue_arena`.
+ queued: ?Queued = null,
+ queue_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 {
@@ -98,6 +103,7 @@ pub const Document = struct {
.check = .{ .arena = .init(gpa) },
.good = .{ .arena = .init(gpa) },
.listing_arena = .init(gpa),
+ .queue_arena = .init(gpa),
};
document.recheck() catch {};
return document;
@@ -108,6 +114,7 @@ pub const Document = struct {
document.check.deinit();
document.good.deinit();
document.listing_arena.deinit();
+ document.queue_arena.deinit();
document.gpa.free(document.path);
document.gpa.destroy(document);
}
@@ -188,6 +195,83 @@ pub const Document = struct {
document.mark();
}
+ /// A change to the text asked for during a frame.
+ pub const Queued = union(enum) {
+ /// An edit to the record at `index` among `section`'s.
+ edit: struct { section: game.Section, index: usize, edit: game.Edit },
+ /// A new record of `kind`, under `name` where one is given.
+ add: struct { kind: game.NewKind, name: ?[]const u8 },
+ /// Text in place of a span of the editor's text.
+ replace: struct { start: text_editor.Pos, end: text_editor.Pos, text: []const u8 },
+ };
+
+ /// What a queued change came to.
+ pub const Made = union(enum) {
+ none,
+ /// Whether an edit was made.
+ edited: bool,
+ /// The new record's section, and the line it starts on; null where it wasn't added.
+ added: struct { section: game.Section, line: ?u32 },
+ };
+
+ /// Asks for `change` to be made once the frame is drawn (`flush`), in place of any asked for
+ /// earlier in the frame. Its strings are copied.
+ pub fn queue(document: *Document, change: Queued) void {
+ _ = document.queue_arena.reset(.retain_capacity);
+ const arena = document.queue_arena.allocator();
+ document.queued = copyQueued(arena, change) catch null;
+ }
+
+ /// Makes the change asked for during the frame, if one was.
+ pub fn flush(document: *Document) Allocator.Error!Made {
+ const queued = document.queued orelse return .none;
+ document.queued = null;
+ switch (queued) {
+ .edit => |asked| return .{ .edited = try document.editRecord(asked.section, asked.index, asked.edit) },
+ .add => |asked| return .{ .added = .{ .section = asked.kind.section, .line = try document.addRecord(asked.kind, asked.name) } },
+ .replace => |asked| {
+ document.editor.replace(asked.start, asked.end, asked.text);
+ try document.recheck();
+ return .{ .edited = true };
+ },
+ }
+ }
+
+ /// Makes `edit` to the record at `index` among `section`'s, through the game's profile, as
+ /// one step of the editor's undo, and checks the text again at once, so that the outline and
+ /// the inspector show the change. Text typed since the last check is checked first. False
+ /// where the source can't take the edit: it doesn't parse, or the declaration has no place for
+ /// it.
+ fn editRecord(document: *Document, section: game.Section, index: usize, edit: game.Edit) Allocator.Error!bool {
+ if (document.checkPending()) try document.recheck();
+ if (document.outline_stale) return false;
+ const current = document.outline().nth(section, index) orelse return false;
+ var arena_state: std.heap.ArenaAllocator = .init(document.gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const text = try arena.dupe(u8, document.editor.text());
+ const change = try document.profile.edit(arena, text, current.*, edit) orelse return false;
+ document.editor.replace(posAt(text, change.start), posAt(text, change.end), change.text);
+ try document.recheck();
+ return true;
+ }
+
+ /// Declares a new record of `kind` through the game's profile, as one step of the editor's
+ /// undo, under `name` where one is given. The line its declaration starts on; null where the
+ /// source doesn't parse, or `name` is taken.
+ fn addRecord(document: *Document, kind: game.NewKind, name: ?[]const u8) Allocator.Error!?u32 {
+ if (document.checkPending()) try document.recheck();
+ if (document.outline_stale) return null;
+ var arena_state: std.heap.ArenaAllocator = .init(document.gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const text = try arena.dupe(u8, document.editor.text());
+ const added = try document.profile.addRecord(arena, text, document.outline(), kind, name) orelse return null;
+ document.editor.replace(posAt(text, added.change.start), posAt(text, added.change.end), added.change.text);
+ try document.recheck();
+ return added.line;
+ }
+
/// Tints the line where the game holds the script, or none.
pub fn setHeldLine(document: *Document, line: ?u32) void {
if (document.held_line == line) return;
@@ -277,11 +361,41 @@ pub const Document = struct {
}
};
+/// A copy of `change` in `arena`, with its strings.
+fn copyQueued(arena: Allocator, change: Document.Queued) Allocator.Error!Document.Queued {
+ return switch (change) {
+ .edit => |asked| .{ .edit = .{ .section = asked.section, .index = asked.index, .edit = switch (asked.edit) {
+ .field => |set| .{ .field = .{ .name = try arena.dupe(u8, set.name), .value = if (set.value) |value| try arena.dupe(u8, value) else null } },
+ .display => |text| .{ .display = try arena.dupe(u8, text) },
+ .subject => |text| .{ .subject = try arena.dupe(u8, text) },
+ .condition => |text| .{ .condition = try arena.dupe(u8, text) },
+ .value => |text| .{ .value = try arena.dupe(u8, text) },
+ } } },
+ .add => |asked| .{ .add = .{ .kind = asked.kind, .name = if (asked.name) |name| try arena.dupe(u8, name) else null } },
+ .replace => |asked| .{ .replace = .{ .start = asked.start, .end = asked.end, .text = try arena.dupe(u8, asked.text) } },
+ };
+}
+
+/// The text editor's place of byte `at` of `text`: its line, and its character on the line.
+fn posAt(text: []const u8, at: usize) text_editor.Pos {
+ const start = if (std.mem.findScalarLast(u8, text[0..at], '\n')) |newline| newline + 1 else 0;
+ const line = std.mem.count(u8, text[0..start], "\n");
+ const index = std.unicode.utf8CountCodepoints(text[start..at]) catch at - start;
+ return .{ .line = line, .index = index };
+}
+
+test posAt {
+ // `é` takes two bytes and is one character.
+ try std.testing.expectEqual(text_editor.Pos{ .line = 1, .index = 3 }, posAt("a\nNé b", 6));
+ try std.testing.expectEqual(text_editor.Pos{ .line = 0, .index = 0 }, posAt("abc", 0));
+}
+
/// A copy of `outline` in `arena`, so that it outlives the check that read it.
fn copyOutline(arena: Allocator, outline: game.Outline) Allocator.Error!game.Outline {
const records = try arena.alloc(game.Record, outline.records.len);
for (records, outline.records) |*copy, record| {
copy.* = record;
+ copy.word = try arena.dupe(u8, record.word);
copy.name = try arena.dupe(u8, record.name);
copy.title = try arena.dupe(u8, record.title);
copy.display = try arena.dupe(u8, record.display);
@@ -289,7 +403,7 @@ fn copyOutline(arena: Allocator, outline: game.Outline) Allocator.Error!game.Out
copy.subject = try arena.dupe(u8, record.subject);
copy.condition = try arena.dupe(u8, record.condition);
const fields = try arena.alloc(game.Field, record.fields.len);
- for (fields, record.fields) |*field, from| field.* = .{ .name = try arena.dupe(u8, from.name), .value = try arena.dupe(u8, from.value), .names_record = from.names_record };
+ for (fields, record.fields) |*field, from| field.* = .{ .name = try arena.dupe(u8, from.name), .source = try arena.dupe(u8, from.source), .value = try arena.dupe(u8, from.value), .names_record = from.names_record };
copy.fields = fields;
}
return .{
diff --git a/src/mission/edit.zig b/src/mission/edit.zig
new file mode 100644
index 0000000..d8bd8a2
--- /dev/null
+++ b/src/mission/edit.zig
@@ -0,0 +1,436 @@
+//! Changes to mission source that the inspector's forms and the map make: a field of a record's
+//! table set, added or taken out, and an argument replaced. Each is one change to a span of the
+//! text (`game.Change`), which the document applies as a single step of the editor's undo.
+//! OpenReliant's syntax tree gives where each node starts. Where a value ends is read from the
+//! text: past its brackets, strings and comments, up to the comma or bracket that closes it.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const openreliant = @import("openreliant");
+const syntax = openreliant.dte.source.syntax;
+
+const game = @import("../game.zig");
+const Change = game.Change;
+
+/// The byte offset in `text` of a place the syntax tree gives: a line and a byte on it, from 0.
+pub fn offset(text: []const u8, at: syntax.Position) usize {
+ var line: u32 = 0;
+ var start: usize = 0;
+ while (line < at.line) : (line += 1) {
+ const end = std.mem.findScalarPos(u8, text, start, '\n') orelse return text.len;
+ start = end + 1;
+ }
+ return @min(start + at.column, text.len);
+}
+
+/// Where the value that starts at byte `start` ends: past its brackets, strings and comments, up
+/// to the comma, semicolon or closing bracket after it, and before the spaces and comments in
+/// between. It reads names, strings, numbers, tables and calls; a function's body can hold commas
+/// of its own, so its end isn't found this way.
+pub fn valueEnd(text: []const u8, start: usize) usize {
+ var depth: usize = 0;
+ var at = start;
+ var end = start;
+ while (at < text.len) {
+ switch (text[at]) {
+ ' ', '\t', '\r', '\n' => {
+ at += 1;
+ continue;
+ },
+ '-' => if (at + 1 < text.len and text[at + 1] == '-') {
+ at = commentEnd(text, at);
+ continue;
+ },
+ '"', '\'' => {
+ at = quotedEnd(text, at);
+ end = at;
+ continue;
+ },
+ '[' => if (longLevel(text, at)) |level| {
+ at = longEnd(text, at, level);
+ end = at;
+ continue;
+ } else {
+ depth += 1;
+ },
+ '(', '{' => depth += 1,
+ ')', '}', ']' => {
+ if (depth == 0) break;
+ depth -= 1;
+ },
+ ',', ';' => if (depth == 0) break,
+ else => {},
+ }
+ at += 1;
+ end = at;
+ }
+ return end;
+}
+
+/// Past the comment that starts with `--` at `at`: a long one to its closing brackets, a short
+/// one to the end of its line.
+fn commentEnd(text: []const u8, at: usize) usize {
+ const rest = at + 2;
+ if (rest < text.len and text[rest] == '[') if (longLevel(text, rest)) |level| return longEnd(text, rest, level);
+ return std.mem.findScalarPos(u8, text, rest, '\n') orelse text.len;
+}
+
+/// Past the string quoted at `at`, with its escapes; an unfinished one ends with its line.
+fn quotedEnd(text: []const u8, at: usize) usize {
+ const quote = text[at];
+ var place = at + 1;
+ while (place < text.len) : (place += 1) switch (text[place]) {
+ '\\' => place += 1,
+ '\n' => return place,
+ else => if (text[place] == quote) return place + 1,
+ };
+ return text.len;
+}
+
+/// The level of the long bracket opening at `at`, `[[` or `[==[`: the number of `=`. Null for a
+/// lone `[`.
+fn longLevel(text: []const u8, at: usize) ?usize {
+ var place = at + 1;
+ while (place < text.len and text[place] == '=') place += 1;
+ if (place < text.len and text[place] == '[') return place - at - 1;
+ return null;
+}
+
+/// Past the long string or comment whose brackets of `level` open at `at`.
+fn longEnd(text: []const u8, at: usize, level: usize) usize {
+ var place = at + level + 2;
+ while (std.mem.findScalarPos(u8, text, place, ']')) |close| {
+ const equals = close + 1 + level;
+ if (equals < text.len and text[equals] == ']' and std.mem.allEqual(u8, text[close + 1 .. equals], '=')) return equals + 1;
+ place = close + 1;
+ }
+ return text.len;
+}
+
+/// The span of the value at node `index`: where it starts, and where it ends (`valueEnd`).
+pub fn span(tree: syntax.Tree, text: []const u8, index: syntax.Index) struct { usize, usize } {
+ const start = offset(text, tree.at(index));
+ return .{ start, valueEnd(text, start) };
+}
+
+/// The change that writes `value` in place of the value at node `index`.
+pub fn replace(tree: syntax.Tree, text: []const u8, index: syntax.Index, value: []const u8) Change {
+ const start, const end = span(tree, text, index);
+ return .{ .start = start, .end = end, .text = value };
+}
+
+/// The item of the table at `table` whose key is `key`, and its value.
+pub fn fieldOf(tree: syntax.Tree, table: syntax.Index, key: []const u8) ?struct { item: syntax.Index, value: syntax.Index } {
+ for (tree.get(table).table) |item| switch (tree.get(item)) {
+ .item => |found| switch (found) {
+ .record => |record| if (std.mem.eql(u8, record.key, key)) return .{ .item = item, .value = record.value },
+ else => {},
+ },
+ else => {},
+ };
+ return null;
+}
+
+/// The change that sets `key` of the table at `table` to `value`, as the source writes it: the
+/// value replaced where the table has the key, or the key added after the table's last item, on a
+/// line of its own where the items are each on one.
+pub fn setField(arena: Allocator, tree: syntax.Tree, text: []const u8, table: syntax.Index, key: []const u8, value: []const u8) Allocator.Error!Change {
+ if (fieldOf(tree, table, key)) |found| return replace(tree, text, found.value, value);
+ const open = offset(text, tree.at(table));
+ const close = valueEnd(text, open) - 1;
+ const items = tree.get(table).table;
+ if (items.len == 0) return .{ .start = open, .end = close + 1, .text = try arena.print("{{ {s} = {s} }}", .{ key, value }) };
+ const last = items[items.len - 1];
+ const last_start = offset(text, tree.at(last));
+ const last_end = itemEnd(tree, text, last);
+ const comma = after(text, last_end, ',');
+ if (lineAt(text, last_start) == lineAt(text, open)) {
+ // On one line: `{ type = "sabre", group = enemies }`.
+ if (comma) |at| return .{ .start = at + 1, .end = at + 1, .text = try arena.print(" {s} = {s},", .{ key, value }) };
+ return .{ .start = last_end, .end = last_end, .text = try arena.print(", {s} = {s}", .{ key, value }) };
+ }
+ // An item to a line, as the last one is indented, with a comma after it as the last has one.
+ const indent = indentAt(text, last_start);
+ if (comma) |at| return .{ .start = at + 1, .end = at + 1, .text = try arena.print("\n{s}{s} = {s},", .{ indent, key, value }) };
+ return .{ .start = last_end, .end = last_end, .text = try arena.print(",\n{s}{s} = {s}", .{ indent, key, value }) };
+}
+
+/// The change that takes `key` out of the table at `table`, with its comma, and its line where it
+/// has one to itself; null where the table doesn't have it.
+pub fn removeField(tree: syntax.Tree, text: []const u8, table: syntax.Index, key: []const u8) ?Change {
+ const found = fieldOf(tree, table, key) orelse return null;
+ const items = tree.get(table).table;
+ const start = offset(text, tree.at(found.item));
+ const end = itemEnd(tree, text, found.item);
+ const comma = after(text, end, ',');
+ const through = if (comma) |at| at + 1 else end;
+ // On a line of its own: the whole line goes.
+ const line_start = lineStart(text, start);
+ const line_end = std.mem.findScalarPos(u8, text, through, '\n') orelse text.len;
+ if (blank(text[line_start..start]) and blank(text[through..line_end]) and line_end < text.len) {
+ return .{ .start = line_start, .end = line_end + 1, .text = "" };
+ }
+ if (items.len == 1) {
+ // The only item: the table is left empty.
+ const open = offset(text, tree.at(table));
+ return .{ .start = open, .end = valueEnd(text, open), .text = "{}" };
+ }
+ if (comma) |at| return .{ .start = start, .end = skipSpaces(text, at + 1), .text = "" };
+ // The last item, without a comma after it: the comma before it goes too.
+ const previous = items[std.mem.findScalar(syntax.Index, items, found.item).? - 1];
+ return .{ .start = itemEnd(tree, text, previous), .end = end, .text = "" };
+}
+
+/// The change that adds a table of `key = value` as an argument after the argument at node
+/// `argument`, which is a name, a string, a number or a table.
+pub fn addTable(arena: Allocator, tree: syntax.Tree, text: []const u8, argument: syntax.Index, key: []const u8, value: []const u8) Allocator.Error!Change {
+ _, const end = span(tree, text, argument);
+ return .{ .start = end, .end = end, .text = try arena.print(", {{ {s} = {s} }}", .{ key, value }) };
+}
+
+/// The change that adds `argument`, as the source writes it, after the last argument of the call
+/// at node `call`, whatever its arguments are: a function's too, since the call's brackets hold
+/// it.
+pub fn appendArgument(arena: Allocator, tree: syntax.Tree, text: []const u8, call: syntax.Index, argument: []const u8) Allocator.Error!Change {
+ _, const end = span(tree, text, call);
+ const close = end - 1;
+ const empty = tree.get(call).call.arguments.len == 0;
+ return .{ .start = close, .end = close, .text = if (empty) argument else try arena.print(", {s}", .{argument}) };
+}
+
+/// Where the item at node `item` ends: its value's end.
+fn itemEnd(tree: syntax.Tree, text: []const u8, item: syntax.Index) usize {
+ const value = switch (tree.get(item)) {
+ .item => |found| switch (found) {
+ .list => |value| value,
+ .record => |record| record.value,
+ .general => |general| general.value,
+ },
+ else => item,
+ };
+ _, const end = span(tree, text, value);
+ return end;
+}
+
+/// The place of `byte` after `at`, past spaces, on the same line; null where another byte comes
+/// first.
+fn after(text: []const u8, at: usize, byte: u8) ?usize {
+ var place = at;
+ while (place < text.len and (text[place] == ' ' or text[place] == '\t')) place += 1;
+ return if (place < text.len and text[place] == byte) place else null;
+}
+
+fn skipSpaces(text: []const u8, at: usize) usize {
+ var place = at;
+ while (place < text.len and (text[place] == ' ' or text[place] == '\t')) place += 1;
+ return place;
+}
+
+fn lineAt(text: []const u8, at: usize) usize {
+ return std.mem.count(u8, text[0..at], "\n");
+}
+
+fn lineStart(text: []const u8, at: usize) usize {
+ return if (std.mem.findScalarLast(u8, text[0..at], '\n')) |newline| newline + 1 else 0;
+}
+
+/// The spaces and tabs that start the line holding byte `at`.
+fn indentAt(text: []const u8, at: usize) []const u8 {
+ const start = lineStart(text, at);
+ var end = start;
+ while (end < at and (text[end] == ' ' or text[end] == '\t')) end += 1;
+ return text[start..end];
+}
+
+fn blank(text: []const u8) bool {
+ return std.mem.trim(u8, text, " \t\r").len == 0;
+}
+
+/// `value` as mission source writes a string: in double quotes, with its quotes, backslashes and
+/// control characters escaped.
+pub fn string(arena: Allocator, value: []const u8) Allocator.Error![]const u8 {
+ var written: std.Io.Writer.Allocating = .init(arena);
+ const writer = &written.writer;
+ writer.writeByte('"') catch return error.OutOfMemory;
+ for (value) |byte| switch (byte) {
+ '"' => writer.writeAll("\\\"") catch return error.OutOfMemory,
+ '\\' => writer.writeAll("\\\\") catch return error.OutOfMemory,
+ '\n' => writer.writeAll("\\n") catch return error.OutOfMemory,
+ // Three digits, so that a digit after it doesn't join the escape.
+ 0...9, 11...31, 127 => writer.print("\\{d:0>3}", .{byte}) catch return error.OutOfMemory,
+ else => writer.writeByte(byte) catch return error.OutOfMemory,
+ };
+ writer.writeByte('"') catch return error.OutOfMemory;
+ return written.written();
+}
+
+/// `value` as mission source writes a number: a whole one without a point, `42000`.
+pub fn number(arena: Allocator, value: f64) Allocator.Error![]const u8 {
+ return arena.print("{d}", .{value});
+}
+
+/// `values` as mission source writes a place or a direction: `{ -2000, 0, 42000 }`.
+pub fn vector(arena: Allocator, values: []const f64) Allocator.Error![]const u8 {
+ var written: std.Io.Writer.Allocating = .init(arena);
+ const writer = &written.writer;
+ writer.writeAll("{ ") catch return error.OutOfMemory;
+ for (values, 0..) |value, index| writer.print("{s}{d}", .{ if (index == 0) "" else ", ", value }) catch return error.OutOfMemory;
+ writer.writeAll(" }") catch return error.OutOfMemory;
+ return written.written();
+}
+
+pub const testing = struct {
+ const source_parser = @import("source_parser");
+
+ /// `text` with `change` made.
+ pub fn applied(arena: Allocator, text: []const u8, change: Change) Allocator.Error![]const u8 {
+ return std.mem.concat(arena, u8, &.{ text[0..change.start], change.text, text[change.end..] });
+ }
+
+ /// The table of the first statement's call, `name = constructor(..., { ... })`.
+ fn table(tree: syntax.Tree) syntax.Index {
+ const statement = tree.get(tree.root).block[0];
+ const call = tree.get(tree.get(statement).assign.values[0]).call;
+ for (call.arguments) |argument| if (tree.get(argument) == .table) return argument;
+ unreachable;
+ }
+
+ /// `text` with `key` of its first record set to `value`.
+ fn set(arena: Allocator, text: []const u8, key: []const u8, value: []const u8) ![]const u8 {
+ const tree = try source_parser.parse(arena, text);
+ return testing.applied(arena, text, try setField(arena, tree, text, table(tree), key, value));
+ }
+
+ /// `text` with `key` of its first record taken out.
+ fn remove(arena: Allocator, text: []const u8, key: []const u8) ![]const u8 {
+ const tree = try source_parser.parse(arena, text);
+ return testing.applied(arena, text, removeField(tree, text, table(tree), key) orelse return error.TestUnexpectedResult);
+ }
+};
+
+test valueEnd {
+ try std.testing.expectEqual(5, valueEnd("alpha, b", 0));
+ try std.testing.expectEqual(18, valueEnd("{ 1, { 2, 3 }, 4 } }", 0));
+ // Strings, with their escapes and brackets, and comments are read past.
+ try std.testing.expectEqual(9, valueEnd("\"a, \\\" }\" -- note, }\n, b", 0));
+ try std.testing.expectEqual(12, valueEnd("[==[a]]b]==], c", 0));
+ try std.testing.expectEqual(12, valueEnd("call(a, \"b\")\n, c", 0));
+ // A minus is a number's sign, and `--[[ ]]` a comment that can end before the line does.
+ try std.testing.expectEqual(5, valueEnd("-2000 --[[ x ]], 1", 0));
+}
+
+test offset {
+ try std.testing.expectEqual(7, offset("ab\ncdef\ng", .{ .line = 1, .column = 4 }));
+ try std.testing.expectEqual(0, offset("", .{ .line = 3, .column = 2 }));
+}
+
+test setField {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ // A value replaced, whatever it is.
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", { type = "sabre", position = { 1, 2, 3 } })
+ , try testing.set(arena,
+ \\s = ship("S", { type = "sabre", position = { -5, 0, 40000 } })
+ , "position", "{ 1, 2, 3 }"));
+ // A field added to a table on one line.
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", { type = "sabre", pilot = "jones" })
+ , try testing.set(arena,
+ \\s = ship("S", { type = "sabre" })
+ , "pilot", "\"jones\""));
+ // To an empty table.
+ try std.testing.expectEqualStrings(
+ \\s = nav_point("N", { group = navs })
+ , try testing.set(arena,
+ \\s = nav_point("N", {})
+ , "group", "navs"));
+ // An item to a line, with the last item's comma or without it.
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", {
+ \\ type = "sabre",
+ \\ yaw = 90,
+ \\})
+ , try testing.set(arena,
+ \\s = ship("S", {
+ \\ type = "sabre",
+ \\})
+ , "yaw", "90"));
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", {
+ \\ type = "sabre",
+ \\ yaw = 90
+ \\})
+ , try testing.set(arena,
+ \\s = ship("S", {
+ \\ type = "sabre"
+ \\})
+ , "yaw", "90"));
+}
+
+test removeField {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ // On one line, with the comma after it, or before it for the last.
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", { type = "sabre" })
+ , try testing.remove(arena,
+ \\s = ship("S", { pilot = "jones", type = "sabre" })
+ , "pilot"));
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", { type = "sabre" })
+ , try testing.remove(arena,
+ \\s = ship("S", { type = "sabre", pilot = "jones" })
+ , "pilot"));
+ // The only item leaves an empty table.
+ try std.testing.expectEqualStrings(
+ \\s = part("P", {})
+ , try testing.remove(arena,
+ \\s = part("P", { start = true })
+ , "start"));
+ // A line of its own goes whole.
+ try std.testing.expectEqualStrings(
+ \\s = ship("S", {
+ \\ type = "sabre",
+ \\})
+ \\
+ , try testing.remove(arena,
+ \\s = ship("S", {
+ \\ type = "sabre",
+ \\ yaw = 90,
+ \\})
+ \\
+ , "yaw"));
+}
+
+test appendArgument {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const text =
+ \\p = part("(F)Start", function()
+ \\ SetAI(a, "fight", b)
+ \\ return 1
+ \\end)
+ ;
+ const tree = try testing.source_parser.parse(arena, text);
+ const statement = tree.get(tree.root).block[0];
+ const made = try appendArgument(arena, tree, text, tree.get(statement).assign.values[0], "{ start = true }");
+ try std.testing.expectEqualStrings(
+ \\p = part("(F)Start", function()
+ \\ SetAI(a, "fight", b)
+ \\ return 1
+ \\end, { start = true })
+ , try testing.applied(arena, text, made));
+}
+
+test string {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ try std.testing.expectEqualStrings("\"(FG)\\\"Alpha\\\"\\\\\"", try string(arena_state.allocator(), "(FG)\"Alpha\"\\"));
+ try std.testing.expectEqualStrings("{ -2000, 0, 42000.5 }", try vector(arena_state.allocator(), &.{ -2000, 0, 42000.5 }));
+ try std.testing.expectEqualStrings("90", try number(arena_state.allocator(), 90));
+}
diff --git a/src/panels.zig b/src/panels.zig
index c8542c8..54c7477 100644
--- a/src/panels.zig
+++ b/src/panels.zig
@@ -14,6 +14,7 @@ const Panel = app_mod.Panel;
const commands = @import("commands.zig");
const game = @import("game.zig");
const Document = @import("mission/document.zig").Document;
+const forms = @import("forms.zig");
const script_view = @import("views/script.zig");
const ui = @import("ui.zig");
const fonts = ui.fonts;
@@ -47,6 +48,31 @@ pub fn draw(app: *App) void {
.live_scene => liveScene(),
}
}
+ addMenu(app);
+}
+
+/// The menu of records to add, next to the pointer, as + and Add a record open it, or Declare with
+/// the name it declares.
+fn addMenu(app: *App) void {
+ if (app.add_menu) {
+ _ = c.ImGui_OpenPopup("add record", 0);
+ app.add_menu = false;
+ }
+ c.ImGui_SetNextWindowSizeConstraints(.{ .x = px(200), .y = 0 }, .{ .x = px(320), .y = px(400) }, null, null);
+ if (!c.ImGui_BeginPopup("add record", 0)) return;
+ defer c.ImGui_EndPopup();
+ const name = app.declaring();
+ widgets.caption(if (name) |given| gui.print("Declare {s} as", .{given}) else "Add", theme.text_dim, fonts.size.small, .regular);
+ gui.space(4);
+ for (app.profile.new_kinds, 0..) |kind, index| {
+ if (name != null and !kind.named) continue;
+ gui.pushIdInt(index);
+ defer gui.popId();
+ if (widgets.choiceRow(.{ .label = kind.label, .mark = .{ .icon = .{ .icon = sectionIcon(kind.section), .color = theme.text_dim } } })) {
+ c.ImGui_CloseCurrentPopup();
+ if (app.document) |document| document.queue(.{ .add = .{ .kind = kind, .name = name } });
+ }
+ }
}
/// Pushes the style ImGui lays out and draws a dock node's tab bar in: the design's 30 pixels
@@ -160,7 +186,7 @@ fn waiting(icon: Icon, text: []const u8, plan: ?commands.Plan) void {
// The outline.
-fn sectionIcon(section: game.Section) Icon {
+pub fn sectionIcon(section: game.Section) Icon {
return switch (section) {
.flight_groups => .layers,
.ships => .navigation_2,
@@ -566,15 +592,14 @@ fn problemCard(app: *App, document: *Document, problem: game.Problem) void {
const declare_width = buttonWidth(declare_label);
x -= declare_width;
gui.setCursor(.{ .x = x, .y = fix_y });
- if (widgets.toolButton("declare", .{ .label = declare_label, .disabled = app.unavailable(.add_record) })) {}
+ if (widgets.toolButton("declare", .{ .label = declare_label, .disabled = app.unavailable(.add_record) })) app.askToAdd(name);
if (closest) |record| {
const use_label = gui.print("Use {s}", .{record.name});
x -= px(6) + buttonWidth(use_label);
gui.setCursor(.{ .x = x, .y = fix_y });
if (widgets.toolButton("use", .{ .label = use_label, .style = .primary, .weight = .semibold })) {
const start: ui.text_editor.Pos = .{ .line = problem.at.line, .index = problem.at.column };
- document.editor.replace(start, .{ .line = start.line, .index = start.index + name.len }, record.name);
- document.recheck() catch {};
+ document.queue(.{ .replace = .{ .start = start, .end = .{ .line = start.line, .index = start.index + name.len }, .text = record.name } });
}
}
}
@@ -719,7 +744,8 @@ fn inspector(app: *App) void {
};
const record = known.find(chosen.section, chosen.line) orelse return waiting(.sliders_horizontal, "Select a record in the outline or the source", null);
gui.pushVar2(c.ImGuiStyleVar_WindowPadding, .{ .x = px(12), .y = px(14) });
- _ = c.ImGui_BeginChild("inspector", .{ .x = 0, .y = 0 }, c.ImGuiChildFlags_AlwaysUseWindowPadding, 0);
+ // 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);
gui.popVar(1);
defer c.ImGui_EndChild();
const left = gui.cursor().x;
@@ -748,10 +774,11 @@ fn inspector(app: *App) void {
gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(18) });
}
- const edit_off = app.unavailable(.add_record);
+ // The forms write the source, which they can only do while it parses.
+ const off: ?widgets.Disabled = if (document.outline_stale) .{ .reason = "Fix the source's problems first" } else null;
switch (record.section) {
- .triggers => triggerFields(app, known, record.*, edit_off),
- else => recordFields(known, record.*, edit_off),
+ .triggers => forms.trigger(app, document, known, record.*, off),
+ else => forms.record(app, document, known, record.*, off),
}
// The code the declaration holds.
@@ -795,7 +822,7 @@ fn heldSlot(app: *App) ?u32 {
return null;
}
-fn capitalNoun(section: game.Section) []const u8 {
+pub fn capitalNoun(section: game.Section) []const u8 {
return switch (section) {
.flight_groups => "Flight group",
.ships => "Ship",
@@ -809,77 +836,7 @@ fn capitalNoun(section: game.Section) []const u8 {
};
}
-fn triggerFields(app: *App, known: game.Outline, record: game.Record, edit_off: ?widgets.Disabled) void {
- widgets.sectionHeading("Trigger");
- gui.space(9);
- const subject = known.named(record.subject);
- widgets.fieldLabel("Watches", false);
- _ = widgets.frame("watches", .{
- .label = if (subject) |found| found.title else record.subject,
- .detail = if (subject != null) record.subject else null,
- .mono_label = subject == null,
- .mark = if (subject) |found| .{ .icon = .{ .icon = sectionIcon(found.section), .color = groupColor(found.*) } } else .none,
- .disabled = edit_off,
- });
- widgets.fieldLabel("Condition", false);
- _ = widgets.frame("condition", .{ .label = record.condition, .mono_label = true, .disabled = edit_off });
- if (app.language.find(record.condition)) |word| {
- const left = gui.cursor().x;
- gui.setCursor(.{ .x = left + px(104), .y = gui.cursor().y });
- widgets.caption(gui.print("{s} · {s}", .{ word.source, word.about }), theme.text_dim, fonts.size.small, .regular);
- gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(3) });
- // The values the condition checks that the trigger gives.
- for (word.parameters) |parameter| {
- const given = for (record.fields) |field| {
- if (std.mem.eql(u8, field.name, parameter.name)) break field;
- } else continue;
- widgets.fieldLabel(parameter.name, true);
- const target = if (given.names_record) known.named(given.value) else null;
- _ = widgets.frame(parameter.name, .{
- .label = if (target) |found| found.title else given.value,
- .detail = if (target != null) given.value else null,
- .mono_label = target == null,
- .mark = if (target) |found| .{ .icon = .{ .icon = sectionIcon(found.section), .color = groupColor(found.*) } } else .none,
- .disabled = edit_off,
- });
- }
- }
- const repeats = fieldValue(record, "repeats") orelse "\"once\"";
- const repeat_index: usize = if (std.mem.eql(u8, repeats, "\"always\"")) 1 else if (std.mem.startsWith(u8, repeats, "{") or std.mem.eql(u8, repeats, "…")) 2 else 0;
- widgets.fieldLabel("Repeats", false);
- _ = widgets.segmented("repeats", &.{ "Once", "Always", "Counted" }, repeat_index, edit_off);
- widgets.fieldLabel("Deferred", false);
- const deferred = if (fieldValue(record, "deferred")) |value| std.mem.eql(u8, value, "true") else false;
- _ = widgets.checkbox("deferred", "Run on scheduler", deferred, edit_off);
-}
-
-fn recordFields(known: game.Outline, record: game.Record, edit_off: ?widgets.Disabled) void {
- widgets.sectionHeading(capitalNoun(record.section));
- gui.space(9);
- widgets.fieldLabel("Name", false);
- _ = widgets.frame("name", .{ .label = if (record.display.len > 0) record.display else record.title, .chevron = false, .disabled = edit_off });
- widgets.fieldLabel("Declared as", false);
- _ = widgets.frame("declared", .{ .label = record.name, .mono_label = true, .chevron = false, .disabled = edit_off });
- for (record.fields) |field| {
- widgets.fieldLabel(field.name, true);
- const target = if (field.names_record) known.named(field.value) else null;
- _ = widgets.frame(field.name, .{
- .label = if (target) |found| found.title else field.value,
- .detail = if (target != null) field.value else null,
- .mono_label = target == null,
- .mark = if (target) |found| .{ .icon = .{ .icon = sectionIcon(found.section), .color = groupColor(found.*) } } else .none,
- .chevron = target != null,
- .disabled = edit_off,
- });
- }
-}
-
-fn fieldValue(record: game.Record, name: []const u8) ?[]const u8 {
- for (record.fields) |field| if (std.mem.eql(u8, field.name, name)) return field.value;
- return null;
-}
-
-fn groupColor(record: game.Record) theme.Color {
+pub fn groupColor(record: game.Record) theme.Color {
return if (record.group) |group| theme.groups[group % theme.groups.len] else theme.text_menu;
}
diff --git a/src/screens/settings.zig b/src/screens/settings.zig
index a9169ea..76e8f87 100644
--- a/src/screens/settings.zig
+++ b/src/screens/settings.zig
@@ -38,7 +38,7 @@ pub fn draw(app: *App) void {
defer c.ImGui_End();
if (capturing == null) app.shortcuts();
shell.menuBar(app, .settings);
- if (capturing == null and c.ImGui_IsKeyPressed(c.ImGuiKey_Escape)) app.screen = if (app.document != null) .workspace else .start;
+ if (capturing == null and c.ImGui_IsKeyPressed(c.ImGuiKey_Escape)) leave(app);
const pos = c.ImGui_GetWindowPos();
const size = c.ImGui_GetWindowSize();
@@ -79,10 +79,23 @@ fn panel(id: [:0]const u8, at: gui.Vec2, size: gui.Vec2) void {
_ = c.ImGui_BeginChild(id, size, 0, 0);
}
+/// Goes back to where the settings were opened from: the mission open, or the start screen.
+fn leave(app: *App) void {
+ capturing = null;
+ app.screen = if (app.document != null) .workspace else .start;
+}
+
fn navigation(app: *App) void {
- gui.setCursor(gui.add(gui.cursor(), .{ .x = px(12), .y = px(14) }));
- widgets.caption("Settings", theme.text_strong, 16, .semibold);
- gui.space(10);
+ // The heading: the arrow back, and the title beside it.
+ const top = gui.cursor();
+ const button_at = gui.add(top, .{ .x = px(8), .y = px(9) });
+ gui.setCursor(button_at);
+ if (widgets.toolButton("back", .{ .icon = .arrow_left, .icon_only = true, .style = .ghost, .tooltip = "Back · Esc" })) leave(app);
+ const middle = button_at.y + px(widgets.tool_button_height) / 2;
+ gui.sans(.semibold, 16);
+ gui.drawText(gui.drawList(), .{ .x = button_at.x + px(widgets.tool_button_height) + px(4), .y = gui.textTopIn(middle, 16) }, theme.text_strong, "Settings");
+ gui.popFont();
+ gui.setCursor(.{ .x = top.x, .y = button_at.y + px(widgets.tool_button_height) + px(3 + 4) });
const pages = [_]struct { SettingsPage, []const u8 }{
.{ .general, "General" },
.{ .appearance, "Appearance" },
@@ -283,14 +296,9 @@ fn portField(app: *App) void {
defer gui.popFont();
gui.setCursor(.{ .x = at.x + px(7), .y = at.y });
c.ImGui_SetNextItemWidth(size.x - px(14));
- gui.pushColor(c.ImGuiCol_FrameBg, theme.fade(theme.field, 0));
- gui.pushColor(c.ImGuiCol_FrameBgHovered, theme.fade(theme.field, 0));
- gui.pushColor(c.ImGuiCol_FrameBgActive, theme.fade(theme.field, 0));
- gui.pushVar(c.ImGuiStyleVar_FrameBorderSize, 0);
- gui.pushVar2(c.ImGuiStyleVar_FramePadding, .{ .x = px(2), .y = gui.textTop(at.y + size.y / 2) - at.y });
+ widgets.pushBareInput(gui.textTop(at.y + size.y / 2) - at.y);
_ = c.ImGui_InputText("##port", &State.buffer, State.buffer.len + 1, c.ImGuiInputTextFlags_CharsDecimal);
- gui.popVar(2);
- gui.popColor(3);
+ widgets.popBareInput();
State.editing = c.ImGui_IsItemActive();
if (c.ImGui_IsItemDeactivatedAfterEdit()) {
if (std.fmt.parseInt(u16, std.mem.sliceTo(&State.buffer, 0), 10)) |port| {
diff --git a/src/ui/icons.zig b/src/ui/icons.zig
index 98129ed..efeab6f 100644
--- a/src/ui/icons.zig
+++ b/src/ui/icons.zig
@@ -8,6 +8,7 @@ const std = @import("std");
pub const Icon = enum(u21) {
archive = 0xe041,
arrow_down_to_dot = 0xe44d,
+ arrow_left = 0xe048,
arrow_right_to_line = 0xe459,
audio_waveform = 0xe55b,
blocks = 0xe4fa,
diff --git a/src/ui/theme.zig b/src/ui/theme.zig
index 27cfa7e..9f41020 100644
--- a/src/ui/theme.zig
+++ b/src/ui/theme.zig
@@ -92,6 +92,12 @@ pub const on_accent = rgb(0x170b02);
pub const accent_hover = rgb(0xf6a259);
pub const accent_key = rgb(0xc96d1c);
pub const on_accent_key = rgb(0x1d0e03);
+/// A vector's axes in the inspector: X, Y and Z, their letters, and the box of an angle's letter.
+pub const axis_x = rgb(0xf2767a);
+pub const axis_y = rgb(0x8fd18a);
+pub const axis_z = rgb(0x6ba8ff);
+pub const on_axis = rgb(0x0b0d12);
+pub const axis_off = rgb(0x3a4256);
pub const link_text = rgb(0xf6a259);
pub const selected = rgb(0x3b2412);
pub const selected_text = rgb(0xffe2c4);
diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig
index d0c0c0e..19d655d 100644
--- a/src/ui/widgets.zig
+++ b/src/ui/widgets.zig
@@ -25,6 +25,8 @@ pub const ButtonStyle = enum {
normal,
/// Orange, for the action to take: Continue, a fix to apply.
primary,
+ /// No box until the pointer is over it, as the settings' arrow back.
+ ghost,
};
pub const Corners = enum { all, left, right };
@@ -119,6 +121,12 @@ pub fn toolButton(id: []const u8, options: ToolButton) bool {
foreground = theme.on_accent;
if (hovered and options.disabled == null) background = theme.accent_hover;
},
+ .ghost => {
+ const lit = hovered and options.disabled == null;
+ background = if (lit) theme.row_hover else theme.fade(theme.row_hover, 0);
+ border = background;
+ foreground = if (lit) theme.white else theme.text_menu;
+ },
}
if (options.disabled != null) {
background = theme.button_off;
@@ -652,9 +660,14 @@ pub fn row(id: []const u8, shown: Row) RowResult {
/// The design's `.field`: a label 96 pixels wide, then whatever follows on the same line.
pub fn fieldLabel(text: []const u8, mono_label: bool) void {
+ fieldLabelWidth(text, mono_label, 96);
+}
+
+/// `fieldLabel` with a label `width` design pixels wide, such as the Scene's inspector's 84.
+pub fn fieldLabelWidth(text: []const u8, mono_label: bool, width: f32) void {
const at = gui.cursor();
const height = px(26);
- c.ImGui_Dummy(.{ .x = px(96), .y = height });
+ c.ImGui_Dummy(.{ .x = px(width), .y = height });
if (mono_label) gui.mono(.regular, fonts.size.small) else gui.sans(.regular, fonts.size.small);
gui.drawText(gui.drawList(), .{ .x = at.x, .y = gui.textTop(at.y + height / 2) }, theme.text_muted, text);
gui.popFont();
@@ -664,6 +677,8 @@ pub fn fieldLabel(text: []const u8, mono_label: bool) void {
pub const Frame = struct {
label: []const u8,
mono_label: bool = false,
+ /// Dim, for a value left out, such as `none`.
+ dim_label: bool = false,
detail: ?[]const u8 = null,
mark: RowMark = .none,
chevron: bool = true,
@@ -682,21 +697,10 @@ pub fn frame(id: []const u8, shown: Frame) bool {
gui.rect(list, at, max, theme.button, 3);
gui.outline(list, at, max, if (hovered and shown.disabled == null) theme.border_strong else theme.border, 3);
const middle = at.y + height / 2;
- var x = at.x + px(8);
- switch (shown.mark) {
- .none => {},
- .swatch => |color| {
- gui.rect(list, .{ .x = x, .y = middle - px(4.5) }, .{ .x = x + px(9), .y = middle + px(4.5) }, color, 2);
- x += px(9) + px(7);
- },
- .icon => |mark| {
- drawIcon(list, .{ .x = x, .y = middle }, mark.icon, mark.size, mark.color);
- x += px(mark.size) + px(7);
- },
- }
+ var x = drawMark(list, at.x + px(8), middle, shown.mark);
const limit = max.x - px(8) - (if (shown.chevron) px(12) + px(6) else 0);
if (shown.mono_label) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui);
- clippedText(list, .{ .x = x, .y = gui.textTop(middle) }, limit, theme.text, shown.label);
+ clippedText(list, .{ .x = x, .y = gui.textTop(middle) }, limit, if (shown.dim_label) theme.text_dim else theme.text, shown.label);
x += gui.textSize(shown.label).x + px(7);
gui.popFont();
if (shown.detail) |detail| if (x < limit) {
@@ -709,6 +713,197 @@ pub fn frame(id: []const u8, shown: Frame) bool {
return clicked;
}
+/// Draws a row's mark from `x`, centred on `middle`. Where the text after it starts.
+fn drawMark(list: *c.ImDrawList, x: f32, middle: f32, mark: RowMark) f32 {
+ switch (mark) {
+ .none => return x,
+ .swatch => |color| {
+ gui.rect(list, .{ .x = x, .y = middle - px(4.5) }, .{ .x = x + px(9), .y = middle + px(4.5) }, color, 2);
+ return x + px(9) + px(7);
+ },
+ .icon => |shown| {
+ drawIcon(list, .{ .x = x, .y = middle }, shown.icon, shown.size, shown.color);
+ return x + px(shown.size) + px(7);
+ },
+ }
+}
+
+pub const Choice = struct {
+ label: []const u8,
+ /// After the label, dimmer, such as a record's name in the source.
+ detail: ?[]const u8 = null,
+ mono: bool = false,
+ /// Dim, for the choice that leaves a value out, such as `none`.
+ dim: bool = false,
+ mark: RowMark = .none,
+};
+
+/// The most choices a list shows without a field to find one in.
+const choices_without_find = 12;
+
+/// The design's frame that opens a list under it: `shown` in the frame and `choices` in the list,
+/// with a field to find one by its label where the list is long. The choice clicked, if one is.
+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;
+ 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);
+ if (!c.ImGui_BeginPopup("choices", c.ImGuiWindowFlags_NoMove)) return null;
+ defer c.ImGui_EndPopup();
+ const Find = struct {
+ var text: [64:0]u8 = @splat(0);
+ };
+ const finding = choices.len > choices_without_find;
+ if (finding) {
+ if (c.ImGui_IsWindowAppearing()) {
+ Find.text[0] = 0;
+ c.ImGui_SetKeyboardFocusHere();
+ }
+ searchField("##find", &Find.text, "Find", gui.available().x, 26, true);
+ gui.space(4);
+ }
+ 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;
+ gui.pushIdInt(index);
+ defer gui.popId();
+ if (choiceRow(choice)) 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 {
+ const width = gui.available().x;
+ const height = px(26);
+ const at = gui.cursor();
+ const clicked = c.ImGui_InvisibleButton("choice", .{ .x = @max(width, 1), .y = height }, 0);
+ 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);
+ gui.popFont();
+ if (choice.detail) |detail| {
+ gui.mono(.regular, fonts.size.ui);
+ gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, detail);
+ gui.popFont();
+ }
+ return clicked;
+}
+
+pub const TextField = struct {
+ mono: bool = false,
+ /// Takes numbers: digits, a sign, a point and an exponent. At rest the number sits at the
+ /// right of the box, as the design's vectors do.
+ number: bool = false,
+ /// A letter in a coloured box at the start, as a vector's X, Y and Z.
+ tag: ?Tag = null,
+ /// Shown dim while the value is empty, such as `none`.
+ hint: ?[]const u8 = null,
+ /// Its width; by default the space left on the line.
+ width: ?f32 = null,
+ disabled: ?Disabled = null,
+};
+
+pub const Tag = struct {
+ letter: []const u8,
+ color: Color,
+ text: Color = theme.on_axis,
+};
+
+/// The field being typed in, and its text: one at a time.
+const Typing = struct {
+ var id: c.ImGuiID = 0;
+ var buffer: [256:0]u8 = @splat(0);
+};
+
+/// The design's field to type in, with `value` in it. The text typed, as the typing ends with
+/// Enter or by leaving the field after a change; null otherwise. Escape puts the value back.
+pub fn textField(id: []const u8, value: []const u8, options: TextField) ?[]const u8 {
+ const width = options.width orelse gui.available().x;
+ const height = px(26);
+ const at = gui.cursor();
+ const list = gui.drawList();
+ const max = gui.vec2(at.x + width, at.y + height);
+ const middle = at.y + height / 2;
+ gui.rect(list, at, max, theme.button, 3);
+ gui.outline(list, at, max, theme.border, 3);
+ var left = at.x + px(8);
+ if (options.tag) |tag| {
+ const tag_width = px(18);
+ c.ImDrawList_AddRectFilledEx(list, at, .{ .x = at.x + tag_width, .y = max.y }, tag.color, px(3), c.ImDrawFlags_RoundCornersLeft);
+ gui.sans(.semibold, 11);
+ gui.drawText(list, .{ .x = at.x + (tag_width - gui.textSize(tag.letter).x) / 2, .y = gui.textTop(middle) }, tag.text, tag.letter);
+ gui.popFont();
+ left = at.x + tag_width + px(6);
+ }
+ if (options.number) gui.mono(.regular, 12) else if (options.mono) gui.mono(.regular, fonts.size.ui) else gui.sans(.regular, fonts.size.ui);
+ defer gui.popFont();
+
+ const label = gui.print("##{s}", .{id});
+ const key = c.ImGui_GetID(label);
+ const typing = Typing.id == key;
+ var shown: [256:0]u8 = undefined;
+ const buffer: *[256:0]u8 = if (typing) &Typing.buffer else buffer: {
+ const length = @min(value.len, shown.len);
+ @memcpy(shown[0..length], value[0..length]);
+ shown[length] = 0;
+ break :buffer &shown;
+ };
+ // At rest a number is drawn here, at the right, which the input can't do: its own text is
+ // hidden until it's typed in. A long one takes the padding before it's cut.
+ const right_aligned = options.number and !typing;
+ if (right_aligned) {
+ const text_width = gui.textSize(value).x;
+ const first = if (options.tag != null) left - px(4) else left;
+ clippedText(list, .{ .x = @max(first, max.x - px(6) - text_width), .y = gui.textTop(middle) }, max.x - px(2), theme.text, value);
+ }
+ if (!typing and value.len == 0) if (options.hint) |hint| gui.drawText(list, .{ .x = left, .y = gui.textTop(middle) }, theme.text_dim, hint);
+ gui.setCursor(.{ .x = left - px(2), .y = at.y });
+ c.ImGui_SetNextItemWidth(max.x - left - px(4));
+ pushBareInput(gui.textTop(middle) - at.y);
+ if (right_aligned) gui.pushColor(c.ImGuiCol_Text, theme.fade(theme.text, 0));
+ if (options.disabled != null) c.ImGui_BeginDisabled(true);
+ _ = c.ImGui_InputText(label, buffer, buffer.len + 1, if (options.number) c.ImGuiInputTextFlags_CharsScientific else 0);
+ if (options.disabled != null) c.ImGui_EndDisabled();
+ if (right_aligned) gui.popColor(1);
+ popBareInput();
+ if (options.disabled) |off| gui.tooltip(off.reason);
+ if (c.ImGui_IsItemActivated()) {
+ Typing.id = key;
+ Typing.buffer = buffer.*;
+ }
+ const typed: ?[]const u8 = if (c.ImGui_IsItemDeactivatedAfterEdit()) std.mem.sliceTo(&Typing.buffer, 0) else null;
+ if (c.ImGui_IsItemDeactivated()) Typing.id = 0;
+ gui.setCursor(at);
+ c.ImGui_Dummy(.{ .x = width, .y = height });
+ return typed;
+}
+
+/// Leaves an ImGui text input bare inside a box the caller draws: no frame and no line around it,
+/// with its text `padding_y` from its top. `popBareInput` ends it.
+pub fn pushBareInput(padding_y: f32) void {
+ gui.pushColor(c.ImGuiCol_FrameBg, theme.fade(theme.field, 0));
+ gui.pushColor(c.ImGuiCol_FrameBgHovered, theme.fade(theme.field, 0));
+ gui.pushColor(c.ImGuiCol_FrameBgActive, theme.fade(theme.field, 0));
+ gui.pushColor(c.ImGuiCol_NavCursor, theme.fade(theme.field, 0));
+ gui.pushVar(c.ImGuiStyleVar_FrameBorderSize, 0);
+ gui.pushVar2(c.ImGuiStyleVar_FramePadding, .{ .x = px(2), .y = padding_y });
+}
+
+pub fn popBareInput() void {
+ gui.popVar(2);
+ gui.popColor(4);
+}
+
/// 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);
@@ -840,16 +1035,11 @@ pub fn searchField(id: [:0]const u8, buffer: [:0]u8, hint: [:0]const u8, width:
const padding_y = gui.textTop(at.y + px(height) / 2) - at.y;
gui.setCursor(.{ .x = x, .y = at.y });
c.ImGui_SetNextItemWidth(max.x - x - px(6));
- gui.pushColor(c.ImGuiCol_FrameBg, theme.fade(theme.field, 0));
- gui.pushColor(c.ImGuiCol_FrameBgHovered, theme.fade(theme.field, 0));
- gui.pushColor(c.ImGuiCol_FrameBgActive, theme.fade(theme.field, 0));
+ pushBareInput(padding_y);
gui.pushColor(c.ImGuiCol_TextDisabled, theme.text_dim);
- gui.pushColor(c.ImGuiCol_NavCursor, theme.fade(theme.field, 0));
- gui.pushVar(c.ImGuiStyleVar_FrameBorderSize, 0);
- gui.pushVar2(c.ImGuiStyleVar_FramePadding, .{ .x = px(2), .y = padding_y });
_ = c.ImGui_InputTextWithHint(id, hint, buffer.ptr, buffer.len + 1, 0);
- gui.popVar(2);
- gui.popColor(5);
+ gui.popColor(1);
+ popBareInput();
gui.setCursor(at);
c.ImGui_Dummy(.{ .x = width, .y = px(height) });
}