feat(gui): scaffold egui window behind the gui feature

Adds the opt-in graphical front-end (pixelpass --gui), default-off via the
`gui` cargo feature so the headless build never pulls the toolkit tree.
eframe 0.34 on the glow/OpenGL backend (no wgpu); 69 feature-gated crates,
vetted. --gui on a headless build errors with a rebuild hint.

This commit is just the shell: a window with a Host/View menu and back
navigation. The shell-out child-spawning + JSON event parsing that drives
real host/viewer controls come next. Window verified to open and render
cleanly on Wayland (glow).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:26:56 -04:00
parent e7ded10db8
commit 6f0fd088f6
5 changed files with 1805 additions and 67 deletions
+5
View File
@@ -68,6 +68,11 @@ pub struct Cli {
pub port: u16,
// ── global ────────────────────────────────────────────────────────
/// Launch the graphical front-end (a window with Host/View controls)
/// instead of the terminal menu. Requires a build with `--features gui`.
#[arg(long)]
pub gui: bool,
/// Emit machine-readable events on stdout (one JSON object per line)
/// alongside the human banner on stderr. For scripts and the --gui
/// front-end. Currently only `json` is supported.
+106
View File
@@ -0,0 +1,106 @@
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
//! feature.
//!
//! Architecture: this window is a thin **shell-out** driver. It never touches
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
//! this same binary in headless mode (`pixelpass --host --output json …` or
//! `pixelpass <ticket> --output json`) as a child process and parses the
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
//! shows. That keeps the fragile capture stack sealed in a separate process:
//! the GUI can be closed or crash without taking a live stream down.
use eframe::egui;
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
/// main thread (a winit requirement), which is where `main` calls it from.
pub fn run() -> anyhow::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([520.0, 420.0])
.with_min_inner_size([420.0, 320.0])
.with_title("PixelPass"),
..Default::default()
};
eframe::run_native(
"PixelPass",
options,
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
)
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
}
/// Which screen the single window is currently showing.
#[derive(Default, PartialEq)]
enum Screen {
#[default]
Menu,
Host,
Viewer,
}
#[derive(Default)]
struct PixelPassApp {
screen: Screen,
}
impl eframe::App for PixelPassApp {
// eframe 0.34 hands us the central-panel `ui` directly (it wraps this in a
// CentralPanel for us).
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
match self.screen {
Screen::Menu => self.menu(ui),
Screen::Host => self.host(ui),
Screen::Viewer => self.viewer(ui),
}
}
}
impl PixelPassApp {
fn menu(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(24.0);
ui.heading("PixelPass");
ui.label("P2P screen sharing");
ui.add_space(32.0);
if ui
.add_sized([240.0, 40.0], egui::Button::new("Host — share my screen"))
.clicked()
{
self.screen = Screen::Host;
}
ui.add_space(8.0);
if ui
.add_sized(
[240.0, 40.0],
egui::Button::new("View — watch someone's screen"),
)
.clicked()
{
self.screen = Screen::Viewer;
}
});
}
fn host(&mut self, ui: &mut egui::Ui) {
self.back_bar(ui, "Host");
ui.separator();
ui.label("Host controls land in the next step.");
}
fn viewer(&mut self, ui: &mut egui::Ui) {
self.back_bar(ui, "View");
ui.separator();
ui.label("Viewer controls land in a later step.");
}
/// Title row with a back-to-menu button, shared by the host/viewer screens.
fn back_bar(&mut self, ui: &mut egui::Ui, title: &str) {
ui.horizontal(|ui| {
if ui.button("← Menu").clicked() {
self.screen = Screen::Menu;
}
ui.heading(title);
});
}
}
+16
View File
@@ -1,5 +1,7 @@
mod cli;
mod common;
#[cfg(feature = "gui")]
mod gui;
mod host;
mod interactive;
mod repair;
@@ -20,6 +22,20 @@ async fn main() -> Result<()> {
common::output::set_json(true);
}
if cli.gui {
#[cfg(feature = "gui")]
{
return gui::run();
}
#[cfg(not(feature = "gui"))]
{
anyhow::bail!(
"this binary was built without GUI support. Rebuild with \
`cargo build --release --features gui` to use --gui."
);
}
}
// libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();