feat: add actor behavior editor to world
Add actor-local temporary behavior editing, reload-from-source, and confirmed updates to the shared source while guarding unsaved drafts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
2f9d500ca3
commit
c697010765
|
|
@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
|
|||
source commits, and explicit image-event collection.
|
||||
- Add committed source history browsing, revision preview, and confirmed
|
||||
rollback to the graphical source editor.
|
||||
- Add actor-local temporary behavior editing and guarded shared-source updates
|
||||
to the graphical actor inspector.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ execution-stack inspection, and retained logs. Its source view supports
|
|||
editing image sources with optimistic conflict checks, compilation diagnostics,
|
||||
explicit history commits, and revision preview/rollback. Rollback requires
|
||||
confirmation and creates a new current source version without deleting history.
|
||||
The actor inspector can also load and edit an actor-local temporary behavior,
|
||||
save it with source-identity checks, reload the behavior from the image source,
|
||||
or explicitly confirm updating the shared source from the temporary version.
|
||||
The events view has a manual collection action:
|
||||
collecting events consumes the image's shared event queue, so other clients do
|
||||
not receive events collected by the world client. Rune frame locals and
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ enum WorkerUpdate {
|
|||
submitted_source: String,
|
||||
},
|
||||
SourceDiagnostics(Vec<SourceDiagnostic>),
|
||||
ActorBehavior {
|
||||
actor_ref: String,
|
||||
path: String,
|
||||
source_id: String,
|
||||
source: String,
|
||||
submitted_source: Option<String>,
|
||||
},
|
||||
SourceHistory {
|
||||
path: String,
|
||||
history: Vec<SourceCommitDto>,
|
||||
|
|
@ -74,6 +81,7 @@ enum WorldView {
|
|||
struct WorkerSnapshot {
|
||||
actors: Vec<ActorSnapshotDto>,
|
||||
sources: Vec<String>,
|
||||
source_ids: BTreeMap<String, String>,
|
||||
actor: Option<ActorSnapshotDto>,
|
||||
execution: Option<ExecutionInspectionDto>,
|
||||
logs: Vec<LogEntry>,
|
||||
|
|
@ -87,6 +95,7 @@ struct WorldApp {
|
|||
notice: String,
|
||||
actors: Vec<ActorSnapshotDto>,
|
||||
sources: Vec<String>,
|
||||
source_ids: BTreeMap<String, String>,
|
||||
selected_actor: Option<String>,
|
||||
actor: Option<ActorSnapshotDto>,
|
||||
execution: Option<ExecutionInspectionDto>,
|
||||
|
|
@ -110,6 +119,10 @@ struct WorldApp {
|
|||
revision_preview: Option<(String, String)>,
|
||||
rollback_confirmation: Option<(String, String)>,
|
||||
events: Vec<EventDto>,
|
||||
actor_behavior: Option<SourceFileDto>,
|
||||
actor_behavior_draft: String,
|
||||
actor_behavior_dirty: bool,
|
||||
update_behavior_confirmation: bool,
|
||||
}
|
||||
|
||||
impl WorldApp {
|
||||
|
|
@ -129,6 +142,7 @@ impl WorldApp {
|
|||
notice: String::new(),
|
||||
actors: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
source_ids: BTreeMap::new(),
|
||||
selected_actor: None,
|
||||
actor: None,
|
||||
execution: None,
|
||||
|
|
@ -152,6 +166,10 @@ impl WorldApp {
|
|||
revision_preview: None,
|
||||
rollback_confirmation: None,
|
||||
events: Vec::new(),
|
||||
actor_behavior: None,
|
||||
actor_behavior_draft: String::new(),
|
||||
actor_behavior_dirty: false,
|
||||
update_behavior_confirmation: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,11 +181,20 @@ impl WorldApp {
|
|||
|
||||
fn choose_actor(&mut self, actor_ref: Option<String>) {
|
||||
if self.selected_actor != actor_ref {
|
||||
if self.actor_behavior_dirty {
|
||||
self.notice =
|
||||
"Save or discard the current actor behavior draft before changing actors"
|
||||
.to_owned();
|
||||
return;
|
||||
}
|
||||
self.selected_actor = actor_ref.clone();
|
||||
self.actor = None;
|
||||
self.execution = None;
|
||||
self.logs.clear();
|
||||
self.breakpoints.clear();
|
||||
self.actor_behavior = None;
|
||||
self.actor_behavior_draft.clear();
|
||||
self.actor_behavior_dirty = false;
|
||||
let _ = self.requests.send(WorkerRequest::Select(actor_ref));
|
||||
}
|
||||
}
|
||||
|
|
@ -202,6 +229,7 @@ impl WorldApp {
|
|||
self.choose_actor(None);
|
||||
self.actors.clear();
|
||||
self.sources.clear();
|
||||
self.source_ids.clear();
|
||||
self.actor = None;
|
||||
self.execution = None;
|
||||
self.logs.clear();
|
||||
|
|
@ -213,6 +241,9 @@ impl WorldApp {
|
|||
self.selected_revision = None;
|
||||
self.revision_preview = None;
|
||||
self.events.clear();
|
||||
self.actor_behavior = None;
|
||||
self.actor_behavior_draft.clear();
|
||||
self.actor_behavior_dirty = false;
|
||||
}
|
||||
Ok(WorkerUpdate::SourceLoaded(source)) => {
|
||||
if self.loading_source.as_deref() == Some(source.path.as_str()) {
|
||||
|
|
@ -239,6 +270,28 @@ impl WorldApp {
|
|||
Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => {
|
||||
self.source_diagnostics = diagnostics;
|
||||
}
|
||||
Ok(WorkerUpdate::ActorBehavior {
|
||||
actor_ref,
|
||||
path,
|
||||
source_id,
|
||||
source,
|
||||
submitted_source,
|
||||
}) => {
|
||||
if self.selected_actor.as_deref() == Some(&actor_ref) {
|
||||
if submitted_source
|
||||
.as_ref()
|
||||
.is_none_or(|submitted| self.actor_behavior_draft == *submitted)
|
||||
{
|
||||
self.actor_behavior_draft.clone_from(&source);
|
||||
}
|
||||
self.actor_behavior_dirty = self.actor_behavior_draft != source;
|
||||
self.actor_behavior = Some(SourceFileDto {
|
||||
path,
|
||||
source_id,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(WorkerUpdate::SourceHistory { path, history }) => {
|
||||
if self.source_path() != Some(path.as_str()) {
|
||||
continue;
|
||||
|
|
@ -272,8 +325,20 @@ impl WorldApp {
|
|||
{
|
||||
self.choose_actor(Some(first.actor_ref.clone()));
|
||||
}
|
||||
if let (Some(previous), Some(current)) = (&self.actor, &snapshot.actor)
|
||||
&& previous.actor_ref == current.actor_ref
|
||||
&& previous.temporary_behavior
|
||||
&& !current.temporary_behavior
|
||||
{
|
||||
let _ =
|
||||
self.requests
|
||||
.send(WorkerRequest::Command(Command::ActorBehavior {
|
||||
actor_ref: current.actor_ref.clone(),
|
||||
}));
|
||||
}
|
||||
self.actors = snapshot.actors;
|
||||
self.sources = snapshot.sources;
|
||||
self.source_ids = snapshot.source_ids;
|
||||
self.actor = snapshot.actor;
|
||||
self.execution = snapshot.execution;
|
||||
self.logs = snapshot.logs;
|
||||
|
|
@ -306,9 +371,10 @@ impl WorldApp {
|
|||
ui.label("Image");
|
||||
ui.text_edit_singleline(&mut self.endpoint);
|
||||
if ui.button("Connect").clicked() {
|
||||
if self.source_dirty {
|
||||
if self.source_dirty || self.actor_behavior_dirty {
|
||||
self.notice =
|
||||
"Save or discard the current source draft before reconnecting".to_owned();
|
||||
"Save or discard the current source and behavior drafts before reconnecting"
|
||||
.to_owned();
|
||||
} else {
|
||||
match self.endpoint.parse() {
|
||||
Ok(address) => {
|
||||
|
|
@ -479,11 +545,136 @@ impl WorldApp {
|
|||
}
|
||||
});
|
||||
self.draw_message_sender(ui, &actor.actor_ref);
|
||||
self.draw_actor_behavior_editor(ui, &actor);
|
||||
self.draw_debugger(ui, &actor);
|
||||
self.draw_logs(ui, &actor.actor_ref);
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_actor_behavior_editor(&mut self, ui: &mut egui::Ui, actor: &ActorSnapshotDto) {
|
||||
ui.collapsing("Actor behavior", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(actor.behavior_path.as_deref().unwrap_or("No source path"));
|
||||
if ui.button("Load active behavior").clicked() {
|
||||
self.submit(Command::ActorBehavior {
|
||||
actor_ref: actor.actor_ref.clone(),
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!self.actor_behavior_dirty,
|
||||
egui::Button::new("Reload from source"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.submit(Command::System {
|
||||
actor_ref: actor.actor_ref.clone(),
|
||||
message: SystemMessageDto::Reload,
|
||||
});
|
||||
}
|
||||
});
|
||||
if self.actor_behavior_dirty {
|
||||
ui.colored_label(egui::Color32::YELLOW, "Unsaved temporary draft");
|
||||
if ui.button("Discard draft").clicked()
|
||||
&& let Some(source) = &self.actor_behavior
|
||||
{
|
||||
self.actor_behavior_draft.clone_from(&source.source);
|
||||
self.actor_behavior_dirty = false;
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(
|
||||
self.actor_behavior.is_some() && self.actor_behavior_dirty,
|
||||
egui::Button::new("Save as temporary behavior"),
|
||||
)
|
||||
.clicked()
|
||||
&& let Some(source) = &self.actor_behavior
|
||||
{
|
||||
self.submit(Command::WriteActorBehavior {
|
||||
actor_ref: actor.actor_ref.clone(),
|
||||
expected_source_id: source.source_id.clone(),
|
||||
source: self.actor_behavior_draft.clone(),
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
actor.temporary_behavior && !self.actor_behavior_dirty,
|
||||
egui::Button::new("Update shared source"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.update_behavior_confirmation = true;
|
||||
}
|
||||
});
|
||||
if self.actor_behavior.is_none() {
|
||||
ui.label(
|
||||
"Load the actor's current behavior to edit an actor-local temporary copy.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut layouter = |ui: &egui::Ui, source: &str, wrap_width: f32| {
|
||||
let mut job = rune_layout_job(source);
|
||||
job.wrap.max_width = wrap_width;
|
||||
ui.fonts(|fonts| fonts.layout_job(job))
|
||||
};
|
||||
let response = ui.add(
|
||||
egui::TextEdit::multiline(&mut self.actor_behavior_draft)
|
||||
.code_editor()
|
||||
.layouter(&mut layouter)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(12),
|
||||
);
|
||||
if response.changed() {
|
||||
self.actor_behavior_dirty = self
|
||||
.actor_behavior
|
||||
.as_ref()
|
||||
.is_some_and(|source| self.actor_behavior_draft != source.source);
|
||||
}
|
||||
});
|
||||
if self.update_behavior_confirmation {
|
||||
egui::Window::new("Confirm shared behavior update")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.label(format!(
|
||||
"Overwrite `{}` in the image source tree with this actor's temporary behavior?",
|
||||
self.actor_behavior
|
||||
.as_ref()
|
||||
.map_or("unknown source", |source| source.path.as_str())
|
||||
));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Update source").clicked() {
|
||||
let Some(temporary) = &self.actor_behavior else {
|
||||
self.update_behavior_confirmation = false;
|
||||
return;
|
||||
};
|
||||
let expected_source_id = self.source_ids.get(&temporary.path).cloned();
|
||||
match expected_source_id {
|
||||
Some(expected_source_id) => self.submit(
|
||||
Command::UpdateBehaviorSource {
|
||||
actor_ref: actor.actor_ref.clone(),
|
||||
path: temporary.path.clone(),
|
||||
expected_source_id,
|
||||
temporary_source_id: temporary.source_id.clone(),
|
||||
},
|
||||
),
|
||||
None => {
|
||||
self.notice =
|
||||
"Could not determine the current source identity; refresh and try again"
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
self.update_behavior_confirmation = false;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
self.update_behavior_confirmation = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) {
|
||||
ui.collapsing("Send message", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -864,6 +1055,7 @@ fn request_reply(client: &mut WebSocketClient, command: Command) -> Result<Reply
|
|||
fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate>) {
|
||||
let mut client = None;
|
||||
let mut selected_actor = None;
|
||||
let mut selected_behavior_path = None;
|
||||
let mut log_sequence = 0;
|
||||
let mut retained_logs = Vec::new();
|
||||
let mut next_poll = Instant::now();
|
||||
|
|
@ -873,6 +1065,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
Ok(connected) => {
|
||||
client = Some(connected);
|
||||
selected_actor = None;
|
||||
selected_behavior_path = None;
|
||||
log_sequence = 0;
|
||||
retained_logs.clear();
|
||||
next_poll = Instant::now();
|
||||
|
|
@ -887,6 +1080,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
},
|
||||
Ok(WorkerRequest::Select(actor_ref)) => {
|
||||
selected_actor = actor_ref;
|
||||
selected_behavior_path = None;
|
||||
log_sequence = 0;
|
||||
retained_logs.clear();
|
||||
next_poll = Instant::now();
|
||||
|
|
@ -907,6 +1101,9 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
let operation = match &command {
|
||||
Command::ReadSource { .. } => "read_source",
|
||||
Command::WriteSource { .. } => "write_source",
|
||||
Command::ActorBehavior { .. } => "actor_behavior",
|
||||
Command::WriteActorBehavior { .. } => "write_actor_behavior",
|
||||
Command::UpdateBehaviorSource { .. } => "update_behavior_source",
|
||||
Command::CheckSource { .. } => "check_source",
|
||||
Command::GetEvents => "get_events",
|
||||
Command::CommitSource { .. } => "commit_source",
|
||||
|
|
@ -916,7 +1113,8 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
_ => "command",
|
||||
};
|
||||
let submitted_source = match &command {
|
||||
Command::WriteSource { source, .. } => Some(source.clone()),
|
||||
Command::WriteSource { source, .. }
|
||||
| Command::WriteActorBehavior { source, .. } => Some(source.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let revision = match &command {
|
||||
|
|
@ -924,13 +1122,19 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
| Command::RollbackSource { revision, .. } => Some(revision.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let requested_actor_ref = match &command {
|
||||
Command::ActorBehavior { actor_ref }
|
||||
| Command::WriteActorBehavior { actor_ref, .. } => Some(actor_ref.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let source_path = match &command {
|
||||
Command::ReadSource { path }
|
||||
| Command::WriteSource { path, .. }
|
||||
| Command::CommitSource { path }
|
||||
| Command::SourceHistory { path }
|
||||
| Command::RevisionSource { path, .. }
|
||||
| Command::RollbackSource { path, .. } => Some(path.clone()),
|
||||
| Command::RollbackSource { path, .. }
|
||||
| Command::UpdateBehaviorSource { path, .. } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
match request_reply(connected, command) {
|
||||
|
|
@ -946,6 +1150,25 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
.expect("write operation includes its submitted source"),
|
||||
})
|
||||
}
|
||||
("actor_behavior", Reply::ActorBehavior { source_id, source }) => {
|
||||
Some(WorkerUpdate::ActorBehavior {
|
||||
actor_ref: requested_actor_ref.unwrap_or_default(),
|
||||
path: selected_behavior_path.clone().unwrap_or_default(),
|
||||
source_id,
|
||||
source,
|
||||
submitted_source: None,
|
||||
})
|
||||
}
|
||||
(
|
||||
"write_actor_behavior",
|
||||
Reply::ActorBehavior { source_id, source },
|
||||
) => Some(WorkerUpdate::ActorBehavior {
|
||||
actor_ref: requested_actor_ref.unwrap_or_default(),
|
||||
path: selected_behavior_path.clone().unwrap_or_default(),
|
||||
source_id,
|
||||
source,
|
||||
submitted_source,
|
||||
}),
|
||||
("check_source", Reply::SourceChecked { diagnostics }) => {
|
||||
Some(WorkerUpdate::SourceDiagnostics(diagnostics))
|
||||
}
|
||||
|
|
@ -979,6 +1202,12 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
submitted_source,
|
||||
})
|
||||
}
|
||||
("update_behavior_source", Reply::Source(source)) => {
|
||||
Some(WorkerUpdate::SourceSaved {
|
||||
source: source.clone(),
|
||||
submitted_source: source.source,
|
||||
})
|
||||
}
|
||||
(_, reply) => Some(WorkerUpdate::Notice(format!(
|
||||
"Completed with unexpected response: {reply:?}"
|
||||
))),
|
||||
|
|
@ -1027,9 +1256,18 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
continue;
|
||||
}
|
||||
};
|
||||
let sources = match request_reply(connected, Command::ListSources) {
|
||||
Ok(Reply::Sources(sources)) => sources.into_iter().map(|source| source.path).collect(),
|
||||
Ok(_) => Vec::new(),
|
||||
let (sources, source_ids) = match request_reply(connected, Command::ListSources) {
|
||||
Ok(Reply::Sources(sources)) => {
|
||||
let source_ids = sources
|
||||
.iter()
|
||||
.map(|source| (source.path.clone(), source.source_id.clone()))
|
||||
.collect();
|
||||
(
|
||||
sources.into_iter().map(|source| source.path).collect(),
|
||||
source_ids,
|
||||
)
|
||||
}
|
||||
Ok(_) => (Vec::new(), BTreeMap::new()),
|
||||
Err(error) => {
|
||||
report_call_error(error, &updates, &mut client);
|
||||
continue;
|
||||
|
|
@ -1041,6 +1279,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
.find(|actor| &actor.actor_ref == selected)
|
||||
.cloned()
|
||||
});
|
||||
selected_behavior_path = actor.as_ref().and_then(|actor| actor.behavior_path.clone());
|
||||
let (execution, logs) = if let Some(actor) = &actor {
|
||||
let execution = match request_reply(
|
||||
connected,
|
||||
|
|
@ -1088,6 +1327,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
|
|||
let _ = updates.send(WorkerUpdate::Snapshot(Box::new(WorkerSnapshot {
|
||||
actors,
|
||||
sources,
|
||||
source_ids,
|
||||
actor,
|
||||
execution,
|
||||
logs,
|
||||
|
|
|
|||
|
|
@ -224,11 +224,13 @@ loopback image over the shared WebSocket protocol. It currently provides an
|
|||
actor browser and inspector with message, lifecycle, log, breakpoint, bounded
|
||||
step, and execution-stack controls, plus a source editor with compilation
|
||||
diagnostics, explicit source commits, committed-revision previews, and
|
||||
confirmed rollback. Image events can be collected manually; this operation
|
||||
drains the shared queue and therefore consumes events for other clients too.
|
||||
Network requests run on a worker thread, not the UI frame loop. This is a
|
||||
client foundation, not yet a Rune-composed graphical world; frame locals and
|
||||
expression evaluation remain unavailable.
|
||||
confirmed rollback. The actor inspector can edit an actor-local temporary
|
||||
behavior and explicitly update its shared source or reload from that source.
|
||||
Image events can be collected manually; this operation drains the shared queue
|
||||
and therefore consumes events for other clients too. Network requests run on a
|
||||
worker thread, not the UI frame loop. This is a client foundation, not yet a
|
||||
Rune-composed graphical world; frame locals and expression evaluation remain
|
||||
unavailable.
|
||||
|
||||
## 8. Unified Programmatic Image API
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue