Add a simple GUI
This commit is contained in:
parent
7a57c6daef
commit
a6a57ab53f
3908
Cargo.lock
generated
3908
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@ -26,6 +26,27 @@ proptest = "1.4" # property-based testing
|
||||
rand = "0.9.2"
|
||||
tempfile = "3.10" # temp dirs for persistence tests
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gui = ["eframe", "egui_extras", "rfd"]
|
||||
|
||||
[dependencies.eframe]
|
||||
version = "0.29"
|
||||
optional = true
|
||||
|
||||
[dependencies.egui_extras]
|
||||
version = "0.29"
|
||||
optional = true
|
||||
|
||||
[dependencies.rfd]
|
||||
version = "0.15"
|
||||
optional = true
|
||||
|
||||
[[bin]]
|
||||
name = "geolog"
|
||||
path = "src/bin/geolog.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "geolog-gui"
|
||||
path = "src/bin/geolog-gui.rs"
|
||||
required-features = ["gui"]
|
||||
|
||||
249
README2.md
Normal file
249
README2.md
Normal file
@ -0,0 +1,249 @@
|
||||
# Geolog Development Notes
|
||||
|
||||
This document describes the features and components added to the geolog project (by me, Hassan).
|
||||
|
||||
## Overview
|
||||
|
||||
Geolog is a language for geometric logic - a type theory with semantics in topoi and geometric morphisms, designed as a unified language for database schemas, queries, and migrations.
|
||||
|
||||
## Components
|
||||
|
||||
### Core Components (Pre-existing)
|
||||
|
||||
- **Parser & Lexer** (`src/lexer.rs`, `src/parser.rs`) - Chumsky-based parser for geolog syntax
|
||||
- **AST** (`src/ast.rs`) - Abstract syntax tree representation
|
||||
- **Core Types** (`src/core.rs`) - Signatures, structures, theories, axioms
|
||||
- **Elaboration** (`src/elaborate.rs`) - Type checking and elaboration
|
||||
- **Store** (`src/store.rs`) - Append-only persistent storage with version control
|
||||
- **REPL** (`src/repl.rs`) - Interactive command-line environment
|
||||
- **Solver** (`src/solver.rs`) - Model enumeration for geometric theories
|
||||
- **Query Engine** (`src/query/`) - Relational algebra backend for queries
|
||||
- **Chase Algorithm** (`src/query/chase.rs`) - Fixpoint computation for axiom closure
|
||||
|
||||
### Debugger (`src/debugger.rs`)
|
||||
|
||||
Interactive debugger for the chase algorithm providing:
|
||||
- Step-through execution
|
||||
- Breakpoints on axiom name patterns
|
||||
- Variable inspection
|
||||
- Iteration stepping
|
||||
|
||||
Usage in REPL:
|
||||
```
|
||||
:debug-chase <instance> [max_iterations]
|
||||
```
|
||||
|
||||
### GUI Application (`src/gui/`)
|
||||
|
||||
A full-featured graphical interface built with egui.
|
||||
|
||||
#### Building the GUI
|
||||
|
||||
```bash
|
||||
# Build with GUI feature
|
||||
cargo build --features gui
|
||||
|
||||
# Run the GUI
|
||||
cargo run --features gui --bin geolog-gui
|
||||
|
||||
# With workspace persistence
|
||||
cargo run --features gui --bin geolog-gui -- -d ./myproject
|
||||
|
||||
# Load a file on startup
|
||||
cargo run --features gui --bin geolog-gui -- examples/preorder.geolog
|
||||
```
|
||||
|
||||
#### GUI Architecture
|
||||
|
||||
```
|
||||
src/gui/
|
||||
├── mod.rs # Module exports
|
||||
├── app.rs # Main GeologApp (egui::App implementation)
|
||||
├── state.rs # GuiState wrapping ReplState
|
||||
├── panels/
|
||||
│ ├── mod.rs
|
||||
│ ├── browser.rs # Theory/instance tree browser
|
||||
│ ├── editor.rs # Code editor with line numbers
|
||||
│ ├── inspector.rs # Detail view for selected items
|
||||
│ └── console.rs # Output/error messages
|
||||
└── visualizations/
|
||||
├── mod.rs
|
||||
├── chase.rs # Chase step-through visualization
|
||||
└── graph.rs # Relation graph visualization
|
||||
```
|
||||
|
||||
#### GUI Features
|
||||
|
||||
**Browser Panel (Left)**
|
||||
- Tree view of all theories and instances
|
||||
- Filter/search functionality
|
||||
- Click to select, right-click for context menu
|
||||
- Shows counts (sorts, functions, relations, axioms, elements)
|
||||
|
||||
**Editor Panel (Center Top)**
|
||||
- Multi-line code editor
|
||||
- Line numbers
|
||||
- Run button (or Ctrl+Enter) to execute code
|
||||
- Clear button
|
||||
|
||||
**Inspector Panel (Center Bottom)**
|
||||
- Theory details: parameters, sorts, functions, relations, instance fields, axioms
|
||||
- Instance details: elements by sort, function values, relation tuples, nested instances
|
||||
- Collapsible sections
|
||||
- "View Graph" button for instances
|
||||
|
||||
**Console Panel (Bottom)**
|
||||
- Scrollable message log
|
||||
- Color-coded messages: info (gray), success (green), error (red), warning (orange)
|
||||
- Auto-scroll option
|
||||
- Clear button
|
||||
|
||||
**Chase Visualization**
|
||||
- Step/Run All/Stop controls
|
||||
- Events log showing axiom firings with bindings
|
||||
- Breakpoint management
|
||||
- Current structure info (elements, relation tuples)
|
||||
- Mode indicator (Idle/Stepping/Running/Completed)
|
||||
|
||||
**Graph Visualization**
|
||||
- Nodes represent elements (colored by sort)
|
||||
- Edges represent relation tuples
|
||||
- Interactive: drag nodes, zoom (scroll), pan
|
||||
- Relation filter dropdown
|
||||
- Reset Layout / Fit buttons
|
||||
- Hover to see sort name
|
||||
|
||||
#### UI Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ File Edit View Chase Help │
|
||||
├────────────┬────────────────────────────────────────────────┤
|
||||
│ ▼ Theories │ ┌─────────────────────────────────────────┐ │
|
||||
│ Preorder │ │ Editor │ │
|
||||
│ Graph │ │ theory Foo { │ │
|
||||
│ ▼ Instances│ │ X : Sort; │ │
|
||||
│ Chain3 │ │ } [Run] [Clear] │ │
|
||||
│ │ └─────────────────────────────────────────┘ │
|
||||
│ │ ┌─────────────────────────────────────────┐ │
|
||||
│ │ │ Inspector: Chain3 : Preorder │ │
|
||||
│ │ │ ▼ Elements (3) │ │
|
||||
│ │ │ X: bot, mid, top │ │
|
||||
│ │ │ ▼ Relations │ │
|
||||
│ │ │ leq: 6 tuples [View Graph] │ │
|
||||
│ │ └─────────────────────────────────────────┘ │
|
||||
├────────────┴────────────────────────────────────────────────┤
|
||||
│ Console [Clear]│
|
||||
│ > Defined theory Preorder (1 sorts, 1 relations, 2 axioms) │
|
||||
│ + Defined instance Chain3 : Preorder (3 elements) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### REPL Commands
|
||||
|
||||
```
|
||||
:help [topic] Show help (topics: syntax, examples)
|
||||
:quit Exit the REPL
|
||||
:list [target] List theories/instances
|
||||
:inspect <name> Show details of a theory or instance
|
||||
:source <file> Load and execute a geolog file
|
||||
:clear Clear the screen
|
||||
:reset Reset all state
|
||||
|
||||
# Version Control
|
||||
:commit [msg] Commit current changes
|
||||
:history Show commit history
|
||||
|
||||
# Instance Mutation
|
||||
:add <inst> <elem> <sort> Add element to instance
|
||||
:assert <inst> <rel> args Assert relation tuple
|
||||
:retract <inst> <elem> Retract element
|
||||
|
||||
# Query
|
||||
:query <inst> <sort> List all elements of a sort
|
||||
:explain <inst> <sort> Show query execution plan
|
||||
:compile <inst> <sort> Show RelAlgIR compilation
|
||||
:chase <inst> [max_iter] Run chase on instance axioms
|
||||
:debug-chase <inst> [max] Run chase with interactive debugger
|
||||
|
||||
# Solver
|
||||
:solve <theory> [budget] Find model of theory from scratch
|
||||
:extend <inst> <theory> Find extension of instance to theory
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Start REPL (in-memory)
|
||||
cargo run
|
||||
|
||||
# Start REPL with workspace persistence
|
||||
cargo run -- -d ./myproject
|
||||
|
||||
# Load file on startup
|
||||
cargo run -- examples/preorder.geolog
|
||||
|
||||
# Show help
|
||||
cargo run -- --help
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies
|
||||
- `chumsky` - Parser combinators
|
||||
- `ariadne` - Error reporting
|
||||
- `uuid` - Unique identifiers
|
||||
- `rkyv` - Zero-copy serialization
|
||||
- `rustyline` - Readline for REPL
|
||||
- `indexmap` - Ordered hash maps
|
||||
|
||||
### GUI Dependencies (feature = "gui")
|
||||
- `eframe` - egui framework with native window
|
||||
- `egui_extras` - Additional egui widgets
|
||||
- `rfd` - Native file dialogs
|
||||
|
||||
## Example
|
||||
|
||||
```geolog
|
||||
// Define a preorder theory
|
||||
theory Preorder {
|
||||
X : Sort;
|
||||
leq : [a: X, b: X] -> Prop;
|
||||
|
||||
// Reflexivity
|
||||
forall x : X. |- [a: x, b: x] leq;
|
||||
|
||||
// Transitivity
|
||||
forall x : X, y : X, z : X.
|
||||
[a: x, b: y] leq, [a: y, b: z] leq |- [a: x, b: z] leq;
|
||||
}
|
||||
|
||||
// Define an instance
|
||||
instance Chain3 : Preorder = {
|
||||
bot : X;
|
||||
mid : X;
|
||||
top : X;
|
||||
[a: bot, b: mid] leq;
|
||||
[a: mid, b: top] leq;
|
||||
}
|
||||
```
|
||||
|
||||
Then run `:chase Chain3` to compute the transitive closure.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Check without GUI
|
||||
cargo check
|
||||
|
||||
# Check with GUI
|
||||
cargo check --features gui
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Build release
|
||||
cargo build --release --features gui
|
||||
```
|
||||
134
src/bin/geolog-gui.rs
Normal file
134
src/bin/geolog-gui.rs
Normal file
@ -0,0 +1,134 @@
|
||||
//! Geolog GUI - Graphical interface for geometric logic
|
||||
//!
|
||||
//! Usage: geolog-gui [workspace]
|
||||
//!
|
||||
//! Provides a graphical interface for:
|
||||
//! - Browsing and editing theories and instances
|
||||
//! - Visualizing chase execution
|
||||
//! - Displaying relation graphs
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use geolog::gui::GeologApp;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
// Parse command line arguments
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let (workspace_path, source_files) = parse_args(&args);
|
||||
|
||||
// Set up native window options
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1200.0, 800.0])
|
||||
.with_min_inner_size([800.0, 600.0])
|
||||
.with_title(format!("Geolog v{}", VERSION)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Run the application
|
||||
eframe::run_native(
|
||||
"Geolog",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// Set up custom fonts and styles
|
||||
setup_fonts(&cc.egui_ctx);
|
||||
|
||||
// Create the app
|
||||
let mut app = if let Some(path) = workspace_path {
|
||||
GeologApp::with_path(cc, path)
|
||||
} else {
|
||||
GeologApp::new(cc)
|
||||
};
|
||||
|
||||
// Load source files if provided
|
||||
for path in source_files {
|
||||
app.state.load_file(&path);
|
||||
app.state.execute_editor();
|
||||
}
|
||||
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse command line arguments
|
||||
fn parse_args(args: &[String]) -> (Option<PathBuf>, Vec<PathBuf>) {
|
||||
let mut workspace_path = None;
|
||||
let mut source_files = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
match arg.as_str() {
|
||||
"-d" | "--dir" => {
|
||||
if i + 1 < args.len() {
|
||||
workspace_path = Some(PathBuf::from(&args[i + 1]));
|
||||
i += 2;
|
||||
} else {
|
||||
eprintln!("Error: -d requires a path argument");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("geolog-gui v{} - Geometric Logic GUI", VERSION);
|
||||
println!();
|
||||
println!("Usage: geolog-gui [OPTIONS] [source_files...]");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -d, --dir <path> Use <path> as workspace directory for persistence");
|
||||
println!(" -h, --help Show this help message");
|
||||
println!(" -v, --version Show version");
|
||||
println!();
|
||||
println!("Examples:");
|
||||
println!(" geolog-gui Start GUI (in-memory, no persistence)");
|
||||
println!(" geolog-gui -d ./myproject Start GUI with workspace persistence");
|
||||
println!(" geolog-gui file.geolog Load file.geolog on startup");
|
||||
std::process::exit(0);
|
||||
}
|
||||
"-v" | "--version" => {
|
||||
println!("geolog-gui v{}", VERSION);
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ if arg.starts_with('-') => {
|
||||
eprintln!("Error: Unknown option '{}'", arg);
|
||||
eprintln!("Try 'geolog-gui --help' for usage information");
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => {
|
||||
// Positional argument - treat as source file
|
||||
source_files.push(PathBuf::from(arg));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(workspace_path, source_files)
|
||||
}
|
||||
|
||||
/// Set up custom fonts and styles
|
||||
fn setup_fonts(ctx: &egui::Context) {
|
||||
// Use a slightly larger default font
|
||||
let mut style = (*ctx.style()).clone();
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Body,
|
||||
egui::FontId::proportional(14.0),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Heading,
|
||||
egui::FontId::proportional(18.0),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Monospace,
|
||||
egui::FontId::monospace(13.0),
|
||||
);
|
||||
|
||||
// Set up dark theme with custom colors
|
||||
style.visuals = egui::Visuals::dark();
|
||||
style.visuals.override_text_color = Some(egui::Color32::from_gray(220));
|
||||
|
||||
ctx.set_style(style);
|
||||
}
|
||||
347
src/gui/app.rs
Normal file
347
src/gui/app.rs
Normal file
@ -0,0 +1,347 @@
|
||||
//! Main Geolog GUI application
|
||||
//!
|
||||
//! Implements the egui::App trait for the main application window.
|
||||
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::panels::{BrowserPanel, ConsolePanel, EditorPanel, InspectorPanel};
|
||||
use super::state::{CentralView, GuiState};
|
||||
use super::visualizations::{ChaseVisualization, GraphView};
|
||||
|
||||
/// Main Geolog GUI application
|
||||
pub struct GeologApp {
|
||||
/// Application state
|
||||
pub state: GuiState,
|
||||
|
||||
/// Panel instances
|
||||
browser: BrowserPanel,
|
||||
editor: EditorPanel,
|
||||
inspector: InspectorPanel,
|
||||
console: ConsolePanel,
|
||||
chase_viz: ChaseVisualization,
|
||||
graph_view: GraphView,
|
||||
}
|
||||
|
||||
impl GeologApp {
|
||||
/// Create a new Geolog application
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
Self {
|
||||
state: GuiState::new(),
|
||||
browser: BrowserPanel::new(),
|
||||
editor: EditorPanel::new(),
|
||||
inspector: InspectorPanel::new(),
|
||||
console: ConsolePanel::new(),
|
||||
chase_viz: ChaseVisualization::new(),
|
||||
graph_view: GraphView::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new application with a workspace path
|
||||
pub fn with_path(_cc: &eframe::CreationContext<'_>, path: PathBuf) -> Self {
|
||||
let mut app = Self::new(_cc);
|
||||
app.state = GuiState::with_path(path);
|
||||
app
|
||||
}
|
||||
|
||||
/// Render the menu bar
|
||||
fn menu_bar(&mut self, ui: &mut egui::Ui) {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Open...").clicked() {
|
||||
self.handle_open_file();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
self.handle_save_file();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Reset State").clicked() {
|
||||
self.state.repl.reset();
|
||||
self.state.log_info("State reset");
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Edit", |ui| {
|
||||
if ui.button("Clear Editor").clicked() {
|
||||
self.state.editor_content.clear();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Clear Console").clicked() {
|
||||
self.state.clear_console();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.state.central_view == CentralView::EditorAndInspector,
|
||||
"Editor & Inspector",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.state.central_view = CentralView::EditorAndInspector;
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.state.central_view == CentralView::ChaseVisualization,
|
||||
"Chase Visualization",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.state.central_view = CentralView::ChaseVisualization;
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.state.central_view == CentralView::GraphVisualization,
|
||||
"Graph Visualization",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.state.central_view = CentralView::GraphVisualization;
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Chase", |ui| {
|
||||
if let Some(crate::gui::state::SelectedItem::Instance(name)) =
|
||||
&self.state.selected_item
|
||||
{
|
||||
let name = name.clone();
|
||||
if ui.button(format!("Run Chase on {}", name)).clicked() {
|
||||
self.handle_run_chase(&name);
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button(format!("Debug Chase on {}", name)).clicked() {
|
||||
self.state.start_chase(&name);
|
||||
ui.close_menu();
|
||||
}
|
||||
} else {
|
||||
ui.label("(Select an instance first)");
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("About").clicked() {
|
||||
self.state.log_info(format!(
|
||||
"Geolog GUI v{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Syntax Help").clicked() {
|
||||
self.show_syntax_help();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle opening a file
|
||||
fn handle_open_file(&mut self) {
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Geolog", &["geolog"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
{
|
||||
self.state.load_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle saving a file
|
||||
fn handle_save_file(&mut self) {
|
||||
if let Some(path) = &self.state.current_file {
|
||||
match std::fs::write(path, &self.state.editor_content) {
|
||||
Ok(()) => {
|
||||
self.state.log_success(format!("Saved: {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.log_error(format!("Failed to save: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Save As
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Geolog", &["geolog"])
|
||||
.save_file()
|
||||
{
|
||||
match std::fs::write(&path, &self.state.editor_content) {
|
||||
Ok(()) => {
|
||||
self.state.current_file = Some(path.clone());
|
||||
self.state.log_success(format!("Saved: {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.log_error(format!("Failed to save: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run chase on an instance
|
||||
fn handle_run_chase(&mut self, instance_name: &str) {
|
||||
use crate::core::RelationStorage;
|
||||
use crate::query::chase::chase_fixpoint;
|
||||
|
||||
// Get theory name first (immutable borrow)
|
||||
let theory_name = match self.state.repl.instances.get(instance_name) {
|
||||
Some(e) => e.theory_name.clone(),
|
||||
None => {
|
||||
self.state
|
||||
.log_error(format!("Instance '{}' not found", instance_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let theory = match self.state.repl.theories.get(&theory_name) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
self.state
|
||||
.log_error(format!("Theory '{}' not found", theory_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let sig = &theory.theory.signature;
|
||||
let axioms = &theory.theory.axioms;
|
||||
|
||||
if axioms.is_empty() {
|
||||
self.state
|
||||
.log_warning(format!("Theory '{}' has no axioms to chase", theory_name));
|
||||
return;
|
||||
}
|
||||
|
||||
self.state.log_info(format!(
|
||||
"Running chase on instance '{}' ({} axioms)...",
|
||||
instance_name,
|
||||
axioms.len()
|
||||
));
|
||||
|
||||
// Now get mutable access to entry
|
||||
let entry = self.state.repl.instances.get_mut(instance_name).unwrap();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = chase_fixpoint(
|
||||
axioms,
|
||||
&mut entry.structure,
|
||||
&mut self.state.repl.store.universe,
|
||||
sig,
|
||||
100,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(iterations) => {
|
||||
let elapsed = start.elapsed();
|
||||
// Get structure info before releasing borrow
|
||||
let total_tuples: usize =
|
||||
entry.structure.relations.iter().map(|r| r.len()).sum();
|
||||
let num_elements = entry.structure.len();
|
||||
|
||||
self.state.log_success(format!(
|
||||
"Chase completed in {} iterations ({:.2}ms)",
|
||||
iterations,
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
));
|
||||
|
||||
// Show structure summary
|
||||
self.state.log_info(format!(
|
||||
"Structure: {} elements, {} relation tuples",
|
||||
num_elements,
|
||||
total_tuples
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.log_error(format!("Chase error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show syntax help in the console
|
||||
fn show_syntax_help(&mut self) {
|
||||
self.state.log_info("=== Geolog Syntax ===");
|
||||
self.state.log_info("");
|
||||
self.state.log_info("theory Name {");
|
||||
self.state.log_info(" Sort1 : Sort;");
|
||||
self.state.log_info(" func : Sort1 -> Sort2;");
|
||||
self.state.log_info(" rel : Sort1 -> Prop;");
|
||||
self.state.log_info("}");
|
||||
self.state.log_info("");
|
||||
self.state.log_info("instance Name : Theory = {");
|
||||
self.state.log_info(" elem : Sort1;");
|
||||
self.state.log_info(" elem func = other_elem;");
|
||||
self.state.log_info(" elem rel;");
|
||||
self.state.log_info("}");
|
||||
}
|
||||
|
||||
/// Render the central panel based on current view mode
|
||||
fn central_panel(&mut self, ui: &mut egui::Ui) {
|
||||
match self.state.central_view {
|
||||
CentralView::EditorAndInspector => {
|
||||
// Split vertically: editor on top, inspector on bottom
|
||||
let available_height = ui.available_height();
|
||||
let editor_height = available_height * 0.5;
|
||||
|
||||
// Editor section
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), editor_height),
|
||||
egui::Layout::top_down(egui::Align::LEFT),
|
||||
|ui| {
|
||||
self.editor.show(ui, &mut self.state);
|
||||
},
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Inspector section
|
||||
self.inspector.show(ui, &mut self.state);
|
||||
}
|
||||
CentralView::ChaseVisualization => {
|
||||
self.chase_viz.show(ui, &mut self.state);
|
||||
}
|
||||
CentralView::GraphVisualization => {
|
||||
self.graph_view.show(ui, &mut self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for GeologApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Top menu bar
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
self.menu_bar(ui);
|
||||
});
|
||||
|
||||
// Left panel: Browser
|
||||
egui::SidePanel::left("browser_panel")
|
||||
.default_width(200.0)
|
||||
.min_width(150.0)
|
||||
.resizable(true)
|
||||
.show(ctx, |ui| {
|
||||
self.browser.show(ui, &mut self.state);
|
||||
});
|
||||
|
||||
// Bottom panel: Console
|
||||
egui::TopBottomPanel::bottom("console_panel")
|
||||
.default_height(150.0)
|
||||
.min_height(100.0)
|
||||
.resizable(true)
|
||||
.show(ctx, |ui| {
|
||||
self.console.show(ui, &mut self.state);
|
||||
});
|
||||
|
||||
// Central panel: Editor + Inspector or Visualizations
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
self.central_panel(ui);
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/gui/mod.rs
Normal file
15
src/gui/mod.rs
Normal file
@ -0,0 +1,15 @@
|
||||
//! GUI module for Geolog using egui
|
||||
//!
|
||||
//! Provides a graphical interface for:
|
||||
//! - Browsing theories and instances
|
||||
//! - Editing geolog code
|
||||
//! - Visualizing chase execution
|
||||
//! - Displaying relation graphs
|
||||
|
||||
pub mod app;
|
||||
pub mod panels;
|
||||
pub mod state;
|
||||
pub mod visualizations;
|
||||
|
||||
pub use app::GeologApp;
|
||||
pub use state::GuiState;
|
||||
178
src/gui/panels/browser.rs
Normal file
178
src/gui/panels/browser.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! Browser panel for theory/instance navigation
|
||||
//!
|
||||
//! Displays a tree view of all theories and instances.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::gui::state::{GuiState, SelectedItem};
|
||||
|
||||
/// Browser panel for navigating theories and instances
|
||||
pub struct BrowserPanel {
|
||||
/// Search/filter text
|
||||
filter: String,
|
||||
}
|
||||
|
||||
impl BrowserPanel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
filter: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
ui.heading("Browser");
|
||||
ui.separator();
|
||||
|
||||
// Filter input
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut self.filter);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Scrollable tree view
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
self.show_theories_section(ui, state);
|
||||
ui.add_space(8.0);
|
||||
self.show_instances_section(ui, state);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_theories_section(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
let theories = state.repl.list_theories();
|
||||
let filtered: Vec<_> = theories
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
self.filter.is_empty()
|
||||
|| t.name.to_lowercase().contains(&self.filter.to_lowercase())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let header = if self.filter.is_empty() {
|
||||
format!("Theories ({})", theories.len())
|
||||
} else {
|
||||
format!("Theories ({}/{})", filtered.len(), theories.len())
|
||||
};
|
||||
|
||||
egui::CollapsingHeader::new(header)
|
||||
.default_open(state.theories_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.theories_expanded = true;
|
||||
if filtered.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for theory in filtered {
|
||||
let is_selected = matches!(
|
||||
&state.selected_item,
|
||||
Some(SelectedItem::Theory(name)) if name == &theory.name
|
||||
);
|
||||
|
||||
let label = format!(
|
||||
"{} ({} sorts{}{}{})",
|
||||
theory.name,
|
||||
theory.num_sorts,
|
||||
if theory.num_functions > 0 {
|
||||
format!(", {} funcs", theory.num_functions)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if theory.num_relations > 0 {
|
||||
format!(", {} rels", theory.num_relations)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if theory.num_axioms > 0 {
|
||||
format!(", {} axioms", theory.num_axioms)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
);
|
||||
|
||||
if ui.selectable_label(is_selected, label).clicked() {
|
||||
state.selected_item = Some(SelectedItem::Theory(theory.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_instances_section(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
let instances = state.repl.list_instances();
|
||||
let filtered: Vec<_> = instances
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
self.filter.is_empty()
|
||||
|| i.name.to_lowercase().contains(&self.filter.to_lowercase())
|
||||
|| i.theory_name
|
||||
.to_lowercase()
|
||||
.contains(&self.filter.to_lowercase())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let header = if self.filter.is_empty() {
|
||||
format!("Instances ({})", instances.len())
|
||||
} else {
|
||||
format!("Instances ({}/{})", filtered.len(), instances.len())
|
||||
};
|
||||
|
||||
egui::CollapsingHeader::new(header)
|
||||
.default_open(state.instances_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.instances_expanded = true;
|
||||
if filtered.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for instance in filtered {
|
||||
let is_selected = matches!(
|
||||
&state.selected_item,
|
||||
Some(SelectedItem::Instance(name)) if name == &instance.name
|
||||
);
|
||||
|
||||
let label = format!(
|
||||
"{} : {} ({} elems)",
|
||||
instance.name, instance.theory_name, instance.num_elements
|
||||
);
|
||||
|
||||
let response = ui.selectable_label(is_selected, label);
|
||||
|
||||
// Left click to select
|
||||
if response.clicked() {
|
||||
state.selected_item =
|
||||
Some(SelectedItem::Instance(instance.name.clone()));
|
||||
}
|
||||
|
||||
// Right-click context menu
|
||||
response.context_menu(|ui| {
|
||||
if ui.button("Inspect").clicked() {
|
||||
state.selected_item =
|
||||
Some(SelectedItem::Instance(instance.name.clone()));
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Run Chase").clicked() {
|
||||
// This will be handled by the app
|
||||
state.selected_item =
|
||||
Some(SelectedItem::Instance(instance.name.clone()));
|
||||
state.log_info(format!(
|
||||
"Use Chase menu to run chase on '{}'",
|
||||
instance.name
|
||||
));
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("View Graph").clicked() {
|
||||
state.show_graph(&instance.name, None);
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrowserPanel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
84
src/gui/panels/console.rs
Normal file
84
src/gui/panels/console.rs
Normal file
@ -0,0 +1,84 @@
|
||||
//! Console panel for displaying output and error messages
|
||||
//!
|
||||
//! Shows a scrollable log of messages with color coding.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::gui::state::{GuiState, MessageKind};
|
||||
|
||||
/// Console panel for output messages
|
||||
pub struct ConsolePanel {
|
||||
/// Whether to auto-scroll to bottom
|
||||
auto_scroll: bool,
|
||||
}
|
||||
|
||||
impl ConsolePanel {
|
||||
pub fn new() -> Self {
|
||||
Self { auto_scroll: true }
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Header with title and buttons
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Console");
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Clear").clicked() {
|
||||
state.clear_console();
|
||||
}
|
||||
|
||||
ui.checkbox(&mut self.auto_scroll, "Auto-scroll");
|
||||
|
||||
ui.label(format!("{} messages", state.console_messages.len()));
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Scrollable message area
|
||||
let scroll_area = egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.stick_to_bottom(self.auto_scroll);
|
||||
|
||||
scroll_area.show(ui, |ui| {
|
||||
if state.console_messages.is_empty() {
|
||||
ui.label("(no messages)");
|
||||
} else {
|
||||
for message in &state.console_messages {
|
||||
let color = match message.kind {
|
||||
MessageKind::Info => egui::Color32::LIGHT_GRAY,
|
||||
MessageKind::Success => egui::Color32::from_rgb(100, 200, 100),
|
||||
MessageKind::Error => egui::Color32::from_rgb(255, 100, 100),
|
||||
MessageKind::Warning => egui::Color32::from_rgb(255, 200, 100),
|
||||
};
|
||||
|
||||
let prefix = match message.kind {
|
||||
MessageKind::Info => ">",
|
||||
MessageKind::Success => "+",
|
||||
MessageKind::Error => "!",
|
||||
MessageKind::Warning => "?",
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(prefix)
|
||||
.color(color)
|
||||
.monospace(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(&message.text)
|
||||
.color(color)
|
||||
.monospace(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConsolePanel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
216
src/gui/panels/editor.rs
Normal file
216
src/gui/panels/editor.rs
Normal file
@ -0,0 +1,216 @@
|
||||
//! Code editor panel
|
||||
//!
|
||||
//! Provides a multi-line text editor for writing Geolog code.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::gui::state::GuiState;
|
||||
|
||||
/// Code editor panel
|
||||
pub struct EditorPanel {
|
||||
/// Whether to show line numbers
|
||||
show_line_numbers: bool,
|
||||
}
|
||||
|
||||
impl EditorPanel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
show_line_numbers: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Header with title and buttons
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Editor");
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Clear").clicked() {
|
||||
state.editor_content.clear();
|
||||
}
|
||||
|
||||
if ui.button("Run").clicked() {
|
||||
state.execute_editor();
|
||||
}
|
||||
|
||||
// Show current file if any
|
||||
if let Some(path) = &state.current_file {
|
||||
ui.label(format!(
|
||||
"{}",
|
||||
path.file_name()
|
||||
.map(|s| s.to_string_lossy())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Editor area with syntax highlighting
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
self.show_editor_content(ui, state);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_editor_content(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Use a monospace font for code
|
||||
let font_id = egui::FontId::monospace(14.0);
|
||||
|
||||
// Calculate line numbers if needed
|
||||
let line_count = state.editor_content.lines().count().max(1);
|
||||
let line_number_width = if self.show_line_numbers {
|
||||
let digits = (line_count as f32).log10().floor() as usize + 1;
|
||||
digits.max(2) as f32 * 10.0 + 8.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Line numbers column
|
||||
if self.show_line_numbers {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(line_number_width, ui.available_height()),
|
||||
egui::Layout::top_down(egui::Align::RIGHT),
|
||||
|ui| {
|
||||
ui.style_mut().visuals.override_text_color =
|
||||
Some(egui::Color32::GRAY);
|
||||
|
||||
for i in 1..=line_count {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{}", i))
|
||||
.font(font_id.clone())
|
||||
.color(egui::Color32::GRAY),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
// Main editor area
|
||||
let text_edit = egui::TextEdit::multiline(&mut state.editor_content)
|
||||
.font(font_id)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(20)
|
||||
.lock_focus(true);
|
||||
|
||||
let response = ui.add(text_edit);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
if response.has_focus() {
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
if modifiers.ctrl || modifiers.command {
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
state.execute_editor();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditorPanel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple syntax highlighting for Geolog code
|
||||
/// Returns a layouter function for egui's code_editor
|
||||
#[allow(dead_code)]
|
||||
fn geolog_highlighter(
|
||||
_ui: &egui::Ui,
|
||||
text: &str,
|
||||
_wrap_width: f32,
|
||||
) -> egui::text::LayoutJob {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
|
||||
let keywords = [
|
||||
"theory", "instance", "query", "forall", "exists", "Sort", "Prop", "true", "false",
|
||||
];
|
||||
|
||||
let font_id = egui::FontId::monospace(14.0);
|
||||
let default_color = egui::Color32::WHITE;
|
||||
let keyword_color = egui::Color32::from_rgb(86, 156, 214); // Blue
|
||||
let comment_color = egui::Color32::from_rgb(106, 153, 85); // Green
|
||||
let string_color = egui::Color32::from_rgb(206, 145, 120); // Orange
|
||||
|
||||
let mut chars = text.char_indices().peekable();
|
||||
|
||||
while let Some((i, c)) = chars.next() {
|
||||
// Check for comments
|
||||
if c == '/' && chars.peek().map(|(_, c)| *c) == Some('/') {
|
||||
// Line comment - consume until end of line
|
||||
let start = i;
|
||||
while let Some((_, c)) = chars.next() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let end = chars.peek().map(|(i, _)| *i).unwrap_or(text.len());
|
||||
job.append(
|
||||
&text[start..end],
|
||||
0.0,
|
||||
egui::TextFormat::simple(font_id.clone(), comment_color),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for strings
|
||||
if c == '"' {
|
||||
let start = i;
|
||||
while let Some((_, c)) = chars.next() {
|
||||
if c == '"' {
|
||||
break;
|
||||
}
|
||||
if c == '\\' {
|
||||
chars.next(); // Skip escaped character
|
||||
}
|
||||
}
|
||||
let end = chars.peek().map(|(i, _)| *i).unwrap_or(text.len());
|
||||
job.append(
|
||||
&text[start..end],
|
||||
0.0,
|
||||
egui::TextFormat::simple(font_id.clone(), string_color),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for identifiers/keywords
|
||||
if c.is_alphabetic() || c == '_' {
|
||||
let start = i;
|
||||
while chars.peek().map(|(_, c)| c.is_alphanumeric() || *c == '_') == Some(true) {
|
||||
chars.next();
|
||||
}
|
||||
let end = chars.peek().map(|(i, _)| *i).unwrap_or(text.len());
|
||||
let word = &text[start..end];
|
||||
|
||||
let color = if keywords.contains(&word) {
|
||||
keyword_color
|
||||
} else {
|
||||
default_color
|
||||
};
|
||||
|
||||
job.append(
|
||||
word,
|
||||
0.0,
|
||||
egui::TextFormat::simple(font_id.clone(), color),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: single character
|
||||
job.append(
|
||||
&text[i..i + c.len_utf8()],
|
||||
0.0,
|
||||
egui::TextFormat::simple(font_id.clone(), default_color),
|
||||
);
|
||||
}
|
||||
|
||||
job
|
||||
}
|
||||
318
src/gui/panels/inspector.rs
Normal file
318
src/gui/panels/inspector.rs
Normal file
@ -0,0 +1,318 @@
|
||||
//! Inspector panel for viewing theory/instance details
|
||||
//!
|
||||
//! Shows detailed information about the currently selected item.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::gui::state::{GuiState, SelectedItem};
|
||||
use crate::repl::{InstanceDetail, TheoryDetail};
|
||||
|
||||
/// Inspector panel for viewing details
|
||||
pub struct InspectorPanel {}
|
||||
|
||||
impl InspectorPanel {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
ui.heading("Inspector");
|
||||
ui.separator();
|
||||
|
||||
match &state.selected_item {
|
||||
Some(SelectedItem::Theory(name)) => {
|
||||
if let Some(detail) = state.selected_theory_detail() {
|
||||
self.show_theory_detail(ui, state, &detail);
|
||||
} else {
|
||||
ui.label(format!("Theory '{}' not found", name));
|
||||
}
|
||||
}
|
||||
Some(SelectedItem::Instance(name)) => {
|
||||
if let Some(detail) = state.selected_instance_detail() {
|
||||
self.show_instance_detail(ui, state, &detail);
|
||||
} else {
|
||||
ui.label(format!("Instance '{}' not found", name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("Select a theory or instance to inspect");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_theory_detail(&self, ui: &mut egui::Ui, state: &mut GuiState, detail: &TheoryDetail) {
|
||||
// Theory header
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong("Theory:");
|
||||
ui.label(&detail.name);
|
||||
});
|
||||
|
||||
// Parameters
|
||||
if !detail.params.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Parameters:");
|
||||
for (name, theory) in &detail.params {
|
||||
ui.label(format!("{}: {}", name, theory));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
// Sorts section
|
||||
egui::CollapsingHeader::new(format!("Sorts ({})", detail.sorts.len()))
|
||||
.default_open(state.inspector_sorts_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.inspector_sorts_expanded = true;
|
||||
if detail.sorts.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for sort in &detail.sorts {
|
||||
ui.label(format!(" {} : Sort", sort));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Functions section
|
||||
egui::CollapsingHeader::new(format!("Functions ({})", detail.functions.len()))
|
||||
.default_open(state.inspector_functions_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.inspector_functions_expanded = true;
|
||||
if detail.functions.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for (name, domain, codomain) in &detail.functions {
|
||||
ui.label(format!(" {} : {} -> {}", name, domain, codomain));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Relations section
|
||||
egui::CollapsingHeader::new(format!("Relations ({})", detail.relations.len()))
|
||||
.default_open(state.inspector_relations_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.inspector_relations_expanded = true;
|
||||
if detail.relations.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for (name, domain) in &detail.relations {
|
||||
ui.label(format!(" {} : {} -> Prop", name, domain));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Instance fields section
|
||||
if !detail.instance_fields.is_empty() {
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"Instance Fields ({})",
|
||||
detail.instance_fields.len()
|
||||
))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
for (name, theory_type) in &detail.instance_fields {
|
||||
ui.label(format!(" {} : {} instance", name, theory_type));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Axioms section
|
||||
egui::CollapsingHeader::new(format!("Axioms ({})", detail.axioms.len()))
|
||||
.default_open(state.inspector_axioms_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.inspector_axioms_expanded = true;
|
||||
if detail.axioms.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for (i, axiom) in detail.axioms.iter().enumerate() {
|
||||
let context_str: Vec<String> = axiom
|
||||
.context
|
||||
.iter()
|
||||
.map(|(name, sort)| format!("{}: {}", name, sort))
|
||||
.collect();
|
||||
|
||||
let axiom_str = if axiom.premise == "true" {
|
||||
format!(
|
||||
"[{}] forall {}. |- {}",
|
||||
i,
|
||||
context_str.join(", "),
|
||||
axiom.conclusion
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[{}] forall {}. {} |- {}",
|
||||
i,
|
||||
context_str.join(", "),
|
||||
axiom.premise,
|
||||
axiom.conclusion
|
||||
)
|
||||
};
|
||||
ui.label(axiom_str);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn show_instance_detail(
|
||||
&self,
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut GuiState,
|
||||
detail: &InstanceDetail,
|
||||
) {
|
||||
// Instance header
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong("Instance:");
|
||||
ui.label(&detail.name);
|
||||
ui.label(":");
|
||||
ui.label(&detail.theory_name);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Action buttons
|
||||
ui.horizontal(|ui| {
|
||||
let detail_name = detail.name.clone();
|
||||
if ui.button("View Graph").clicked() {
|
||||
state.show_graph(&detail_name, None);
|
||||
}
|
||||
if ui.button("Run Chase").clicked() {
|
||||
state.log_info(format!("Use Chase menu to run chase on '{}'", detail_name));
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
// Elements section
|
||||
let total_elements: usize = detail.elements.iter().map(|(_, v)| v.len()).sum();
|
||||
egui::CollapsingHeader::new(format!("Elements ({})", total_elements))
|
||||
.default_open(state.inspector_elements_expanded)
|
||||
.show(ui, |ui| {
|
||||
state.inspector_elements_expanded = true;
|
||||
if detail.elements.is_empty() {
|
||||
ui.label("(none)");
|
||||
} else {
|
||||
for (sort_name, elements) in &detail.elements {
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong(format!("{} ({}):", sort_name, elements.len()));
|
||||
});
|
||||
ui.indent("elements", |ui| {
|
||||
// Show elements in a grid if there are many
|
||||
if elements.len() <= 10 {
|
||||
for elem in elements {
|
||||
ui.label(format!(" {}", elem));
|
||||
}
|
||||
} else {
|
||||
// Show first few and a count
|
||||
for elem in elements.iter().take(5) {
|
||||
ui.label(format!(" {}", elem));
|
||||
}
|
||||
ui.label(format!(" ... and {} more", elements.len() - 5));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Functions section
|
||||
let total_func_values: usize = detail.functions.iter().map(|(_, v)| v.len()).sum();
|
||||
if total_func_values > 0 {
|
||||
egui::CollapsingHeader::new(format!("Function Values ({})", total_func_values))
|
||||
.default_open(state.inspector_functions_expanded)
|
||||
.show(ui, |ui| {
|
||||
for (func_name, values) in &detail.functions {
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong(format!("{} ({}):", func_name, values.len()));
|
||||
});
|
||||
ui.indent("func_values", |ui| {
|
||||
if values.len() <= 10 {
|
||||
for value in values {
|
||||
ui.label(format!(" {}", value));
|
||||
}
|
||||
} else {
|
||||
for value in values.iter().take(5) {
|
||||
ui.label(format!(" {}", value));
|
||||
}
|
||||
ui.label(format!(" ... and {} more", values.len() - 5));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Relations section
|
||||
let total_tuples: usize = detail.relations.iter().map(|(_, _, t)| t.len()).sum();
|
||||
if total_tuples > 0 {
|
||||
egui::CollapsingHeader::new(format!("Relations ({} tuples)", total_tuples))
|
||||
.default_open(state.inspector_relations_expanded)
|
||||
.show(ui, |ui| {
|
||||
for (rel_name, field_names, tuples) in &detail.relations {
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong(format!("{} ({} tuples):", rel_name, tuples.len()));
|
||||
|
||||
// Add button to view as graph
|
||||
let detail_name = detail.name.clone();
|
||||
let rel_name_clone = rel_name.clone();
|
||||
if ui.small_button("View Graph").clicked() {
|
||||
state.show_graph(&detail_name, Some(&rel_name_clone));
|
||||
}
|
||||
});
|
||||
|
||||
ui.indent("rel_tuples", |ui| {
|
||||
let display_tuples = if tuples.len() <= 10 {
|
||||
tuples.as_slice()
|
||||
} else {
|
||||
&tuples[..5]
|
||||
};
|
||||
|
||||
for tuple in display_tuples {
|
||||
let tuple_str = if field_names.is_empty() {
|
||||
// Unary relation
|
||||
format!(" {} {}", tuple.join(", "), rel_name)
|
||||
} else {
|
||||
// Multi-ary relation with field names
|
||||
let fields: Vec<String> = field_names
|
||||
.iter()
|
||||
.zip(tuple.iter())
|
||||
.map(|(f, v)| format!("{}: {}", f, v))
|
||||
.collect();
|
||||
format!(" [{}] {}", fields.join(", "), rel_name)
|
||||
};
|
||||
ui.label(tuple_str);
|
||||
}
|
||||
|
||||
if tuples.len() > 10 {
|
||||
ui.label(format!(" ... and {} more", tuples.len() - 5));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Nested instances section
|
||||
if !detail.nested.is_empty() {
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"Nested Instances ({})",
|
||||
detail.nested.len()
|
||||
))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
for (field_name, elem_count) in &detail.nested {
|
||||
ui.label(format!(" {} ({} elements)", field_name, elem_count));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InspectorPanel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
11
src/gui/panels/mod.rs
Normal file
11
src/gui/panels/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! GUI panels for the Geolog application
|
||||
|
||||
pub mod browser;
|
||||
pub mod console;
|
||||
pub mod editor;
|
||||
pub mod inspector;
|
||||
|
||||
pub use browser::BrowserPanel;
|
||||
pub use console::ConsolePanel;
|
||||
pub use editor::EditorPanel;
|
||||
pub use inspector::InspectorPanel;
|
||||
321
src/gui/state.rs
Normal file
321
src/gui/state.rs
Normal file
@ -0,0 +1,321 @@
|
||||
//! GUI state management
|
||||
//!
|
||||
//! Wraps ReplState with GUI-specific state for the egui application.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::repl::{InstanceDetail, ReplState, TheoryDetail};
|
||||
|
||||
/// What item is currently selected in the browser
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SelectedItem {
|
||||
Theory(String),
|
||||
Instance(String),
|
||||
}
|
||||
|
||||
/// A message displayed in the console
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsoleMessage {
|
||||
pub text: String,
|
||||
pub kind: MessageKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MessageKind {
|
||||
Info,
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
/// A recorded chase event for visualization
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChaseEventRecord {
|
||||
pub iteration: usize,
|
||||
pub axiom_index: usize,
|
||||
pub axiom_name: Option<String>,
|
||||
pub binding: Vec<(String, String)>, // (var_name, element_name)
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Chase execution mode
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChaseMode {
|
||||
/// Not running
|
||||
Idle,
|
||||
/// Stepping through events
|
||||
Stepping,
|
||||
/// Running continuously
|
||||
Running,
|
||||
/// Chase completed
|
||||
Completed,
|
||||
}
|
||||
|
||||
/// State for chase visualization
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChaseVisualizationState {
|
||||
pub mode: ChaseMode,
|
||||
pub instance_name: String,
|
||||
pub events: Vec<ChaseEventRecord>,
|
||||
pub current_event: usize,
|
||||
pub current_iteration: usize,
|
||||
pub breakpoints: HashSet<String>,
|
||||
pub total_iterations: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChaseVisualizationState {
|
||||
pub fn new(instance_name: String) -> Self {
|
||||
Self {
|
||||
mode: ChaseMode::Idle,
|
||||
instance_name,
|
||||
events: Vec::new(),
|
||||
current_event: 0,
|
||||
current_iteration: 0,
|
||||
breakpoints: HashSet::new(),
|
||||
total_iterations: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_event(&mut self, event: ChaseEventRecord) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn step(&mut self) {
|
||||
if self.current_event < self.events.len().saturating_sub(1) {
|
||||
self.current_event += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_next(&self) -> bool {
|
||||
self.current_event < self.events.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Central view mode
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum CentralView {
|
||||
#[default]
|
||||
EditorAndInspector,
|
||||
ChaseVisualization,
|
||||
GraphVisualization,
|
||||
}
|
||||
|
||||
/// GUI state wrapping ReplState
|
||||
pub struct GuiState {
|
||||
/// The underlying REPL state (theories, instances, store)
|
||||
pub repl: ReplState,
|
||||
|
||||
/// Currently selected item in the browser
|
||||
pub selected_item: Option<SelectedItem>,
|
||||
|
||||
/// Content of the code editor
|
||||
pub editor_content: String,
|
||||
|
||||
/// Console output messages
|
||||
pub console_messages: Vec<ConsoleMessage>,
|
||||
|
||||
/// Chase visualization state (if running)
|
||||
pub chase_state: Option<ChaseVisualizationState>,
|
||||
|
||||
/// Graph view state
|
||||
pub graph_instance: Option<String>,
|
||||
pub graph_relation: Option<String>,
|
||||
|
||||
/// Currently loaded file path (if any)
|
||||
pub current_file: Option<PathBuf>,
|
||||
|
||||
/// Current view mode for the central panel
|
||||
pub central_view: CentralView,
|
||||
|
||||
/// Whether the browser tree sections are expanded
|
||||
pub theories_expanded: bool,
|
||||
pub instances_expanded: bool,
|
||||
|
||||
/// Inspector collapsible sections
|
||||
pub inspector_sorts_expanded: bool,
|
||||
pub inspector_functions_expanded: bool,
|
||||
pub inspector_relations_expanded: bool,
|
||||
pub inspector_axioms_expanded: bool,
|
||||
pub inspector_elements_expanded: bool,
|
||||
}
|
||||
|
||||
impl Default for GuiState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GuiState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
repl: ReplState::new(),
|
||||
selected_item: None,
|
||||
editor_content: String::new(),
|
||||
console_messages: Vec::new(),
|
||||
chase_state: None,
|
||||
graph_instance: None,
|
||||
graph_relation: None,
|
||||
current_file: None,
|
||||
central_view: CentralView::EditorAndInspector,
|
||||
theories_expanded: true,
|
||||
instances_expanded: true,
|
||||
inspector_sorts_expanded: true,
|
||||
inspector_functions_expanded: true,
|
||||
inspector_relations_expanded: true,
|
||||
inspector_axioms_expanded: true,
|
||||
inspector_elements_expanded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new GUI state with a workspace path for persistence
|
||||
pub fn with_path(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
repl: ReplState::with_path(path),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an info message to the console
|
||||
pub fn log_info(&mut self, text: impl Into<String>) {
|
||||
self.console_messages.push(ConsoleMessage {
|
||||
text: text.into(),
|
||||
kind: MessageKind::Info,
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a success message to the console
|
||||
pub fn log_success(&mut self, text: impl Into<String>) {
|
||||
self.console_messages.push(ConsoleMessage {
|
||||
text: text.into(),
|
||||
kind: MessageKind::Success,
|
||||
});
|
||||
}
|
||||
|
||||
/// Add an error message to the console
|
||||
pub fn log_error(&mut self, text: impl Into<String>) {
|
||||
self.console_messages.push(ConsoleMessage {
|
||||
text: text.into(),
|
||||
kind: MessageKind::Error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a warning message to the console
|
||||
pub fn log_warning(&mut self, text: impl Into<String>) {
|
||||
self.console_messages.push(ConsoleMessage {
|
||||
text: text.into(),
|
||||
kind: MessageKind::Warning,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear all console messages
|
||||
pub fn clear_console(&mut self) {
|
||||
self.console_messages.clear();
|
||||
}
|
||||
|
||||
/// Execute the editor content as geolog code
|
||||
pub fn execute_editor(&mut self) {
|
||||
let source = self.editor_content.clone();
|
||||
if source.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.repl.execute_geolog(&source) {
|
||||
Ok(results) => {
|
||||
for result in results {
|
||||
match result {
|
||||
crate::repl::ExecuteResult::Namespace(name) => {
|
||||
self.log_info(format!("Namespace: {}", name));
|
||||
}
|
||||
crate::repl::ExecuteResult::Theory {
|
||||
name,
|
||||
num_sorts,
|
||||
num_functions,
|
||||
num_relations,
|
||||
num_axioms,
|
||||
} => {
|
||||
let mut parts = vec![format!("{} sorts", num_sorts)];
|
||||
if num_functions > 0 {
|
||||
parts.push(format!("{} functions", num_functions));
|
||||
}
|
||||
if num_relations > 0 {
|
||||
parts.push(format!("{} relations", num_relations));
|
||||
}
|
||||
if num_axioms > 0 {
|
||||
parts.push(format!("{} axioms", num_axioms));
|
||||
}
|
||||
self.log_success(format!(
|
||||
"Defined theory {} ({})",
|
||||
name,
|
||||
parts.join(", ")
|
||||
));
|
||||
}
|
||||
crate::repl::ExecuteResult::Instance {
|
||||
name,
|
||||
theory_name,
|
||||
num_elements,
|
||||
} => {
|
||||
self.log_success(format!(
|
||||
"Defined instance {} : {} ({} elements)",
|
||||
name, theory_name, num_elements
|
||||
));
|
||||
}
|
||||
crate::repl::ExecuteResult::Query(_result) => {
|
||||
self.log_info("Query executed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.log_error(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a file into the editor
|
||||
pub fn load_file(&mut self, path: &PathBuf) {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
self.editor_content = content;
|
||||
self.current_file = Some(path.clone());
|
||||
self.log_info(format!("Loaded: {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.log_error(format!("Failed to load {}: {}", path.display(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected theory detail
|
||||
pub fn selected_theory_detail(&self) -> Option<TheoryDetail> {
|
||||
if let Some(SelectedItem::Theory(name)) = &self.selected_item {
|
||||
if let Some(crate::repl::InspectResult::Theory(detail)) = self.repl.inspect(name) {
|
||||
return Some(detail);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the currently selected instance detail
|
||||
pub fn selected_instance_detail(&self) -> Option<InstanceDetail> {
|
||||
if let Some(SelectedItem::Instance(name)) = &self.selected_item {
|
||||
if let Some(crate::repl::InspectResult::Instance(detail)) = self.repl.inspect(name) {
|
||||
return Some(detail);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Start a chase visualization for an instance
|
||||
pub fn start_chase(&mut self, instance_name: &str) {
|
||||
self.chase_state = Some(ChaseVisualizationState::new(instance_name.to_string()));
|
||||
self.central_view = CentralView::ChaseVisualization;
|
||||
}
|
||||
|
||||
/// Start graph visualization for an instance and relation
|
||||
pub fn show_graph(&mut self, instance_name: &str, relation_name: Option<&str>) {
|
||||
self.graph_instance = Some(instance_name.to_string());
|
||||
self.graph_relation = relation_name.map(|s| s.to_string());
|
||||
self.central_view = CentralView::GraphVisualization;
|
||||
}
|
||||
}
|
||||
497
src/gui/visualizations/chase.rs
Normal file
497
src/gui/visualizations/chase.rs
Normal file
@ -0,0 +1,497 @@
|
||||
//! Chase algorithm visualization
|
||||
//!
|
||||
//! Provides step-through debugging and visualization of the chase algorithm.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::core::RelationStorage;
|
||||
use crate::gui::state::{ChaseEventRecord, ChaseMode, ChaseVisualizationState, CentralView, GuiState, SelectedItem};
|
||||
use crate::query::chase::{ChaseControl, ChaseEvent, ChaseObserver};
|
||||
|
||||
/// Chase visualization panel
|
||||
pub struct ChaseVisualization {
|
||||
/// Breakpoint being added
|
||||
new_breakpoint: String,
|
||||
}
|
||||
|
||||
impl ChaseVisualization {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
new_breakpoint: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Header
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Chase Visualization");
|
||||
|
||||
if let Some(chase_state) = &state.chase_state {
|
||||
ui.label(format!("Instance: {}", chase_state.instance_name));
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if state.chase_state.is_none() {
|
||||
self.show_start_panel(ui, state);
|
||||
} else {
|
||||
self.show_chase_panel(ui, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_start_panel(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
ui.label("Select an instance to start chase visualization:");
|
||||
ui.add_space(8.0);
|
||||
|
||||
let instances = state.repl.list_instances();
|
||||
if instances.is_empty() {
|
||||
ui.label("No instances defined. Create an instance first.");
|
||||
} else {
|
||||
for instance in instances {
|
||||
if ui.button(format!("{} : {}", instance.name, instance.theory_name)).clicked() {
|
||||
state.start_chase(&instance.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or use currently selected instance
|
||||
if let Some(SelectedItem::Instance(name)) = &state.selected_item {
|
||||
ui.add_space(16.0);
|
||||
let name = name.clone();
|
||||
if ui.button(format!("Start Chase on selected: {}", name)).clicked() {
|
||||
state.start_chase(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_chase_panel(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Read mode and instance_name before mutable operations
|
||||
let (mode, instance_name) = {
|
||||
let chase_state = state.chase_state.as_ref().unwrap();
|
||||
(chase_state.mode, chase_state.instance_name.clone())
|
||||
};
|
||||
|
||||
// Control buttons
|
||||
let mut step_clicked = false;
|
||||
let mut run_clicked = false;
|
||||
let mut stop_clicked = false;
|
||||
let mut reset_clicked = false;
|
||||
let mut close_clicked = false;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Mode indicator
|
||||
let mode_text = match mode {
|
||||
ChaseMode::Idle => "Idle",
|
||||
ChaseMode::Stepping => "Stepping",
|
||||
ChaseMode::Running => "Running",
|
||||
ChaseMode::Completed => "Completed",
|
||||
};
|
||||
ui.label(format!("Mode: {}", mode_text));
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Only show Run/Step if not completed
|
||||
if mode != ChaseMode::Completed {
|
||||
if ui.button("Step").clicked() {
|
||||
step_clicked = true;
|
||||
}
|
||||
|
||||
if ui.button("Run All").clicked() {
|
||||
run_clicked = true;
|
||||
}
|
||||
|
||||
if ui.button("Stop").clicked() {
|
||||
stop_clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset button
|
||||
if ui.button("Reset").clicked() {
|
||||
reset_clicked = true;
|
||||
}
|
||||
|
||||
// Close button
|
||||
if ui.button("Close").clicked() {
|
||||
close_clicked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle button actions
|
||||
if step_clicked {
|
||||
self.step_chase(state);
|
||||
}
|
||||
if run_clicked {
|
||||
self.run_chase_to_completion(state);
|
||||
}
|
||||
if stop_clicked {
|
||||
if let Some(chase_state) = &mut state.chase_state {
|
||||
chase_state.mode = ChaseMode::Completed;
|
||||
}
|
||||
}
|
||||
if reset_clicked {
|
||||
state.chase_state = Some(ChaseVisualizationState::new(instance_name));
|
||||
}
|
||||
if close_clicked {
|
||||
state.chase_state = None;
|
||||
state.central_view = CentralView::EditorAndInspector;
|
||||
return;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Main content area split into events and info
|
||||
ui.columns(2, |columns| {
|
||||
// Left column: Events
|
||||
columns[0].heading("Events");
|
||||
self.show_events_panel(&mut columns[0], state);
|
||||
|
||||
// Right column: Info and breakpoints
|
||||
columns[1].heading("Information");
|
||||
self.show_info_panel(&mut columns[1], state);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_events_panel(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.stick_to_bottom(true)
|
||||
.show(ui, |ui| {
|
||||
if let Some(chase_state) = &state.chase_state {
|
||||
if chase_state.events.is_empty() {
|
||||
ui.label("No events yet. Click 'Step' or 'Run All' to start.");
|
||||
} else {
|
||||
for (i, event) in chase_state.events.iter().enumerate() {
|
||||
let is_current = i == chase_state.current_event;
|
||||
let prefix = if is_current { ">" } else { " " };
|
||||
|
||||
let axiom_name = event.axiom_name.as_deref().unwrap_or("(unnamed)");
|
||||
let binding_str: Vec<String> = event
|
||||
.binding
|
||||
.iter()
|
||||
.map(|(var, val)| format!("{}={}", var, val))
|
||||
.collect();
|
||||
|
||||
let changed_marker = if event.changed { " [changed]" } else { "" };
|
||||
|
||||
let text = format!(
|
||||
"{} [{}] #{} {} {{ {} }}{}",
|
||||
prefix,
|
||||
event.iteration,
|
||||
event.axiom_index,
|
||||
axiom_name,
|
||||
binding_str.join(", "),
|
||||
changed_marker
|
||||
);
|
||||
|
||||
let color = if is_current {
|
||||
egui::Color32::YELLOW
|
||||
} else if event.changed {
|
||||
egui::Color32::from_rgb(100, 200, 100)
|
||||
} else {
|
||||
egui::Color32::LIGHT_GRAY
|
||||
};
|
||||
|
||||
ui.label(egui::RichText::new(text).color(color).monospace());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_info_panel(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Read info from chase_state
|
||||
let (current_iteration, total_iterations, events_count, breakpoints, instance_name) = {
|
||||
if let Some(chase_state) = &state.chase_state {
|
||||
(
|
||||
chase_state.current_iteration,
|
||||
chase_state.total_iterations,
|
||||
chase_state.events.len(),
|
||||
chase_state.breakpoints.iter().cloned().collect::<Vec<_>>(),
|
||||
chase_state.instance_name.clone(),
|
||||
)
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Iteration info
|
||||
ui.label(format!("Current iteration: {}", current_iteration));
|
||||
if let Some(total) = total_iterations {
|
||||
ui.label(format!("Total iterations: {}", total));
|
||||
}
|
||||
ui.label(format!("Events recorded: {}", events_count));
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Breakpoints section
|
||||
ui.heading("Breakpoints");
|
||||
|
||||
// Add new breakpoint
|
||||
ui.horizontal(|ui| {
|
||||
ui.text_edit_singleline(&mut self.new_breakpoint);
|
||||
if ui.button("+").clicked() && !self.new_breakpoint.is_empty() {
|
||||
if let Some(chase_state) = &mut state.chase_state {
|
||||
chase_state.breakpoints.insert(self.new_breakpoint.clone());
|
||||
}
|
||||
self.new_breakpoint.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// List breakpoints
|
||||
if breakpoints.is_empty() {
|
||||
ui.label("(no breakpoints)");
|
||||
} else {
|
||||
let mut to_remove = None;
|
||||
for bp in &breakpoints {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!(" {}", bp));
|
||||
if ui.small_button("x").clicked() {
|
||||
to_remove = Some(bp.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(bp) = to_remove {
|
||||
if let Some(chase_state) = &mut state.chase_state {
|
||||
chase_state.breakpoints.remove(&bp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Structure info
|
||||
ui.heading("Structure");
|
||||
if let Some(entry) = state.repl.instances.get(&instance_name) {
|
||||
ui.label(format!("Elements: {}", entry.structure.len()));
|
||||
|
||||
let total_tuples: usize = entry.structure.relations.iter().map(|r| r.len()).sum();
|
||||
ui.label(format!("Relation tuples: {}", total_tuples));
|
||||
|
||||
// Show relation breakdown
|
||||
let theory_name = entry.theory_name.clone();
|
||||
if let Some(theory) = state.repl.theories.get(&theory_name) {
|
||||
for (rel_id, rel) in theory.theory.signature.relations.iter().enumerate() {
|
||||
if rel_id < entry.structure.relations.len() {
|
||||
let count = entry.structure.relations[rel_id].len();
|
||||
if count > 0 {
|
||||
ui.label(format!(" {}: {} tuples", rel.name, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single step of the chase
|
||||
fn step_chase(&mut self, state: &mut GuiState) {
|
||||
let instance_name = match &state.chase_state {
|
||||
Some(s) => s.instance_name.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let current_iteration = state.chase_state.as_ref().map(|s| s.current_iteration).unwrap_or(0);
|
||||
|
||||
// Get theory name first (immutable borrow)
|
||||
let theory_name = match state.repl.instances.get(&instance_name) {
|
||||
Some(e) => e.theory_name.clone(),
|
||||
None => {
|
||||
state.log_error(format!("Instance '{}' not found", instance_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let theory = match state.repl.theories.get(&theory_name) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
state.log_error(format!("Theory '{}' not found", theory_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Now get mutable access to entry
|
||||
let entry = state.repl.instances.get_mut(&instance_name).unwrap();
|
||||
|
||||
// Create a GUI observer that records events
|
||||
let mut observer = GuiChaseObserver {
|
||||
events: Vec::new(),
|
||||
slid_to_name: &entry.slid_to_name,
|
||||
iteration: current_iteration,
|
||||
step_mode: true,
|
||||
steps_remaining: 1,
|
||||
};
|
||||
|
||||
// Run chase with observer (just one step)
|
||||
let sig = &theory.theory.signature;
|
||||
let axioms = &theory.theory.axioms;
|
||||
let axiom_names = &theory.theory.axiom_names;
|
||||
|
||||
let result = crate::query::chase::chase_fixpoint_with_observer(
|
||||
axioms,
|
||||
axiom_names,
|
||||
&mut entry.structure,
|
||||
&mut state.repl.store.universe,
|
||||
sig,
|
||||
1, // Just one iteration
|
||||
&mut observer,
|
||||
);
|
||||
|
||||
// Record events
|
||||
let events = observer.events;
|
||||
if let Some(chase_state) = &mut state.chase_state {
|
||||
chase_state.mode = ChaseMode::Stepping;
|
||||
for event in events {
|
||||
chase_state.events.push(event);
|
||||
}
|
||||
chase_state.current_event = chase_state.events.len().saturating_sub(1);
|
||||
chase_state.current_iteration += 1;
|
||||
|
||||
if let Ok(iterations) = result {
|
||||
if iterations == 0 {
|
||||
chase_state.mode = ChaseMode::Completed;
|
||||
chase_state.total_iterations = Some(chase_state.current_iteration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log completion separately
|
||||
if let Ok(iterations) = result {
|
||||
if iterations == 0 {
|
||||
let total = state.chase_state.as_ref().map(|s| s.current_iteration).unwrap_or(0);
|
||||
state.log_success(format!("Chase completed after {} iterations", total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the chase to completion
|
||||
fn run_chase_to_completion(&mut self, state: &mut GuiState) {
|
||||
let instance_name = match &state.chase_state {
|
||||
Some(s) => s.instance_name.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Get theory name first (immutable borrow)
|
||||
let theory_name = match state.repl.instances.get(&instance_name) {
|
||||
Some(e) => e.theory_name.clone(),
|
||||
None => {
|
||||
state.log_error(format!("Instance '{}' not found", instance_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let theory = match state.repl.theories.get(&theory_name) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
state.log_error(format!("Theory '{}' not found", theory_name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Now get mutable access to entry
|
||||
let entry = state.repl.instances.get_mut(&instance_name).unwrap();
|
||||
|
||||
// Run chase to fixpoint
|
||||
let sig = &theory.theory.signature;
|
||||
let axioms = &theory.theory.axioms;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = crate::query::chase::chase_fixpoint(
|
||||
axioms,
|
||||
&mut entry.structure,
|
||||
&mut state.repl.store.universe,
|
||||
sig,
|
||||
100,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(iterations) => {
|
||||
let elapsed = start.elapsed();
|
||||
if let Some(chase_state) = &mut state.chase_state {
|
||||
chase_state.mode = ChaseMode::Completed;
|
||||
chase_state.total_iterations = Some(iterations);
|
||||
}
|
||||
state.log_success(format!(
|
||||
"Chase completed in {} iterations ({:.2}ms)",
|
||||
iterations,
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
state.log_error(format!("Chase error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChaseVisualization {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Observer that records chase events for the GUI
|
||||
struct GuiChaseObserver<'a> {
|
||||
events: Vec<ChaseEventRecord>,
|
||||
slid_to_name: &'a std::collections::HashMap<crate::id::Slid, String>,
|
||||
iteration: usize,
|
||||
step_mode: bool,
|
||||
steps_remaining: usize,
|
||||
}
|
||||
|
||||
impl<'a> ChaseObserver for GuiChaseObserver<'a> {
|
||||
fn on_event(&mut self, event: ChaseEvent<'_>) -> ChaseControl {
|
||||
match event {
|
||||
ChaseEvent::IterationStart { iteration } => {
|
||||
self.iteration = iteration;
|
||||
ChaseControl::Continue
|
||||
}
|
||||
ChaseEvent::AxiomFiring {
|
||||
axiom_index,
|
||||
axiom_name,
|
||||
binding,
|
||||
violation_count: _,
|
||||
} => {
|
||||
// Record the event
|
||||
let binding_vec: Vec<(String, String)> = binding
|
||||
.iter()
|
||||
.map(|(var, slid)| {
|
||||
let elem_name = self
|
||||
.slid_to_name
|
||||
.get(slid)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("#{}", slid));
|
||||
(var.clone(), elem_name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.events.push(ChaseEventRecord {
|
||||
iteration: self.iteration,
|
||||
axiom_index,
|
||||
axiom_name: axiom_name.map(|s| s.to_string()),
|
||||
binding: binding_vec,
|
||||
changed: false, // Will be updated in AxiomFired
|
||||
});
|
||||
|
||||
if self.step_mode {
|
||||
self.steps_remaining = self.steps_remaining.saturating_sub(1);
|
||||
if self.steps_remaining == 0 {
|
||||
ChaseControl::Stop
|
||||
} else {
|
||||
ChaseControl::Continue
|
||||
}
|
||||
} else {
|
||||
ChaseControl::Continue
|
||||
}
|
||||
}
|
||||
ChaseEvent::AxiomFired { changed, .. } => {
|
||||
// Update the last event with the changed status
|
||||
if let Some(last) = self.events.last_mut() {
|
||||
last.changed = changed;
|
||||
}
|
||||
ChaseControl::Continue
|
||||
}
|
||||
ChaseEvent::IterationEnd { .. } => ChaseControl::Continue,
|
||||
ChaseEvent::ChaseComplete { .. } => ChaseControl::Continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
562
src/gui/visualizations/graph.rs
Normal file
562
src/gui/visualizations/graph.rs
Normal file
@ -0,0 +1,562 @@
|
||||
//! Relation graph visualization
|
||||
//!
|
||||
//! Displays elements as nodes and relation tuples as edges.
|
||||
|
||||
use eframe::egui;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::RelationStorage;
|
||||
use crate::gui::state::{CentralView, GuiState};
|
||||
use crate::id::{NumericId, Slid};
|
||||
|
||||
/// A node in the graph
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GraphNode {
|
||||
pub id: Slid,
|
||||
pub name: String,
|
||||
pub sort: String,
|
||||
pub position: egui::Pos2,
|
||||
pub color: egui::Color32,
|
||||
}
|
||||
|
||||
/// An edge in the graph
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GraphEdge {
|
||||
pub from: Slid,
|
||||
pub to: Slid,
|
||||
pub relation: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Graph view panel
|
||||
pub struct GraphView {
|
||||
/// Nodes in the graph
|
||||
nodes: Vec<GraphNode>,
|
||||
/// Edges in the graph
|
||||
edges: Vec<GraphEdge>,
|
||||
/// Node positions (for dragging)
|
||||
node_positions: HashMap<Slid, egui::Pos2>,
|
||||
/// Currently dragged node
|
||||
dragged_node: Option<Slid>,
|
||||
/// Zoom level
|
||||
zoom: f32,
|
||||
/// Pan offset
|
||||
pan: egui::Vec2,
|
||||
/// Selected relation filter
|
||||
selected_relation: Option<String>,
|
||||
/// Whether we need to recalculate layout
|
||||
needs_layout: bool,
|
||||
/// Last instance/relation we rendered
|
||||
last_instance: Option<String>,
|
||||
last_relation: Option<String>,
|
||||
}
|
||||
|
||||
impl GraphView {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
node_positions: HashMap::new(),
|
||||
dragged_node: None,
|
||||
zoom: 1.0,
|
||||
pan: egui::Vec2::ZERO,
|
||||
selected_relation: None,
|
||||
needs_layout: true,
|
||||
last_instance: None,
|
||||
last_relation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
// Header
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Graph Visualization");
|
||||
|
||||
if let Some(instance) = &state.graph_instance {
|
||||
ui.label(format!("Instance: {}", instance));
|
||||
}
|
||||
if let Some(relation) = &state.graph_relation {
|
||||
ui.label(format!("Relation: {}", relation));
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Check if we need to rebuild the graph
|
||||
if state.graph_instance != self.last_instance || state.graph_relation != self.last_relation {
|
||||
self.rebuild_graph(state);
|
||||
}
|
||||
|
||||
// No instance selected
|
||||
if state.graph_instance.is_none() {
|
||||
self.show_selection_panel(ui, state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Controls
|
||||
ui.horizontal(|ui| {
|
||||
// Relation filter dropdown
|
||||
if let Some(instance_name) = &state.graph_instance {
|
||||
if let Some(entry) = state.repl.instances.get(instance_name) {
|
||||
if let Some(theory) = state.repl.theories.get(&entry.theory_name) {
|
||||
let relations: Vec<&str> = theory
|
||||
.theory
|
||||
.signature
|
||||
.relations
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect();
|
||||
|
||||
ui.label("Relation:");
|
||||
egui::ComboBox::from_label("")
|
||||
.selected_text(
|
||||
self.selected_relation
|
||||
.as_deref()
|
||||
.unwrap_or("(all)"),
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(self.selected_relation.is_none(), "(all)").clicked() {
|
||||
self.selected_relation = None;
|
||||
self.needs_layout = true;
|
||||
}
|
||||
for rel in relations {
|
||||
if ui.selectable_label(
|
||||
self.selected_relation.as_deref() == Some(rel),
|
||||
rel,
|
||||
).clicked() {
|
||||
self.selected_relation = Some(rel.to_string());
|
||||
self.needs_layout = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("Reset Layout").clicked() {
|
||||
self.needs_layout = true;
|
||||
}
|
||||
|
||||
if ui.button("Fit").clicked() {
|
||||
self.fit_to_view(ui);
|
||||
}
|
||||
|
||||
// Zoom controls
|
||||
ui.label("Zoom:");
|
||||
if ui.button("-").clicked() {
|
||||
self.zoom = (self.zoom - 0.1).max(0.1);
|
||||
}
|
||||
ui.label(format!("{:.0}%", self.zoom * 100.0));
|
||||
if ui.button("+").clicked() {
|
||||
self.zoom = (self.zoom + 0.1).min(3.0);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Close").clicked() {
|
||||
state.graph_instance = None;
|
||||
state.graph_relation = None;
|
||||
state.central_view = CentralView::EditorAndInspector;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Apply layout if needed
|
||||
if self.needs_layout {
|
||||
self.apply_layout(ui);
|
||||
self.needs_layout = false;
|
||||
}
|
||||
|
||||
// Draw the graph
|
||||
self.draw_graph(ui);
|
||||
}
|
||||
|
||||
fn show_selection_panel(&mut self, ui: &mut egui::Ui, state: &mut GuiState) {
|
||||
ui.label("Select an instance to visualize:");
|
||||
ui.add_space(8.0);
|
||||
|
||||
let instances = state.repl.list_instances();
|
||||
if instances.is_empty() {
|
||||
ui.label("No instances defined.");
|
||||
} else {
|
||||
for instance in instances {
|
||||
if ui.button(format!("{} : {}", instance.name, instance.theory_name)).clicked() {
|
||||
state.graph_instance = Some(instance.name.clone());
|
||||
self.needs_layout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
if ui.button("Back to Editor").clicked() {
|
||||
state.central_view = CentralView::EditorAndInspector;
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_graph(&mut self, state: &GuiState) {
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
self.node_positions.clear();
|
||||
|
||||
let instance_name = match &state.graph_instance {
|
||||
Some(name) => name,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let entry = match state.repl.instances.get(instance_name) {
|
||||
Some(e) => e,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let theory = match state.repl.theories.get(&entry.theory_name) {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let sig = &theory.theory.signature;
|
||||
|
||||
// Create nodes for each element
|
||||
let sort_colors = generate_sort_colors(sig.sorts.len());
|
||||
|
||||
for (slid_idx, &sort_id) in entry.structure.sorts.iter().enumerate() {
|
||||
let slid = Slid::from_usize(slid_idx);
|
||||
let name = entry
|
||||
.slid_to_name
|
||||
.get(&slid)
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_else(|| format!("#{}", slid_idx));
|
||||
|
||||
let sort_name = sig.sorts.get(sort_id).cloned().unwrap_or_default();
|
||||
let color = sort_colors.get(sort_id).copied().unwrap_or(egui::Color32::GRAY);
|
||||
|
||||
self.nodes.push(GraphNode {
|
||||
id: slid,
|
||||
name,
|
||||
sort: sort_name,
|
||||
position: egui::Pos2::ZERO, // Will be set by layout
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
// Create edges for relations
|
||||
for (rel_id, relation) in entry.structure.relations.iter().enumerate() {
|
||||
if relation.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel_name = sig
|
||||
.relations
|
||||
.get(rel_id)
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| format!("rel#{}", rel_id));
|
||||
|
||||
// Filter by selected relation
|
||||
if let Some(selected) = &self.selected_relation {
|
||||
if &rel_name != selected {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for tuple in relation.iter() {
|
||||
if tuple.len() >= 2 {
|
||||
// Binary or higher-arity relation: draw edge from first to last
|
||||
self.edges.push(GraphEdge {
|
||||
from: tuple[0],
|
||||
to: tuple[tuple.len() - 1],
|
||||
relation: rel_name.clone(),
|
||||
label: if tuple.len() > 2 {
|
||||
Some(format!("via {} elements", tuple.len() - 2))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
} else if tuple.len() == 1 {
|
||||
// Unary relation: self-loop (or just highlight the node)
|
||||
self.edges.push(GraphEdge {
|
||||
from: tuple[0],
|
||||
to: tuple[0],
|
||||
relation: rel_name.clone(),
|
||||
label: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_instance = state.graph_instance.clone();
|
||||
self.last_relation = state.graph_relation.clone();
|
||||
self.needs_layout = true;
|
||||
}
|
||||
|
||||
fn apply_layout(&mut self, ui: &egui::Ui) {
|
||||
let available = ui.available_size();
|
||||
let center = egui::Pos2::new(available.x / 2.0, available.y / 2.0);
|
||||
|
||||
if self.nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple circular layout
|
||||
let radius = (available.x.min(available.y) / 2.0 - 50.0).max(100.0);
|
||||
let count = self.nodes.len();
|
||||
|
||||
for (i, node) in self.nodes.iter_mut().enumerate() {
|
||||
let angle = (i as f32 / count as f32) * std::f32::consts::TAU;
|
||||
let pos = egui::Pos2::new(
|
||||
center.x + radius * angle.cos(),
|
||||
center.y + radius * angle.sin(),
|
||||
);
|
||||
node.position = pos;
|
||||
self.node_positions.insert(node.id, pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_to_view(&mut self, ui: &egui::Ui) {
|
||||
if self.nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounding box
|
||||
let mut min_x = f32::MAX;
|
||||
let mut min_y = f32::MAX;
|
||||
let mut max_x = f32::MIN;
|
||||
let mut max_y = f32::MIN;
|
||||
|
||||
for node in &self.nodes {
|
||||
min_x = min_x.min(node.position.x);
|
||||
min_y = min_y.min(node.position.y);
|
||||
max_x = max_x.max(node.position.x);
|
||||
max_y = max_y.max(node.position.y);
|
||||
}
|
||||
|
||||
let width = max_x - min_x;
|
||||
let height = max_y - min_y;
|
||||
let available = ui.available_size();
|
||||
|
||||
// Calculate zoom to fit
|
||||
let zoom_x = if width > 0.0 { (available.x - 100.0) / width } else { 1.0 };
|
||||
let zoom_y = if height > 0.0 { (available.y - 100.0) / height } else { 1.0 };
|
||||
self.zoom = zoom_x.min(zoom_y).clamp(0.1, 2.0);
|
||||
|
||||
// Calculate pan to center
|
||||
let center_x = (min_x + max_x) / 2.0;
|
||||
let center_y = (min_y + max_y) / 2.0;
|
||||
self.pan = egui::Vec2::new(
|
||||
available.x / 2.0 - center_x * self.zoom,
|
||||
available.y / 2.0 - center_y * self.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_graph(&mut self, ui: &mut egui::Ui) {
|
||||
let (response, painter) = ui.allocate_painter(
|
||||
ui.available_size(),
|
||||
egui::Sense::click_and_drag(),
|
||||
);
|
||||
|
||||
let rect = response.rect;
|
||||
|
||||
// Handle panning
|
||||
if response.dragged() && self.dragged_node.is_none() {
|
||||
self.pan += response.drag_delta();
|
||||
}
|
||||
|
||||
// Handle zooming with scroll
|
||||
if response.hovered() {
|
||||
let scroll = ui.input(|i| i.raw_scroll_delta.y);
|
||||
if scroll != 0.0 {
|
||||
let old_zoom = self.zoom;
|
||||
self.zoom = (self.zoom + scroll * 0.001).clamp(0.1, 3.0);
|
||||
|
||||
// Zoom toward mouse position
|
||||
if let Some(pos) = response.hover_pos() {
|
||||
let rel_pos = pos - rect.min;
|
||||
let zoom_factor = self.zoom / old_zoom;
|
||||
self.pan = rel_pos - (rel_pos - self.pan) * zoom_factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform helper
|
||||
let transform = |pos: egui::Pos2| -> egui::Pos2 {
|
||||
egui::Pos2::new(
|
||||
rect.min.x + pos.x * self.zoom + self.pan.x,
|
||||
rect.min.y + pos.y * self.zoom + self.pan.y,
|
||||
)
|
||||
};
|
||||
|
||||
// Draw edges first (behind nodes)
|
||||
for edge in &self.edges {
|
||||
let from_pos = self.node_positions.get(&edge.from);
|
||||
let to_pos = self.node_positions.get(&edge.to);
|
||||
|
||||
if let (Some(&from), Some(&to)) = (from_pos, to_pos) {
|
||||
let from_screen = transform(from);
|
||||
let to_screen = transform(to);
|
||||
|
||||
if edge.from == edge.to {
|
||||
// Self-loop
|
||||
let offset = 20.0 * self.zoom;
|
||||
let loop_center = egui::Pos2::new(from_screen.x, from_screen.y - offset);
|
||||
painter.circle_stroke(
|
||||
loop_center,
|
||||
10.0 * self.zoom,
|
||||
egui::Stroke::new(1.5, egui::Color32::LIGHT_GRAY),
|
||||
);
|
||||
} else {
|
||||
// Regular edge
|
||||
painter.line_segment(
|
||||
[from_screen, to_screen],
|
||||
egui::Stroke::new(1.5, egui::Color32::LIGHT_GRAY),
|
||||
);
|
||||
|
||||
// Draw arrowhead
|
||||
let dir = (to_screen - from_screen).normalized();
|
||||
let arrow_size = 8.0 * self.zoom;
|
||||
let node_radius = 20.0 * self.zoom;
|
||||
let arrow_tip = to_screen - dir * node_radius;
|
||||
let perp = egui::Vec2::new(-dir.y, dir.x);
|
||||
|
||||
let p1 = arrow_tip;
|
||||
let p2 = arrow_tip - dir * arrow_size + perp * arrow_size * 0.5;
|
||||
let p3 = arrow_tip - dir * arrow_size - perp * arrow_size * 0.5;
|
||||
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![p1, p2, p3],
|
||||
egui::Color32::LIGHT_GRAY,
|
||||
egui::Stroke::NONE,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
let node_radius = 20.0 * self.zoom;
|
||||
|
||||
for node in &self.nodes {
|
||||
let pos = self.node_positions.get(&node.id).copied().unwrap_or(node.position);
|
||||
let screen_pos = transform(pos);
|
||||
|
||||
// Check if node is being hovered or dragged
|
||||
let node_rect = egui::Rect::from_center_size(
|
||||
screen_pos,
|
||||
egui::Vec2::splat(node_radius * 2.0),
|
||||
);
|
||||
|
||||
let is_hovered = response.hovered()
|
||||
&& ui.input(|i| i.pointer.hover_pos())
|
||||
.map(|p| node_rect.contains(p))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Handle node dragging
|
||||
if is_hovered && response.drag_started() {
|
||||
self.dragged_node = Some(node.id);
|
||||
}
|
||||
|
||||
if self.dragged_node == Some(node.id) {
|
||||
if response.dragged() {
|
||||
let delta = response.drag_delta() / self.zoom;
|
||||
if let Some(pos) = self.node_positions.get_mut(&node.id) {
|
||||
*pos += delta;
|
||||
}
|
||||
}
|
||||
if response.drag_stopped() {
|
||||
self.dragged_node = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw node circle
|
||||
let fill_color = if is_hovered || self.dragged_node == Some(node.id) {
|
||||
node.color.linear_multiply(1.3)
|
||||
} else {
|
||||
node.color
|
||||
};
|
||||
|
||||
painter.circle_filled(screen_pos, node_radius, fill_color);
|
||||
painter.circle_stroke(
|
||||
screen_pos,
|
||||
node_radius,
|
||||
egui::Stroke::new(2.0, egui::Color32::WHITE),
|
||||
);
|
||||
|
||||
// Draw node label
|
||||
let font_size = (12.0 * self.zoom).max(8.0);
|
||||
painter.text(
|
||||
screen_pos,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&node.name,
|
||||
egui::FontId::proportional(font_size),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
|
||||
// Draw sort label below
|
||||
if is_hovered {
|
||||
painter.text(
|
||||
screen_pos + egui::Vec2::new(0.0, node_radius + 5.0),
|
||||
egui::Align2::CENTER_TOP,
|
||||
&node.sort,
|
||||
egui::FontId::proportional(10.0 * self.zoom),
|
||||
egui::Color32::LIGHT_GRAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show info panel
|
||||
if self.nodes.is_empty() {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"No elements to display",
|
||||
egui::FontId::proportional(16.0),
|
||||
egui::Color32::GRAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate distinct colors for each sort
|
||||
fn generate_sort_colors(count: usize) -> Vec<egui::Color32> {
|
||||
let mut colors = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let hue = (i as f32 / count as f32) * 360.0;
|
||||
let (r, g, b) = hsv_to_rgb(hue, 0.6, 0.8);
|
||||
colors.push(egui::Color32::from_rgb(r, g, b));
|
||||
}
|
||||
|
||||
colors
|
||||
}
|
||||
|
||||
/// Convert HSV to RGB
|
||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||
let m = v - c;
|
||||
|
||||
let (r, g, b) = if h < 60.0 {
|
||||
(c, x, 0.0)
|
||||
} else if h < 120.0 {
|
||||
(x, c, 0.0)
|
||||
} else if h < 180.0 {
|
||||
(0.0, c, x)
|
||||
} else if h < 240.0 {
|
||||
(0.0, x, c)
|
||||
} else if h < 300.0 {
|
||||
(x, 0.0, c)
|
||||
} else {
|
||||
(c, 0.0, x)
|
||||
};
|
||||
|
||||
(
|
||||
((r + m) * 255.0) as u8,
|
||||
((g + m) * 255.0) as u8,
|
||||
((b + m) * 255.0) as u8,
|
||||
)
|
||||
}
|
||||
7
src/gui/visualizations/mod.rs
Normal file
7
src/gui/visualizations/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
//! Visualization components for the Geolog GUI
|
||||
|
||||
pub mod chase;
|
||||
pub mod graph;
|
||||
|
||||
pub use chase::ChaseVisualization;
|
||||
pub use graph::GraphView;
|
||||
@ -27,6 +27,9 @@ pub mod universe;
|
||||
pub mod version;
|
||||
pub mod zerocopy;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
pub use ast::*;
|
||||
pub use lexer::lexer;
|
||||
pub use parser::parser;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user