287 lines
7.5 KiB
Rust
Raw Normal View History

2026-04-09 10:12:59 +02:00
//! Minimal local web UI for the query-engine frontend language.
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use super::Session;
pub fn serve_gui(address: &str) -> io::Result<()> {
let listener = TcpListener::bind(address)?;
let session = Arc::new(Mutex::new(Session::new()));
println!("GUI available at http://{}", address);
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let shared = Arc::clone(&session);
if let Err(err) = handle_connection(stream, &shared) {
eprintln!("gui error: {}", err);
}
}
Err(err) => eprintln!("gui accept error: {}", err),
}
}
Ok(())
}
fn handle_connection(mut stream: TcpStream, session: &Arc<Mutex<Session>>) -> io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
if request_line.trim().is_empty() {
return Ok(());
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let path = parts.next().unwrap_or("/");
let mut content_length = 0usize;
loop {
let mut header = String::new();
reader.read_line(&mut header)?;
if header == "\r\n" || header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
let parsed = value.trim().parse::<usize>().ok();
if let Some(length) = parsed {
content_length = length;
}
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body)?;
}
let response = match (method, path) {
("GET", "/") => http_response("200 OK", "text/html; charset=utf-8", INDEX_HTML),
("POST", "/execute") => {
let script = String::from_utf8_lossy(&body);
let output = {
let mut locked = session
.lock()
.map_err(|_| io::Error::other("session lock poisoned"))?;
match locked.execute_script(script.as_ref()) {
Ok(output) => output,
Err(err) => format!("error: {}", err),
}
};
http_response("200 OK", "text/plain; charset=utf-8", &output)
}
("POST", "/reset") => {
let mut locked = session
.lock()
.map_err(|_| io::Error::other("session lock poisoned"))?;
locked.reset();
http_response("200 OK", "text/plain; charset=utf-8", "Session reset.")
}
_ => http_response("404 Not Found", "text/plain; charset=utf-8", "Not found"),
};
stream.write_all(response.as_bytes())?;
stream.flush()?;
Ok(())
}
fn http_response(status: &str, content_type: &str, body: &str) -> String {
format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
content_type,
body.len(),
body
)
}
const INDEX_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>query-engine GUI</title>
<style>
:root {
--bg: #f4efe6;
--panel: rgba(255, 251, 245, 0.9);
--ink: #1f1d1a;
--accent: #b5542f;
--accent-soft: #e7c7b8;
--border: #d7c7b8;
--shadow: rgba(78, 52, 38, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Iosevka", "SFMono-Regular", "Menlo", monospace;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(181, 84, 47, 0.18), transparent 28rem),
linear-gradient(135deg, #efe4d4, var(--bg));
}
main {
width: min(1100px, calc(100% - 2rem));
margin: 2rem auto;
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: 0 18px 50px var(--shadow);
backdrop-filter: blur(10px);
}
.editor {
padding: 1.25rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
h1, h2, p {
margin: 0;
}
h1 {
font-size: clamp(1.5rem, 2vw, 2rem);
letter-spacing: 0.04em;
text-transform: uppercase;
}
p {
color: rgba(31, 29, 26, 0.74);
line-height: 1.5;
}
textarea {
width: 100%;
min-height: 22rem;
border: 1px solid var(--border);
border-radius: 14px;
background: #fffdfa;
color: var(--ink);
padding: 1rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 999px;
padding: 0.8rem 1.2rem;
font: inherit;
cursor: pointer;
transition: transform 140ms ease, opacity 140ms ease;
}
button:hover { transform: translateY(-1px); }
button:active { transform: translateY(0); }
.primary {
background: var(--accent);
color: #fff8f3;
}
.secondary {
background: var(--accent-soft);
color: var(--ink);
}
pre {
margin: 0;
padding: 1.25rem;
min-height: 100%;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.output {
overflow: hidden;
}
.sample {
font-size: 0.92rem;
padding: 0 1.25rem 1.25rem;
}
</style>
</head>
<body>
<main>
<section class="editor">
<div class="header">
<div>
<h1>query-engine</h1>
<p>Minimal local workbench for rule-driven query experiments.</p>
</div>
</div>
<textarea id="script">fact Parent(alice, bob).
fact Parent(bob, carol).
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
rule Ancestor(?X, ?Y), Parent(?Y, ?Z) -> Ancestor(?X, ?Z).
run.
explain Ancestor(alice, carol)?</textarea>
<div class="actions">
<button class="primary" id="execute">Execute</button>
<button class="secondary" id="reset">Reset Session</button>
</div>
<p class="sample">Try <code>query Ancestor(?X, ?Y)?</code>, <code>explain Ancestor(alice, carol)?</code>, or boolean queries like <code>query Parent(alice, bob)?</code>.</p>
</section>
<section class="output">
<pre id="output">Session ready.</pre>
</section>
</main>
<script>
const output = document.getElementById("output");
const script = document.getElementById("script");
async function send(path, body) {
const response = await fetch(path, { method: "POST", body });
const text = await response.text();
return text;
}
document.getElementById("execute").addEventListener("click", async () => {
const text = await send("/execute", script.value);
output.textContent += `\n\n> ${new Date().toLocaleTimeString()}\n${text}`;
});
document.getElementById("reset").addEventListener("click", async () => {
const text = await send("/reset", "");
output.textContent = text;
});
</script>
</body>
</html>
"#;