Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending.
647 lines
22 KiB
Rust
647 lines
22 KiB
Rust
//! Text-only presentation and captured command envelopes. Source/model authority
|
|
//! remains in the existing App controllers; dispatch this envelope before the
|
|
//! generic source_interaction invalidation in App::update.
|
|
use super::{App, Message};
|
|
use crate::{context_menu, source_navigation as nav};
|
|
use iced::{
|
|
Point, Task,
|
|
widget::{self, text_editor::Cursor},
|
|
};
|
|
use nav::query::{QueryStamp, SnapshotKey};
|
|
use oa_core::{Id, source_project::SourceLocation};
|
|
use std::collections::BTreeSet;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DrawerTab {
|
|
References,
|
|
Problems,
|
|
Status,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Command {
|
|
Find,
|
|
GoToLine,
|
|
Definition,
|
|
References,
|
|
RenameAlias,
|
|
Back,
|
|
UndoText,
|
|
RedoText,
|
|
UndoModel,
|
|
RedoModel,
|
|
UndoRename,
|
|
RevertFile,
|
|
ShowProblems,
|
|
ShowStatus,
|
|
ShowDiagramUsages,
|
|
}
|
|
impl Command {
|
|
fn shortcut(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::Find => Some("Ctrl F"),
|
|
Self::GoToLine => Some("Ctrl G"),
|
|
Self::Definition => Some("F12"),
|
|
Self::References => Some("Shift F12"),
|
|
Self::RenameAlias => Some("F2"),
|
|
Self::Back => Some("Alt ←"),
|
|
Self::UndoText => Some("Ctrl Z"),
|
|
Self::RedoText => Some("Ctrl Y"),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Find => "Find in file",
|
|
Self::GoToLine => "Go to line",
|
|
Self::Definition => "Go to definition",
|
|
Self::References => "Find references",
|
|
Self::RenameAlias => "Rename source alias",
|
|
Self::Back => "Back in source",
|
|
Self::UndoText => "Undo text edit",
|
|
Self::RedoText => "Redo text edit",
|
|
Self::UndoModel => "Undo model change",
|
|
Self::RedoModel => "Redo model change",
|
|
Self::UndoRename => "Undo source rename",
|
|
Self::RevertFile => "Revert current file",
|
|
Self::ShowProblems => "Source problems",
|
|
Self::ShowStatus => "Source status",
|
|
Self::ShowDiagramUsages => "Show diagram usages",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct CommandContext {
|
|
document: Id,
|
|
document_revision: u64,
|
|
source: SnapshotKey,
|
|
path: String,
|
|
cursor: Cursor,
|
|
interaction: Id,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommandRequest {
|
|
context: CommandContext,
|
|
command: Command,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct MenuRequest {
|
|
context: CommandContext,
|
|
anchor: Point,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct DiagnosticPublication {
|
|
generation: Id,
|
|
source: SnapshotKey,
|
|
}
|
|
impl DiagnosticPublication {
|
|
pub fn generation(self) -> Id {
|
|
self.generation
|
|
}
|
|
pub fn source(self) -> SnapshotKey {
|
|
self.source
|
|
}
|
|
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
|
DiagnosticTarget {
|
|
publication: self,
|
|
visit,
|
|
location,
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct DiagnosticTarget {
|
|
publication: DiagnosticPublication,
|
|
visit: Id,
|
|
location: SourceLocation,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
ShowDrawer(DrawerTab),
|
|
HideDrawer,
|
|
ResizeDrawer(f32),
|
|
ToggleFile {
|
|
stamp: QueryStamp,
|
|
visit: Id,
|
|
path: String,
|
|
},
|
|
ReferenceScrolled {
|
|
stamp: QueryStamp,
|
|
visit: Id,
|
|
y: f32,
|
|
},
|
|
Command(CommandRequest),
|
|
OpenMenu(MenuRequest),
|
|
DismissMenu(Id),
|
|
Invoke {
|
|
token: Id,
|
|
command: Command,
|
|
},
|
|
Diagnostic(DiagnosticTarget),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FrozenEntry {
|
|
command: Command,
|
|
action: Option<Action>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
enum Action {
|
|
Dispatch(Box<Message>),
|
|
Drawer(DrawerTab),
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct Menu {
|
|
token: Id,
|
|
popup_token: u64,
|
|
anchor: Point,
|
|
context: CommandContext,
|
|
entries: Vec<FrozenEntry>,
|
|
}
|
|
impl Menu {
|
|
pub fn token(&self) -> Id {
|
|
self.token
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ReferencePresentation {
|
|
stamp: Option<QueryStamp>,
|
|
origin: String,
|
|
files: BTreeSet<String>,
|
|
installed: bool,
|
|
collapsed_files: BTreeSet<String>,
|
|
scroll_y: f32,
|
|
}
|
|
impl Default for ReferencePresentation {
|
|
fn default() -> Self {
|
|
Self {
|
|
stamp: None,
|
|
origin: String::new(),
|
|
files: BTreeSet::new(),
|
|
installed: false,
|
|
collapsed_files: BTreeSet::new(),
|
|
scroll_y: 0.,
|
|
}
|
|
}
|
|
}
|
|
impl ReferencePresentation {
|
|
pub fn stamp(&self) -> Option<QueryStamp> {
|
|
self.stamp
|
|
}
|
|
pub fn scroll_y(&self) -> f32 {
|
|
self.scroll_y
|
|
}
|
|
pub fn collapsed(&self, path: &str) -> bool {
|
|
self.collapsed_files.contains(path)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct State {
|
|
drawer: Option<DrawerTab>,
|
|
drawer_visit: Id,
|
|
drawer_height: f32,
|
|
references: ReferencePresentation,
|
|
menu: Option<Menu>,
|
|
diagnostic_publication: Option<DiagnosticPublication>,
|
|
}
|
|
impl Default for State {
|
|
fn default() -> Self {
|
|
Self {
|
|
drawer: None,
|
|
drawer_visit: Id::new_v4(),
|
|
drawer_height: 180.,
|
|
references: ReferencePresentation::default(),
|
|
menu: None,
|
|
diagnostic_publication: None,
|
|
}
|
|
}
|
|
}
|
|
impl State {
|
|
pub fn drawer_visit(&self) -> Id {
|
|
self.drawer_visit
|
|
}
|
|
pub fn reference_scroll_id(&self) -> widget::Id {
|
|
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
|
}
|
|
pub fn drawer(&self) -> Option<DrawerTab> {
|
|
self.drawer
|
|
}
|
|
pub fn drawer_height(&self) -> f32 {
|
|
self.drawer_height
|
|
}
|
|
pub fn references(&self) -> &ReferencePresentation {
|
|
&self.references
|
|
}
|
|
pub fn menu(&self) -> Option<&Menu> {
|
|
self.menu.as_ref()
|
|
}
|
|
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
|
self.diagnostic_publication
|
|
}
|
|
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
|
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
|
let publication = DiagnosticPublication {
|
|
generation: Id::new_v4(),
|
|
source,
|
|
};
|
|
self.diagnostic_publication = Some(publication);
|
|
publication
|
|
}
|
|
pub fn reset(&mut self) {
|
|
*self = Self::default();
|
|
}
|
|
pub fn dismiss_menu(&mut self) {
|
|
self.menu = None;
|
|
}
|
|
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
|
self.references = ReferencePresentation {
|
|
stamp: Some(stamp),
|
|
origin,
|
|
..ReferencePresentation::default()
|
|
};
|
|
self.show_drawer(Some(DrawerTab::References));
|
|
}
|
|
/// Install presentation groups only for the current completed query. Repeated
|
|
/// delivery of the same completion cannot undo the user's group choices.
|
|
pub fn install_reference_files(
|
|
&mut self,
|
|
stamp: QueryStamp,
|
|
paths: impl IntoIterator<Item = String>,
|
|
) {
|
|
let refs = &mut self.references;
|
|
if refs.stamp != Some(stamp) || refs.installed {
|
|
return;
|
|
}
|
|
refs.files = paths.into_iter().collect();
|
|
refs.collapsed_files = refs.files.clone();
|
|
let expanded = if refs.files.contains(&refs.origin) {
|
|
Some(refs.origin.clone())
|
|
} else {
|
|
refs.files.first().cloned()
|
|
};
|
|
if let Some(path) = expanded {
|
|
refs.collapsed_files.remove(&path);
|
|
}
|
|
refs.installed = true;
|
|
}
|
|
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
|
self.drawer = drawer;
|
|
self.drawer_visit = Id::new_v4();
|
|
}
|
|
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
|
let refs = &mut self.references;
|
|
if self.drawer != Some(DrawerTab::References)
|
|
|| self.drawer_visit != visit
|
|
|| refs.stamp != Some(stamp)
|
|
|| !refs.files.contains(&path)
|
|
{
|
|
return false;
|
|
}
|
|
if !refs.collapsed_files.remove(&path) {
|
|
refs.collapsed_files.insert(path);
|
|
}
|
|
// Collapsing a group can replace a pressed row at the same position.
|
|
self.drawer_visit = Id::new_v4();
|
|
true
|
|
}
|
|
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
|
let refs = &mut self.references;
|
|
if self.drawer == Some(DrawerTab::References)
|
|
&& refs.stamp == Some(stamp)
|
|
&& self.drawer_visit == visit
|
|
&& y.is_finite()
|
|
&& y >= 0.
|
|
{
|
|
refs.scroll_y = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
fn text_command_context(&self) -> CommandContext {
|
|
CommandContext {
|
|
document: self.canvas_document,
|
|
document_revision: self.document.revision(),
|
|
source: self.source_key(),
|
|
path: self.source_workspace.active.clone(),
|
|
cursor: self.source_editor.cursor(),
|
|
interaction: self.source_symbols.interaction,
|
|
}
|
|
}
|
|
pub fn text_command_message(&self, command: Command) -> Message {
|
|
Message::TextWorkspace(Event::Command(CommandRequest {
|
|
context: self.text_command_context(),
|
|
command,
|
|
}))
|
|
}
|
|
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
|
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
|
context: self.text_command_context(),
|
|
anchor,
|
|
}))
|
|
}
|
|
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
|
let menu = self.text_workspace.menu.as_ref()?;
|
|
Some(context_menu::Menu {
|
|
token: menu.popup_token,
|
|
anchor: menu.anchor,
|
|
entries: menu
|
|
.entries
|
|
.iter()
|
|
.flat_map(|entry| {
|
|
let mut rows = Vec::with_capacity(2);
|
|
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
|
rows.push(context_menu::Entry::separator());
|
|
}
|
|
rows.push(context_menu::Entry::Action {
|
|
label: entry.command.label().into(),
|
|
shortcut: entry.command.shortcut().map(str::to_owned),
|
|
message: entry.action.as_ref().map(|_| {
|
|
Message::TextWorkspace(Event::Invoke {
|
|
token: menu.token(),
|
|
command: entry.command,
|
|
})
|
|
}),
|
|
});
|
|
rows
|
|
})
|
|
.collect(),
|
|
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
|
})
|
|
}
|
|
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
|
let publication = self.text_workspace.diagnostic_publication?;
|
|
(publication.source == self.source_key()
|
|
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
|
.then(|| {
|
|
Message::TextWorkspace(Event::Diagnostic(
|
|
publication.target(self.text_workspace.drawer_visit(), location),
|
|
))
|
|
})
|
|
}
|
|
pub(crate) fn source_annotation_snapshot(
|
|
&self,
|
|
) -> Option<crate::source_annotations::Snapshot<'_>> {
|
|
let publication = self.text_workspace.diagnostic_publication?;
|
|
let buffer = self
|
|
.source_workspace
|
|
.files
|
|
.get(&self.source_workspace.active)?;
|
|
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
|
|
return None;
|
|
}
|
|
Some(crate::source_annotations::Snapshot {
|
|
publication: publication.generation,
|
|
source: self.source_snapshot.clone(),
|
|
path: &self.source_workspace.active,
|
|
diagnostics: &self.source_diagnostics,
|
|
})
|
|
}
|
|
fn text_command_action(&self, command: Command) -> Option<Action> {
|
|
// Menu has been taken before dispatch-time admission. This intentionally
|
|
// uses navigation admission, not rename admission, for Find/text history.
|
|
if !self.source_navigation_available() {
|
|
return None;
|
|
}
|
|
let message =
|
|
match command {
|
|
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
|
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
|
Command::Definition => self
|
|
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
|
Command::References => self
|
|
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
|
Command::RenameAlias => {
|
|
self.rename_admission().ok()?;
|
|
self.source_rename_message()
|
|
}
|
|
Command::Back => {
|
|
if !self
|
|
.source_symbols
|
|
.back
|
|
.iter()
|
|
.any(|entry| entry.session == self.source_workspace.session())
|
|
{
|
|
return None;
|
|
}
|
|
self.source_symbol_message(nav::SymbolEvent::Back)
|
|
}
|
|
Command::UndoText => Message::SourceUndo,
|
|
Command::RedoText => Message::SourceRedo,
|
|
Command::UndoModel | Command::RedoModel => {
|
|
if self.source_dirty() || self.draft_dirty() {
|
|
return None;
|
|
}
|
|
if command == Command::UndoModel {
|
|
if !self.document.can_undo() {
|
|
return None;
|
|
}
|
|
Message::Undo
|
|
} else {
|
|
if !self.document.can_redo() {
|
|
return None;
|
|
}
|
|
Message::Redo
|
|
}
|
|
}
|
|
Command::UndoRename => self.source_rename_undo_message()?,
|
|
Command::RevertFile => Message::RevertSource,
|
|
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
|
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
|
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
|
};
|
|
Some(Action::Dispatch(Box::new(message)))
|
|
}
|
|
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
|
match event {
|
|
Event::OpenMenu(request) => {
|
|
if request.context != self.text_command_context()
|
|
|| !self.source_navigation_available()
|
|
|| self.text_workspace.menu.is_some()
|
|
|| !request.anchor.x.is_finite()
|
|
|| !request.anchor.y.is_finite()
|
|
{
|
|
return Task::none();
|
|
}
|
|
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
|
return Task::none();
|
|
};
|
|
self.source_interaction();
|
|
let commands = [
|
|
Command::Find,
|
|
Command::GoToLine,
|
|
Command::Definition,
|
|
Command::References,
|
|
Command::RenameAlias,
|
|
Command::Back,
|
|
Command::UndoText,
|
|
Command::RedoText,
|
|
Command::UndoRename,
|
|
Command::UndoModel,
|
|
Command::RedoModel,
|
|
Command::RevertFile,
|
|
Command::ShowProblems,
|
|
Command::ShowStatus,
|
|
Command::ShowDiagramUsages,
|
|
];
|
|
let entries = commands
|
|
.into_iter()
|
|
.filter_map(|command| {
|
|
let action = self.text_command_action(command);
|
|
if command == Command::ShowDiagramUsages && action.is_none() {
|
|
None
|
|
} else {
|
|
Some(FrozenEntry { command, action })
|
|
}
|
|
})
|
|
.collect();
|
|
self.menu_sequence = popup_token;
|
|
self.text_workspace.menu = Some(Menu {
|
|
token: Id::new_v4(),
|
|
popup_token,
|
|
anchor: request.anchor,
|
|
context: self.text_command_context(),
|
|
entries,
|
|
});
|
|
}
|
|
Event::DismissMenu(token) => {
|
|
if self
|
|
.text_workspace
|
|
.menu
|
|
.as_ref()
|
|
.is_some_and(|menu| menu.token == token)
|
|
{
|
|
self.text_workspace.menu = None;
|
|
self.source_interaction();
|
|
}
|
|
}
|
|
Event::Invoke { token, command } => {
|
|
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
|
menu.token == token && menu.context == self.text_command_context()
|
|
}) {
|
|
return Task::none();
|
|
}
|
|
// Validate before closing; consume this invocation only once.
|
|
// Never resolve a frozen row against a newly generated menu.
|
|
let menu = self.text_workspace.menu.take().unwrap();
|
|
let Some(action) = menu
|
|
.entries
|
|
.into_iter()
|
|
.find(|entry| entry.command == command)
|
|
.and_then(|entry| entry.action)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
if self.text_command_action(command).is_none() {
|
|
return Task::none();
|
|
}
|
|
return self.dispatch_text_action(action);
|
|
}
|
|
Event::Command(request) => {
|
|
if request.context != self.text_command_context() {
|
|
return Task::none();
|
|
}
|
|
if let Some(action) = self.text_command_action(request.command) {
|
|
return self.dispatch_text_action(action);
|
|
}
|
|
}
|
|
Event::ShowDrawer(tab) => {
|
|
if !self.source_navigation_available() {
|
|
return Task::none();
|
|
}
|
|
return self.show_text_drawer(Some(tab));
|
|
}
|
|
Event::HideDrawer => {
|
|
if !self.source_navigation_available() {
|
|
return Task::none();
|
|
}
|
|
return self.show_text_drawer(None);
|
|
}
|
|
Event::ResizeDrawer(height) => {
|
|
if self.source_navigation_available() && height.is_finite() {
|
|
// The view applies the measured editor-allocation bound.
|
|
let height = height.clamp(180., 1200.);
|
|
if height != self.text_workspace.drawer_height {
|
|
self.source_interaction();
|
|
self.text_workspace.drawer_height = height;
|
|
}
|
|
}
|
|
}
|
|
Event::ToggleFile { stamp, visit, path } => {
|
|
if self.source_navigation_available()
|
|
&& self.text_workspace.toggle_file(stamp, visit, path)
|
|
{
|
|
self.source_interaction();
|
|
}
|
|
}
|
|
Event::ReferenceScrolled { stamp, visit, y } => {
|
|
// Passive geometry must neither invalidate pending navigation
|
|
// nor reset the editor identity, even while commands are blocked.
|
|
self.text_workspace.reference_scrolled(stamp, visit, y);
|
|
}
|
|
Event::Diagnostic(target) => {
|
|
if !self.source_navigation_available()
|
|
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
|
|| self.text_workspace.drawer_visit() != target.visit
|
|
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
|
|| target.publication.source != self.source_key()
|
|
|| !self
|
|
.source_workspace
|
|
.files
|
|
.get(&target.location.path)
|
|
.is_some_and(|file| valid_span(&file.text, &target.location))
|
|
{
|
|
return Task::none();
|
|
}
|
|
self.source_interaction();
|
|
self.jump_to_source(target.location);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
|
match action {
|
|
Action::Dispatch(message) => self.update(*message),
|
|
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
|
|
}
|
|
}
|
|
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
|
self.source_interaction();
|
|
self.text_workspace.show_drawer(drawer);
|
|
if drawer == Some(DrawerTab::References) {
|
|
let refs = self.text_workspace.references();
|
|
widget::operation::scroll_to(
|
|
self.text_workspace.reference_scroll_id(),
|
|
widget::scrollable::AbsoluteOffset {
|
|
x: 0.,
|
|
y: refs.scroll_y(),
|
|
},
|
|
)
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
|
let span = location.span;
|
|
span.start <= span.end
|
|
&& span.end <= text.len()
|
|
&& text.is_char_boundary(span.start)
|
|
&& text.is_char_boundary(span.end)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
mod lifecycle_tests;
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod focus_selection_tests;
|