Add a simple GUI

This commit is contained in:
Hassan Abedi 2026-03-20 11:01:04 +01:00
parent 6518e99263
commit 84d1f0d86a
16 changed files with 6841 additions and 30 deletions

3908
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,27 @@ proptest = "1.4" # property-based testing
rand = "0.9.2" rand = "0.9.2"
tempfile = "3.10" # temp dirs for persistence tests 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]] [[bin]]
name = "geolog" name = "geolog"
path = "src/bin/geolog.rs" path = "src/bin/geolog.rs"
[[bin]]
name = "geolog-gui"
path = "src/bin/geolog-gui.rs"
required-features = ["gui"]

249
README2.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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,
}
}
}

View 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,
)
}

View 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;

View File

@ -27,6 +27,9 @@ pub mod universe;
pub mod version; pub mod version;
pub mod zerocopy; pub mod zerocopy;
#[cfg(feature = "gui")]
pub mod gui;
pub use ast::*; pub use ast::*;
pub use lexer::lexer; pub use lexer::lexer;
pub use parser::parser; pub use parser::parser;