From a7bb0b4c9965a656f8eb4fe0211777b57a2a334d Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 19:52:39 +0100 Subject: [PATCH 01/63] chore: publish 0.3.0 From 0999f86ea15922c8881baf8ae29ff7e9d4d86688 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 20:48:16 +0100 Subject: [PATCH 02/63] feat: add browser surface plumbing --- crates/taskers-app/src/main.rs | 180 +++++++++++++++++++++- crates/taskers-cli/src/main.rs | 205 +++++++++++++++++++++++--- crates/taskers-core/src/app_state.rs | 67 ++++++++- crates/taskers-domain/src/model.rs | 9 ++ crates/taskers-ghostty/src/backend.rs | 3 + crates/taskers-macos-ffi/src/lib.rs | 66 ++++++++- 6 files changed, 499 insertions(+), 31 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index bb4e91e..cea2f36 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -773,6 +773,9 @@ impl UiHandle { } let surface = pane.active_surface()?; + if surface.kind != PaneKind::Terminal { + return None; + } if let Some(widget) = self.ghostty_surfaces.borrow().get(&surface.id) { detach_widget(widget); @@ -1359,13 +1362,13 @@ impl UiHandle { agent_icon.add_css_class("pane-agent-icon"); header.append(agent_icon.widget()); - let title = Label::new(Some("Unnamed terminal pane")); + let title = Label::new(Some("Unnamed surface")); title.add_css_class("pane-title"); title.set_xalign(0.0); title.set_hexpand(true); title.set_ellipsize(gtk::pango::EllipsizeMode::End); title.set_cursor_from_name(Some("text")); - title.set_tooltip_text(Some("Click to rename terminal")); + title.set_tooltip_text(Some("Click to rename surface")); let title_parent: Widget = title.clone().upcast(); let rename_title_ui = Rc::clone(self); let rename_title_pane_id = pane.id; @@ -1485,7 +1488,7 @@ impl UiHandle { content.set_margin_top(4); content.set_margin_bottom(4); - let rename_terminal = Button::with_label("Rename terminal"); + let rename_terminal = Button::with_label("Rename surface"); rename_terminal.add_css_class("flat"); rename_terminal.add_css_class("context-item"); let rename_ui = Rc::clone(&ctx_ui); @@ -1679,7 +1682,7 @@ impl UiHandle { let display_title = pane .active_surface() .map(display_surface_title) - .unwrap_or_else(|| "Unnamed terminal pane".into()); + .unwrap_or_else(|| "Unnamed surface".into()); configure_agent_icon( &card.agent_icon, pane.active_surface().and_then(surface_agent_kind), @@ -1687,7 +1690,7 @@ impl UiHandle { ); card.title.set_text(&display_title); card.title.set_tooltip_text(Some(&format!( - "{}\nClick to rename terminal", + "{}\nClick to rename surface", format_pane_meta(pane, snapshot.as_ref()) ))); @@ -4613,6 +4616,13 @@ fn initialize_terminal_body( card.displayed_surface_id .set(pane.active_surface().map(|surface| surface.id)); + if pane + .active_surface() + .is_some_and(|surface| surface.kind == PaneKind::Browser) + { + return initialize_browser_placeholder_body(ui, pane, card); + } + if let Some(widget) = ui.terminal_widget(workspace_id, pane) { widget.set_focusable(true); card.terminal_host.append(&widget); @@ -4699,6 +4709,68 @@ fn initialize_terminal_body( entry.upcast() } +fn initialize_browser_placeholder_body( + ui: &Rc, + pane: &PaneRecord, + card: &PaneCardWidgets, +) -> Widget { + let root = GtkBox::new(Orientation::Vertical, 10); + root.set_hexpand(true); + root.set_vexpand(true); + root.set_valign(Align::Center); + root.set_margin_start(20); + root.set_margin_end(20); + root.set_margin_top(20); + root.set_margin_bottom(20); + root.add_css_class("terminal-output"); + + let title = Label::new(Some( + "Browser surfaces currently render only in the native macOS host.", + )); + title.set_wrap(true); + title.set_xalign(0.0); + root.append(&title); + + let detail = Label::new(Some( + "This Linux/GTK shell keeps the browser surface metadata in sync and can hand the URL off to your default browser.", + )); + detail.add_css_class("pane-meta"); + detail.set_wrap(true); + detail.set_xalign(0.0); + root.append(&detail); + + let mut focus_target: Widget = root.clone().upcast(); + if let Some(url) = pane + .active_surface() + .and_then(browser_surface_url) + .map(str::to_string) + { + let url_label = Label::new(Some(&format!("URL: {url}"))); + url_label.add_css_class("pane-meta"); + url_label.set_wrap(true); + url_label.set_xalign(0.0); + url_label.set_selectable(true); + root.append(&url_label); + + let open_uri = resolved_browser_uri(&url); + let open_button = Button::with_label("Open in Default Browser"); + let open_ui = Rc::clone(ui); + open_button.connect_clicked(move |_| { + if let Err(error) = gtk::gio::AppInfo::launch_default_for_uri( + &open_uri, + None::<>k::gio::AppLaunchContext>, + ) { + open_ui.toast(&format!("failed to open browser URL: {error}")); + } + }); + root.append(&open_button); + focus_target = open_button.upcast(); + } + + card.terminal_host.append(&root); + focus_target +} + fn sync_terminal_body( ui: &Rc, workspace_id: taskers_domain::WorkspaceId, @@ -6165,12 +6237,94 @@ fn display_surface_title(surface: &SurfaceRecord) -> String { return humanize_agent_kind(agent); } + if surface.kind == PaneKind::Browser + && let Some(url) = browser_surface_url(surface) + { + return url.to_string(); + } + match surface.kind { PaneKind::Terminal => "Terminal".into(), PaneKind::Browser => "Browser".into(), } } +fn browser_surface_url(surface: &SurfaceRecord) -> Option<&str> { + surface + .metadata + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) +} + +fn has_explicit_browser_scheme(value: &str) -> bool { + if value.contains("://") { + return true; + } + + let Some((scheme, rest)) = value.split_once(':') else { + return false; + }; + let Some(first) = scheme.chars().next() else { + return false; + }; + if !first.is_ascii_alphabetic() { + return false; + } + if !scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) + { + return false; + } + if rest.starts_with("//") { + return true; + } + + matches!( + scheme.to_ascii_lowercase().as_str(), + "about" | "data" | "file" | "javascript" | "mailto" + ) +} + +fn is_local_browser_target(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if lower.starts_with("localhost") + || lower.starts_with("127.0.0.1") + || lower.starts_with("[::1]") + { + return true; + } + if value.contains('/') { + return false; + } + + value.rsplit_once(':').is_some_and(|(host, port)| { + !host.is_empty() + && !host.contains('.') + && !host.contains(':') + && port.chars().all(|ch| ch.is_ascii_digit()) + }) +} + +fn resolved_browser_uri(raw: &str) -> String { + let trimmed = raw.trim(); + if has_explicit_browser_scheme(trimmed) { + return trimmed.to_string(); + } + if trimmed.chars().any(char::is_whitespace) { + return format!( + "https://duckduckgo.com/?q={}", + trimmed.split_whitespace().collect::>().join("+") + ); + } + if is_local_browser_target(trimmed) { + return format!("http://{trimmed}"); + } + format!("https://{trimmed}") +} + fn editable_surface_title(surface: &SurfaceRecord) -> String { surface .metadata @@ -6860,6 +7014,7 @@ fn connect_ghostty_widget( patch: PaneMetadataPatch { title, cwd: None, + url: None, repo_name: None, git_branch: None, ports: None, @@ -6892,6 +7047,7 @@ fn connect_ghostty_widget( patch: PaneMetadataPatch { title: None, cwd, + url: None, repo_name: None, git_branch: None, ports: None, @@ -6984,6 +7140,20 @@ fn detach_widget(widget: &Widget) { } fn format_pane_meta(pane: &PaneRecord, snapshot: Option<&PaneRuntimeSnapshot>) -> String { + if let Some(surface) = pane.active_surface() + && surface.kind == PaneKind::Browser + { + let title = surface + .metadata + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("browser surface"); + let url = browser_surface_url(surface).unwrap_or("no URL"); + return format!("browser \u{2022} {title} \u{2022} {url}"); + } + let metadata = pane.active_metadata(); let cwd = metadata .and_then(|meta| meta.cwd.as_deref()) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 2b46b0f..79bacb3 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -4,10 +4,10 @@ use std::{ path::PathBuf, }; -use anyhow::Context; +use anyhow::{Context, anyhow, bail}; use clap::{Parser, Subcommand, ValueEnum}; use taskers_control::{ - ControlClient, ControlCommand, ControlQuery, InMemoryController, bind_socket, + ControlClient, ControlCommand, ControlQuery, ControlResponse, InMemoryController, bind_socket, default_socket_path, serve, }; use taskers_domain::{ @@ -151,6 +151,10 @@ enum PaneCommand { pane: Option, #[arg(long, value_enum, default_value_t = CliAxis::Vertical)] axis: CliAxis, + #[arg(long, value_enum, default_value_t = CliPaneKind::Terminal)] + kind: CliPaneKind, + #[arg(long)] + url: Option, }, Focus { #[arg(long)] @@ -223,6 +227,10 @@ enum SurfaceCommand { workspace: WorkspaceId, #[arg(long)] pane: PaneId, + #[arg(long, value_enum, default_value_t = CliPaneKind::Terminal)] + kind: CliPaneKind, + #[arg(long)] + url: Option, }, Focus { #[arg(long)] @@ -281,6 +289,12 @@ enum CliDirection { Down, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum CliPaneKind { + Terminal, + Browser, +} + impl From for SignalKind { fn from(value: CliSignalKind) -> Self { match value { @@ -315,6 +329,15 @@ impl From for Direction { } } +impl From for PaneKind { + fn from(value: CliPaneKind) -> Self { + match value { + CliPaneKind::Terminal => PaneKind::Terminal, + CliPaneKind::Browser => PaneKind::Browser, + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -514,16 +537,63 @@ async fn main() -> anyhow::Result<()> { workspace, pane, axis, + kind, + url, } => { + if url.is_some() && kind != CliPaneKind::Browser { + bail!("--url requires --kind browser"); + } + let client = ControlClient::new(resolve_socket_path(socket)); - let response = client - .send(ControlCommand::SplitPane { - workspace_id: workspace, - pane_id: pane, - axis: axis.into(), - }) + if kind == CliPaneKind::Terminal { + let response = client + .send(ControlCommand::SplitPane { + workspace_id: workspace, + pane_id: pane, + axis: axis.into(), + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + let response = send_control_command( + &client, + ControlCommand::SplitPane { + workspace_id: workspace, + pane_id: pane, + axis: axis.into(), + }, + ) .await?; - println!("{}", serde_json::to_string_pretty(&response)?); + let pane_id = match response { + ControlResponse::PaneSplit { pane_id } => pane_id, + other => bail!("unexpected split response: {other:?}"), + }; + let placeholder_surface_id = + active_surface_for_pane(&query_model(&client).await?, workspace, pane_id)?; + let surface_id = + create_surface(&client, workspace, pane_id, kind.into(), url.clone()) + .await?; + send_control_command( + &client, + ControlCommand::CloseSurface { + workspace_id: workspace, + pane_id, + surface_id: placeholder_surface_id, + }, + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "browser_surface_opened", + "workspace_id": workspace, + "pane_id": pane_id, + "surface_id": surface_id, + "replaced_surface_id": placeholder_surface_id, + "url": url, + }))? + ); + } } PaneCommand::Focus { socket, @@ -615,6 +685,7 @@ async fn main() -> anyhow::Result<()> { patch: PaneMetadataPatch { title, cwd, + url: None, repo_name: repo, git_branch: branch, ports: None, @@ -630,16 +701,38 @@ async fn main() -> anyhow::Result<()> { socket, workspace, pane, + kind, + url, } => { + if url.is_some() && kind != CliPaneKind::Browser { + bail!("--url requires --kind browser"); + } + let client = ControlClient::new(resolve_socket_path(socket)); - let response = client - .send(ControlCommand::CreateSurface { - workspace_id: workspace, - pane_id: pane, - kind: PaneKind::Terminal, - }) - .await?; - println!("{}", serde_json::to_string_pretty(&response)?); + if kind == CliPaneKind::Terminal { + let response = client + .send(ControlCommand::CreateSurface { + workspace_id: workspace, + pane_id: pane, + kind: PaneKind::Terminal, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + let surface_id = + create_surface(&client, workspace, pane, kind.into(), url.clone()).await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "surface_created", + "workspace_id": workspace, + "pane_id": pane, + "surface_id": surface_id, + "kind": "browser", + "url": url, + }))? + ); + } } SurfaceCommand::Focus { socket, @@ -719,6 +812,84 @@ fn resolve_socket_path(socket: Option) -> PathBuf { .unwrap_or_else(default_socket_path) } +async fn send_control_command( + client: &ControlClient, + command: ControlCommand, +) -> anyhow::Result { + let response = client.send(command).await?; + response.response.map_err(|error| anyhow!(error)) +} + +async fn query_model(client: &ControlClient) -> anyhow::Result { + let response = send_control_command( + client, + ControlCommand::QueryStatus { + query: ControlQuery::All, + }, + ) + .await?; + match response { + ControlResponse::Status { session } => Ok(session.model), + other => bail!("unexpected query response: {other:?}"), + } +} + +fn active_surface_for_pane( + model: &AppModel, + workspace_id: WorkspaceId, + pane_id: PaneId, +) -> anyhow::Result { + model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}")) +} + +async fn create_surface( + client: &ControlClient, + workspace_id: WorkspaceId, + pane_id: PaneId, + kind: PaneKind, + url: Option, +) -> anyhow::Result { + let response = send_control_command( + client, + ControlCommand::CreateSurface { + workspace_id, + pane_id, + kind, + }, + ) + .await?; + let surface_id = match response { + ControlResponse::SurfaceCreated { surface_id } => surface_id, + other => bail!("unexpected create surface response: {other:?}"), + }; + + if let Some(url) = url { + send_control_command( + client, + ControlCommand::UpdateSurfaceMetadata { + surface_id, + patch: PaneMetadataPatch { + title: None, + cwd: None, + url: Some(url), + repo_name: None, + git_branch: None, + ports: None, + agent_kind: None, + }, + }, + ) + .await?; + } + + Ok(surface_id) +} + fn infer_agent_kind(value: &str) -> Option { let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index 9461878..1a3f296 100644 --- a/crates/taskers-core/src/app_state.rs +++ b/crates/taskers-core/src/app_state.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; +use std::{collections::BTreeMap, path::PathBuf}; use anyhow::{Context, Result, anyhow}; use taskers_control::{ControlCommand, ControlResponse, InMemoryController}; -use taskers_domain::{AppModel, PaneId, WorkspaceId}; +use taskers_domain::{AppModel, PaneId, PaneKind, WorkspaceId}; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; @@ -112,17 +112,25 @@ impl AppState { .active_surface() .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?; - let mut env = self.shell_launch.env.clone(); - env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); - env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); - env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); + let (command_argv, env) = match surface.kind { + PaneKind::Terminal => { + let mut env = self.shell_launch.env.clone(); + env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); + env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); + env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); + (self.shell_launch.program_and_args(), env) + } + PaneKind::Browser => (Vec::new(), BTreeMap::new()), + }; Ok(SurfaceDescriptor { cols: 120, rows: 40, + kind: surface.kind.clone(), cwd: surface.metadata.cwd.clone(), title: surface.metadata.title.clone(), - command_argv: self.shell_launch.program_and_args(), + url: surface.metadata.url.clone(), + command_argv, env, }) } @@ -133,7 +141,7 @@ mod tests { use std::path::PathBuf; use taskers_control::{ControlCommand, ControlQuery}; - use taskers_domain::AppModel; + use taskers_domain::{AppModel, PaneKind, PaneMetadataPatch}; use taskers_ghostty::BackendChoice; use taskers_runtime::ShellLaunchSpec; @@ -164,6 +172,8 @@ mod tests { .surface_descriptor_for_pane(workspace, pane) .expect("descriptor"); + assert_eq!(descriptor.kind, PaneKind::Terminal); + assert_eq!(descriptor.url, None); assert_eq!(descriptor.command_argv, vec!["/bin/zsh", "-i"]); assert_eq!( descriptor.env.get("TASKERS_WORKSPACE_ID"), @@ -176,6 +186,47 @@ mod tests { assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID")); } + #[test] + fn browser_surface_descriptor_omits_shell_launch_and_keeps_url() { + let mut model = AppModel::new("Main"); + let workspace = model.active_workspace_id().expect("workspace"); + let pane = model.active_workspace().expect("workspace").active_pane; + let surface = model + .create_surface(workspace, pane, PaneKind::Browser) + .expect("browser surface"); + model + .update_surface_metadata( + surface, + PaneMetadataPatch { + title: Some("Taskers".into()), + cwd: None, + url: Some("https://example.com".into()), + repo_name: None, + git_branch: None, + ports: None, + agent_kind: None, + }, + ) + .expect("metadata updated"); + + let app_state = AppState::new( + model, + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::Mock, + ShellLaunchSpec::fallback(), + ) + .expect("app state"); + + let descriptor = app_state + .surface_descriptor_for_pane(workspace, pane) + .expect("descriptor"); + + assert_eq!(descriptor.kind, PaneKind::Browser); + assert_eq!(descriptor.url.as_deref(), Some("https://example.com")); + assert!(descriptor.command_argv.is_empty()); + assert!(descriptor.env.is_empty()); + } + #[test] fn revision_tracks_controller_mutations() { let app_state = AppState::new( diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 2d42896..414f03b 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -56,6 +56,7 @@ pub enum PaneKind { pub struct PaneMetadata { pub title: Option, pub cwd: Option, + pub url: Option, pub repo_name: Option, pub git_branch: Option, pub ports: Vec, @@ -69,6 +70,7 @@ pub struct PaneMetadata { pub struct PaneMetadataPatch { pub title: Option, pub cwd: Option, + pub url: Option, pub repo_name: Option, pub git_branch: Option, pub ports: Option>, @@ -897,6 +899,7 @@ impl AppModel { PaneMetadataPatch { title: Some("Codex".into()), cwd: Some("/home/notes/Projects/taskers".into()), + url: None, repo_name: Some("taskers".into()), git_branch: Some("main".into()), ports: Some(vec![3000]), @@ -921,6 +924,7 @@ impl AppModel { PaneMetadataPatch { title: Some("Claude".into()), cwd: Some("/home/notes/Projects/taskers".into()), + url: None, repo_name: Some("taskers".into()), git_branch: Some("feature/bootstrap".into()), ports: Some(vec![]), @@ -955,6 +959,7 @@ impl AppModel { PaneMetadataPatch { title: Some("OpenCode".into()), cwd: Some("/home/notes/Documents".into()), + url: None, repo_name: Some("notes".into()), git_branch: Some("docs".into()), ports: Some(vec![8080, 8081]), @@ -1410,6 +1415,9 @@ impl AppModel { if patch.cwd.is_some() { surface.metadata.cwd = patch.cwd; } + if patch.url.is_some() { + surface.metadata.url = patch.url; + } if patch.repo_name.is_some() { surface.metadata.repo_name = patch.repo_name; } @@ -2451,6 +2459,7 @@ mod tests { PaneMetadataPatch { title: Some("Codex".into()), cwd: None, + url: None, repo_name: None, git_branch: None, ports: None, diff --git a/crates/taskers-ghostty/src/backend.rs b/crates/taskers-ghostty/src/backend.rs index 3f56cbb..faf7305 100644 --- a/crates/taskers-ghostty/src/backend.rs +++ b/crates/taskers-ghostty/src/backend.rs @@ -1,6 +1,7 @@ use crate::runtime::{runtime_bridge_path, runtime_resources_dir}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use taskers_domain::PaneKind; use thiserror::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -32,8 +33,10 @@ pub struct BackendProbe { pub struct SurfaceDescriptor { pub cols: u16, pub rows: u16, + pub kind: PaneKind, pub cwd: Option, pub title: Option, + pub url: Option, #[serde(default)] pub command_argv: Vec, #[serde(default)] diff --git a/crates/taskers-macos-ffi/src/lib.rs b/crates/taskers-macos-ffi/src/lib.rs index aeea9f5..7b9f0bf 100644 --- a/crates/taskers-macos-ffi/src/lib.rs +++ b/crates/taskers-macos-ffi/src/lib.rs @@ -530,6 +530,7 @@ pub extern "C" fn taskers_macos_string_free(value: *mut c_char) { #[cfg(test)] mod tests { use serde_json::{Value, json}; + use taskers_control::ControlCommand; use tempfile::tempdir; use super::{CoreOptions, TaskersMacosCore}; @@ -539,7 +540,7 @@ mod tests { let temp = tempdir().expect("tempdir"); let session_path = temp.path().join("session.json"); let socket_path = temp.path().join("taskers.sock"); - let mut core = TaskersMacosCore::new( + let core = TaskersMacosCore::new( Some(session_path), Some(socket_path), Some("/bin/sh"), @@ -617,6 +618,69 @@ mod tests { ); } + #[test] + fn surface_descriptor_json_includes_browser_kind_and_url() { + let temp = tempdir().expect("tempdir"); + let session_path = temp.path().join("session.json"); + let socket_path = temp.path().join("taskers.sock"); + let mut core = TaskersMacosCore::new( + Some(session_path), + Some(socket_path), + Some("/bin/sh"), + false, + ) + .expect("core"); + + let model = core.app_state.snapshot_model(); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = core + .app_state + .dispatch(ControlCommand::CreateSurface { + workspace_id, + pane_id, + kind: taskers_domain::PaneKind::Browser, + }) + .expect("create browser surface"); + let surface_id = match surface_id { + taskers_control::ControlResponse::SurfaceCreated { surface_id } => surface_id, + other => panic!("unexpected response: {other:?}"), + }; + core.app_state + .dispatch(ControlCommand::UpdateSurfaceMetadata { + surface_id, + patch: taskers_domain::PaneMetadataPatch { + title: None, + cwd: None, + url: Some("https://example.com".into()), + repo_name: None, + git_branch: None, + ports: None, + agent_kind: None, + }, + }) + .expect("set browser url"); + + let descriptor = core + .surface_descriptor_json(&workspace_id.to_string(), &pane_id.to_string()) + .expect("descriptor"); + let descriptor: Value = serde_json::from_str(&descriptor).expect("descriptor json"); + assert_eq!( + descriptor.get("kind").and_then(Value::as_str), + Some("browser") + ); + assert_eq!( + descriptor.get("url").and_then(Value::as_str), + Some("https://example.com") + ); + assert!( + descriptor + .get("command_argv") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + ); + } + #[test] fn options_json_supports_explicit_mock_backend() { let temp = tempdir().expect("tempdir"); From 2590327b02661cfa60a7dd30bd7e1abd46682f2e Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 09:14:10 +0100 Subject: [PATCH 03/63] feat: wire macos browser host --- .../Sources/TaskersBrowserView.swift | 305 ++++++++++++++++++ .../Sources/TaskersCoreBridge.swift | 72 +++++ .../Sources/TaskersDefaultSurfaceHost.swift | 47 +++ .../Sources/TaskersMockSurfaceHost.swift | 8 +- .../Sources/TaskersWorkspaceController.swift | 71 +++- macos/TaskersMac/Sources/main.swift | 54 +++- .../TaskersBrowserViewTests.swift | 41 +++ macos/TaskersMacTests/TaskersSmokeTests.swift | 38 ++- macos/project.yml | 1 + 9 files changed, 620 insertions(+), 17 deletions(-) create mode 100644 macos/TaskersMac/Sources/TaskersBrowserView.swift create mode 100644 macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift create mode 100644 macos/TaskersMacTests/TaskersBrowserViewTests.swift diff --git a/macos/TaskersMac/Sources/TaskersBrowserView.swift b/macos/TaskersMac/Sources/TaskersBrowserView.swift new file mode 100644 index 0000000..5da1867 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersBrowserView.swift @@ -0,0 +1,305 @@ +import AppKit +import Foundation +import WebKit + +final class TaskersBrowserView: NSView, TaskersHostedSurface, WKNavigationDelegate { + let workspaceID: String + let paneID: String + let surfaceID: String + + private let core: TaskersCoreBridge + private let descriptor: TaskersSurfaceDescriptor + private let backButton = NSButton() + private let forwardButton = NSButton() + private let reloadButton = NSButton() + private let addressField = NSSearchField() + private let webView: WKWebView + private var titleObservation: NSKeyValueObservation? + private var urlObservation: NSKeyValueObservation? + private var canGoBackObservation: NSKeyValueObservation? + private var canGoForwardObservation: NSKeyValueObservation? + private var didRequestInitialFocus = false + private var lastReportedTitle: String? + private var lastReportedURL: String? + private var isDisposed = false + + override var acceptsFirstResponder: Bool { + true + } + + var hostingView: NSView { self } + + init( + core: TaskersCoreBridge, + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) { + self.core = core + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + self.descriptor = descriptor + self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + self.lastReportedTitle = Self.trimmed(descriptor.title) + self.lastReportedURL = Self.trimmed(descriptor.url) + + super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + configureChrome() + observeWebView() + loadInitialURLIfNeeded() + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + dispose() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard !didRequestInitialFocus, window != nil, Self.resolvedURL(from: descriptor.url) == nil else { + return + } + + didRequestInitialFocus = true + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window else { + return + } + window.makeFirstResponder(self.addressField) + } + } + + func dispose() { + guard !isDisposed else { + return + } + + isDisposed = true + titleObservation = nil + urlObservation = nil + canGoBackObservation = nil + canGoForwardObservation = nil + webView.navigationDelegate = nil + } + + static func resolvedURL(from rawValue: String?) -> URL? { + guard let trimmed = trimmed(rawValue), !trimmed.isEmpty else { + return nil + } + if hasExplicitScheme(trimmed) { + return URL(string: trimmed) + } + if trimmed.contains(where: { $0.isWhitespace }) { + var components = URLComponents(string: "https://duckduckgo.com/") + components?.queryItems = [URLQueryItem(name: "q", value: trimmed)] + return components?.url + } + if isLocalTarget(trimmed) { + return URL(string: "http://\(trimmed)") + } + return URL(string: "https://\(trimmed)") + } + + private func configureChrome() { + let toolbar = NSStackView() + toolbar.translatesAutoresizingMaskIntoConstraints = false + toolbar.orientation = .horizontal + toolbar.alignment = .centerY + toolbar.spacing = 8 + toolbar.edgeInsets = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) + + configureButton(backButton, symbolName: "chevron.left", action: #selector(goBack)) + configureButton(forwardButton, symbolName: "chevron.right", action: #selector(goForward)) + configureButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reloadPage)) + + addressField.translatesAutoresizingMaskIntoConstraints = false + addressField.placeholderString = "Enter URL or search" + addressField.sendsSearchStringImmediately = false + addressField.target = self + addressField.action = #selector(loadAddressFromField) + addressField.stringValue = descriptor.url ?? "" + + toolbar.addArrangedSubview(backButton) + toolbar.addArrangedSubview(forwardButton) + toolbar.addArrangedSubview(reloadButton) + toolbar.addArrangedSubview(addressField) + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.navigationDelegate = self + webView.allowsBackForwardNavigationGestures = true + + addSubview(toolbar) + addSubview(webView) + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: trailingAnchor), + toolbar.topAnchor.constraint(equalTo: topAnchor), + + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + webView.topAnchor.constraint(equalTo: toolbar.bottomAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor), + + addressField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280) + ]) + + updateChrome() + } + + private func configureButton(_ button: NSButton, symbolName: String, action: Selector) { + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .texturedRounded + button.target = self + button.action = action + if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { + button.image = image + } + } + + private func observeWebView() { + titleObservation = webView.observe(\.title, options: [.initial, .new]) { [weak self] _, _ in + self?.syncBrowserMetadata() + } + urlObservation = webView.observe(\.url, options: [.initial, .new]) { [weak self] _, _ in + self?.syncBrowserMetadata() + } + canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] _, _ in + self?.updateChrome() + } + canGoForwardObservation = webView.observe(\.canGoForward, options: [.initial, .new]) { [weak self] _, _ in + self?.updateChrome() + } + } + + private func loadInitialURLIfNeeded() { + guard let url = Self.resolvedURL(from: descriptor.url) else { + return + } + load(url) + } + + private func syncBrowserMetadata() { + updateChrome() + + let title = Self.trimmed(webView.title) + let currentURL = Self.trimmed(webView.url?.absoluteString) + guard title != lastReportedTitle || currentURL != lastReportedURL else { + return + } + + lastReportedTitle = title + lastReportedURL = currentURL + try? core.updateSurfaceMetadata(surfaceId: surfaceID, title: title, url: currentURL) + } + + private func updateChrome() { + backButton.isEnabled = webView.canGoBack + forwardButton.isEnabled = webView.canGoForward + reloadButton.isEnabled = true + if let url = webView.url?.absoluteString, !url.isEmpty { + addressField.stringValue = url + } else if addressField.stringValue.isEmpty { + addressField.stringValue = descriptor.url ?? "" + } + } + + private func load(_ url: URL) { + addressField.stringValue = url.absoluteString + webView.load(URLRequest(url: url)) + lastReportedURL = url.absoluteString + try? core.updateSurfaceMetadata(surfaceId: surfaceID, url: url.absoluteString) + } + + @objc private func goBack(_ sender: Any?) { + _ = sender + webView.goBack() + } + + @objc private func goForward(_ sender: Any?) { + _ = sender + webView.goForward() + } + + @objc private func reloadPage(_ sender: Any?) { + _ = sender + if webView.url == nil, let url = Self.resolvedURL(from: addressField.stringValue) { + load(url) + } else { + webView.reload() + } + } + + @objc private func loadAddressFromField(_ sender: Any?) { + _ = sender + guard let url = Self.resolvedURL(from: addressField.stringValue) else { + return + } + load(url) + } + + private static func trimmed(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return value + } + + private static func hasExplicitScheme(_ value: String) -> Bool { + if value.contains("://") { + return true + } + guard let colonIndex = value.firstIndex(of: ":") else { + return false + } + let scheme = String(value[.. Bool { + let lowercased = value.lowercased() + if lowercased.hasPrefix("localhost") + || lowercased.hasPrefix("127.0.0.1") + || lowercased.hasPrefix("[::1]") { + return true + } + if value.contains("/") { + return false + } + guard let separator = value.lastIndex(of: ":") else { + return false + } + let host = String(value[.. String { + let response = try dispatch(command: [ + "command": "split_pane", + "workspace_id": workspaceId, + "pane_id": paneId, + "axis": axis + ]) + return try requiredString("pane_id", in: response, context: "split pane") + } + + func createSurface( + workspaceId: String, + paneId: String, + kind: TaskersSurfaceKind + ) throws -> String { + let response = try dispatch(command: [ + "command": "create_surface", + "workspace_id": workspaceId, + "pane_id": paneId, + "kind": kind.rawValue + ]) + return try requiredString("surface_id", in: response, context: "create surface") + } + + func closeSurface(workspaceID: String, paneID: String, surfaceID: String) throws { + _ = try dispatch(command: [ + "command": "close_surface", + "workspace_id": workspaceID, + "pane_id": paneID, + "surface_id": surfaceID + ]) + } + + func updateSurfaceMetadata(surfaceId: String, title: String? = nil, url: String? = nil) throws { + var patch: [String: Any] = [:] + if let title { + patch["title"] = title + } + if let url { + patch["url"] = url + } + guard !patch.isEmpty else { + return + } + + _ = try dispatch(command: [ + "command": "update_surface_metadata", + "surface_id": surfaceId, + "patch": patch + ]) + } + private func decode(_ type: T.Type, from string: String) throws -> T { do { return try decoder.decode(T.self, from: Data(string.utf8)) @@ -123,6 +184,17 @@ final class TaskersCoreBridge { } } + private func requiredString( + _ key: String, + in payload: [String: Any], + context: String + ) throws -> String { + guard let value = payload[key] as? String else { + throw TaskersCoreBridgeError.invalidResponse("\(context) response missing \(key)") + } + return value + } + private func callString(_ body: () -> UnsafeMutablePointer?) throws -> String { guard let pointer = body() else { throw TaskersCoreBridgeError.callFailed(Self.lastError()) diff --git a/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift b/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift new file mode 100644 index 0000000..c3768cc --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift @@ -0,0 +1,47 @@ +import Foundation + +final class TaskersDefaultSurfaceHost: TaskersSurfaceHosting { + private let core: TaskersCoreBridge + private let ghosttyHost: TaskersGhosttyHost + + var onSurfaceClosed: ((String, String, String) -> Void)? { + didSet { + ghosttyHost.onSurfaceClosed = onSurfaceClosed + } + } + + init(core: TaskersCoreBridge, ghosttyHost: TaskersGhosttyHost = TaskersGhosttyHost()) { + self.core = core + self.ghosttyHost = ghosttyHost + self.ghosttyHost.onSurfaceClosed = onSurfaceClosed + } + + func makeSurface( + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws -> any TaskersHostedSurface { + switch descriptor.kind { + case .terminal: + return try ghosttyHost.makeSurface( + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID, + descriptor: descriptor + ) + case .browser: + return TaskersBrowserView( + core: core, + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID, + descriptor: descriptor + ) + } + } + + func setFocused(_ focused: Bool) { + ghosttyHost.setFocused(focused) + } +} diff --git a/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift b/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift index 648147b..d3e7add 100644 --- a/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift +++ b/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift @@ -40,7 +40,13 @@ final class TaskersMockSurfaceHost: TaskersSurfaceHosting { _ = workspaceID _ = paneID _ = surfaceID - return TaskersMockSurfaceView(title: descriptor.title) + let title = switch descriptor.kind { + case .terminal: + descriptor.title + case .browser: + descriptor.title ?? descriptor.url ?? "Taskers Mock Browser" + } + return TaskersMockSurfaceView(title: title) } func setFocused(_ focused: Bool) { diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index 3e81ded..af3f35b 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -109,6 +109,23 @@ final class WeightedSplitView: NSSplitView { } } +enum TaskersWorkspaceControllerError: LocalizedError { + case missingActiveWorkspace + case missingWorkspace(String) + case missingPane(String) + + var errorDescription: String? { + switch self { + case .missingActiveWorkspace: + return "no active workspace is available" + case .missingWorkspace(let workspaceID): + return "workspace \(workspaceID) is not present" + case .missingPane(let paneID): + return "pane \(paneID) is not present" + } + } +} + final class TaskersWorkspaceController: NSWindowController { private let core: TaskersCoreBridge private let surfaceHost: any TaskersSurfaceHosting @@ -125,9 +142,9 @@ final class TaskersWorkspaceController: NSWindowController { Set(surfaceRegistry.keys) } - init(core: TaskersCoreBridge, ghosttyHost: any TaskersSurfaceHosting) { + init(core: TaskersCoreBridge, surfaceHost: any TaskersSurfaceHosting) { self.core = core - self.surfaceHost = ghosttyHost + self.surfaceHost = surfaceHost let window = NSWindow( contentRect: NSRect(x: 80, y: 80, width: 1380, height: 900), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -164,6 +181,38 @@ final class TaskersWorkspaceController: NSWindowController { } } + func openBrowserSplit(url: String? = nil) throws { + let snapshot = try core.snapshot() + guard let workspace = snapshot.activeWorkspace else { + throw TaskersWorkspaceControllerError.missingActiveWorkspace + } + + let newPaneID = try core.splitPane( + workspaceId: workspace.id, + paneId: workspace.activePane, + axis: "horizontal" + ) + let placeholderSurfaceID = try activeSurfaceID( + workspaceID: workspace.id, + paneID: newPaneID + ) + let browserSurfaceID = try core.createSurface( + workspaceId: workspace.id, + paneId: newPaneID, + kind: .browser + ) + if let url = url?.trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty { + try core.updateSurfaceMetadata(surfaceId: browserSurfaceID, url: url) + } + try core.closeSurface( + workspaceID: workspace.id, + paneID: newPaneID, + surfaceID: placeholderSurfaceID + ) + try refresh(force: true) + } + func refresh(force: Bool) throws { let currentRevision = core.revision if !force, lastRevision == currentRevision { @@ -300,12 +349,18 @@ final class TaskersWorkspaceController: NSWindowController { private func closeSurface(workspaceID: String, paneID: String, surfaceID: String) { taskersMacDebugLog("close surface command workspace=\(workspaceID) pane=\(paneID) surface=\(surfaceID)") - _ = try? core.dispatch(command: [ - "command": "close_surface", - "workspace_id": workspaceID, - "pane_id": paneID, - "surface_id": surfaceID - ]) + try? core.closeSurface(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + + private func activeSurfaceID(workspaceID: String, paneID: String) throws -> String { + let snapshot = try core.snapshot() + guard let workspace = snapshot.workspaces[workspaceID] else { + throw TaskersWorkspaceControllerError.missingWorkspace(workspaceID) + } + guard let pane = workspace.panes[paneID] else { + throw TaskersWorkspaceControllerError.missingPane(paneID) + } + return pane.activeSurface } private func makeMessageView(_ message: String) -> NSView { diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index f687d0c..f44ae5c 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -3,7 +3,7 @@ import Foundation final class TaskersMacApplication: NSObject, NSApplicationDelegate { private var core: TaskersCoreBridge? - private var ghosttyHost: TaskersGhosttyHost? + private var surfaceHost: TaskersDefaultSurfaceHost? private var workspaceController: TaskersWorkspaceController? func applicationDidFinishLaunching(_ notification: Notification) { @@ -16,15 +16,16 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { } do { + configureMenus() TaskersEnvironment.emitSmokeLog("creating core bridge") let core = try TaskersCoreBridge(options: TaskersEnvironment.defaultCoreOptions()) - TaskersEnvironment.emitSmokeLog("creating Ghostty host") - let ghosttyHost = TaskersGhosttyHost() + TaskersEnvironment.emitSmokeLog("creating surface host") + let surfaceHost = TaskersDefaultSurfaceHost(core: core) TaskersEnvironment.emitSmokeLog("creating workspace controller") - let controller = TaskersWorkspaceController(core: core, ghosttyHost: ghosttyHost) + let controller = TaskersWorkspaceController(core: core, surfaceHost: surfaceHost) self.core = core - self.ghosttyHost = ghosttyHost + self.surfaceHost = surfaceHost self.workspaceController = controller TaskersEnvironment.emitSmokeLog("starting workspace controller") @@ -50,18 +51,57 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { func applicationDidBecomeActive(_ notification: Notification) { _ = notification - ghosttyHost?.setFocused(true) + surfaceHost?.setFocused(true) } func applicationDidResignActive(_ notification: Notification) { _ = notification - ghosttyHost?.setFocused(false) + surfaceHost?.setFocused(false) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { _ = sender return !TaskersEnvironment.isRunningUnderXCTest } + + @objc private func openBrowserInSplit(_ sender: Any?) { + _ = sender + do { + try workspaceController?.openBrowserSplit() + } catch { + NSApp.presentError(error) + } + } + + private func configureMenus() { + let mainMenu = NSMenu() + + let appMenuItem = NSMenuItem() + let appMenu = NSMenu(title: "Taskers") + appMenu.addItem( + withTitle: "Quit Taskers", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + let surfaceMenuItem = NSMenuItem() + let surfaceMenu = NSMenu(title: "Surface") + let browserItem = NSMenuItem( + title: "Open Browser in Split", + action: #selector(openBrowserInSplit(_:)), + keyEquivalent: "L" + ) + browserItem.keyEquivalentModifierMask = [.command, .shift] + browserItem.target = self + surfaceMenu.addItem(browserItem) + surfaceMenuItem.title = "Surface" + surfaceMenuItem.submenu = surfaceMenu + mainMenu.addItem(surfaceMenuItem) + + NSApp.mainMenu = mainMenu + } } let app = NSApplication.shared diff --git a/macos/TaskersMacTests/TaskersBrowserViewTests.swift b/macos/TaskersMacTests/TaskersBrowserViewTests.swift new file mode 100644 index 0000000..e2ce06a --- /dev/null +++ b/macos/TaskersMacTests/TaskersBrowserViewTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import TaskersMac + +final class TaskersBrowserViewTests: XCTestCase { + func testResolvedURLPreservesExplicitSchemes() { + XCTAssertEqual( + TaskersBrowserView.resolvedURL(from: "https://example.com/docs")?.absoluteString, + "https://example.com/docs" + ) + XCTAssertEqual( + TaskersBrowserView.resolvedURL(from: "about:blank")?.absoluteString, + "about:blank" + ) + } + + func testResolvedURLUsesDuckDuckGoForQueries() { + let url = TaskersBrowserView.resolvedURL(from: "taskers browser integration") + let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } + + XCTAssertEqual(components?.host, "duckduckgo.com") + XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "q" })?.value, "taskers browser integration") + } + + func testResolvedURLUsesHttpForLocalTargets() { + XCTAssertEqual( + TaskersBrowserView.resolvedURL(from: "localhost:3777")?.absoluteString, + "http://localhost:3777" + ) + XCTAssertEqual( + TaskersBrowserView.resolvedURL(from: "devbox:8080")?.absoluteString, + "http://devbox:8080" + ) + } + + func testResolvedURLDefaultsToHttpsForHosts() { + XCTAssertEqual( + TaskersBrowserView.resolvedURL(from: "taskers.dev")?.absoluteString, + "https://taskers.dev" + ) + } +} diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift index 98fdf4a..fc8cf65 100644 --- a/macos/TaskersMacTests/TaskersSmokeTests.swift +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -42,7 +42,7 @@ final class TaskersSmokeTests: XCTestCase { backend: "mock" )) let host = TaskersMockSurfaceHost() - let controller = TaskersWorkspaceController(core: core, ghosttyHost: host) + let controller = TaskersWorkspaceController(core: core, surfaceHost: host) smokeLog("show window") controller.showWindow(nil) @@ -90,6 +90,42 @@ final class TaskersSmokeTests: XCTestCase { smokeLog("controller close end") } + func testWorkspaceControllerOpensBrowserSplitInActivePane() throws { + let core = try TaskersCoreBridge(options: TaskersCoreOptions( + sessionPath: tempDirectory.appendingPathComponent("session.json").path, + socketPath: tempDirectory.appendingPathComponent("taskers.sock").path, + configuredShell: "/bin/sh", + demo: false, + backend: "mock" + )) + let host = TaskersMockSurfaceHost() + let controller = TaskersWorkspaceController(core: core, surfaceHost: host) + + try controller.start() + drainMainRunLoop() + + try controller.openBrowserSplit(url: "taskers.dev") + drainMainRunLoop() + + let workspace = try XCTUnwrap(try core.snapshot().activeWorkspace) + var browserDescriptor: TaskersSurfaceDescriptor? + for (paneID, _) in workspace.panes.elements { + let descriptor = try core.surfaceDescriptor(workspaceId: workspace.id, paneId: paneID) + if descriptor.kind == .browser { + browserDescriptor = descriptor + break + } + } + + XCTAssertEqual(controller.surfaceCount, 2) + let descriptor = try XCTUnwrap(browserDescriptor) + XCTAssertEqual(descriptor.kind, .browser) + XCTAssertEqual(descriptor.url, "taskers.dev") + + controller.close() + drainMainRunLoop() + } + private func bootstrapTestEnvironment() throws { let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/macos/project.yml b/macos/project.yml index 7aa3fe3..d7e3f2a 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -25,6 +25,7 @@ targets: - sdk: Carbon.framework - sdk: libc++.tbd - sdk: liblzma.tbd + - sdk: WebKit.framework settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.app From 2baf394b3b623784bee51a82bfb04a34ae3ae810 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 11:39:06 +0100 Subject: [PATCH 04/63] feat: embed browser surfaces in linux gtk shell --- Cargo.lock | 83 +++ Cargo.toml | 1 + crates/taskers-app/Cargo.toml | 1 + crates/taskers-app/src/main.rs | 743 +++++++++++++++++++++-- crates/taskers-app/src/settings_store.rs | 36 +- 5 files changed, 800 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 509facb..5d6690e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,29 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "javascriptcore6" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" +dependencies = [ + "glib", + "javascriptcore6-sys", + "libc", +] + +[[package]] +name = "javascriptcore6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1425,6 +1448,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1612,6 +1661,7 @@ dependencies = [ "time", "tokio", "toml 0.8.23", + "webkit6", ] [[package]] @@ -2057,6 +2107,39 @@ dependencies = [ "semver", ] +[[package]] +name = "webkit6" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "javascriptcore6", + "libc", + "soup3", + "webkit6-sys", +] + +[[package]] +name = "webkit6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "javascriptcore6-sys", + "libc", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index c3bd846..7e9cdb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde" tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } +webkit6 = { version = "0.6.1", features = ["v2_50"] } xz2 = "0.1" taskers-core = { version = "0.3.0", path = "crates/taskers-core" } taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index c34142f..b081898 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -30,6 +30,7 @@ taskers-domain = { version = "0.3.0", path = "../taskers-domain" } taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } time.workspace = true toml.workspace = true +webkit6.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index cea2f36..ac35ccc 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -7,6 +7,7 @@ mod themes; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet}, + fs, future::pending, path::PathBuf, process::{Command, Stdio}, @@ -27,7 +28,7 @@ use serde_json::json; use settings_store::{AppConfig, ShortcutAction, ShortcutPreset}; use svgtypes::{SimplePathSegment, SimplifyingPathParser}; use taskers_control::{ - ControlCommand, InMemoryController, bind_socket, default_socket_path, serve, + ControlCommand, ControlResponse, InMemoryController, bind_socket, default_socket_path, serve, }; use taskers_core::{AppState, PaneRuntimeSnapshot, default_session_path, load_or_bootstrap}; use taskers_domain::{ @@ -51,6 +52,10 @@ use terminal_transitions::{ TransitionItemKind, TransitionPhase, WorkspaceSceneSnapshot, WorkspaceWindowSnapshot, derive_pane_frames, plan_workspace_transition, retarget_transition_plan, }; +use webkit6::{ + LoadEvent, NavigationPolicyDecision, NetworkSession, PolicyDecisionType, + ResponsePolicyDecision, Settings as WebKitSettings, WebView, prelude::*, +}; #[derive(Debug, Clone, Parser)] #[command(name = "taskers")] @@ -89,6 +94,8 @@ struct UiHandle { crash_reporter: CrashReporter, ghostty_host: Option, ghostty_surfaces: RefCell>, + browser_surfaces: RefCell>, + browser_network_session: RefCell>, shell: RefCell>, pane_cards: RefCell>, settings: RefCell, @@ -140,6 +147,18 @@ struct WorkspaceStageWidgets { ghost_layer: Fixed, } +#[derive(Clone)] +struct BrowserSurfaceWidgets { + root: GtkBox, + web_view: WebView, + address_entry: Entry, + back_button: Button, + forward_button: Button, + reload_button: Button, + devtools_button: Button, + devtools_open: Rc>, +} + #[derive(Default)] struct WorkspaceTransitionState { presented: HashMap, @@ -331,6 +350,7 @@ const SURFACE_TAB_MIN_WIDTH: i32 = 72; const SURFACE_TAB_MAX_WIDTH: i32 = 220; const SIDEBAR_MIN_WIDTH: i32 = 224; const TOOLBAR_ACTION_NEW_GLYPH: &str = "+"; +const TOOLBAR_ACTION_BROWSER_GLYPH: &str = "Web"; const TOOLBAR_ACTION_RESIZE_GLYPH: &str = "\u{2922}"; const TOOLBAR_ACTION_FOCUS_GLYPH: &str = "\u{25ce}"; @@ -399,6 +419,8 @@ impl UiHandle { crash_reporter, ghostty_host, ghostty_surfaces: RefCell::new(HashMap::new()), + browser_surfaces: RefCell::new(HashMap::new()), + browser_network_session: RefCell::new(None), shell: RefCell::new(None), pane_cards: RefCell::new(HashMap::new()), settings: RefCell::new(app_config), @@ -457,6 +479,20 @@ impl UiHandle { self.refresh(before != after); } + fn dispatch_with_response(self: &Rc, command: ControlCommand) -> Option { + let before = self.app_state.snapshot_model(); + let response = match self.app_state.dispatch(command) { + Ok(response) => response, + Err(error) => { + self.toast(&error.to_string()); + return None; + } + }; + let after = self.app_state.snapshot_model(); + self.refresh(before != after); + Some(response) + } + fn active_top_level_resize_preview( &self, workspace_id: taskers_domain::WorkspaceId, @@ -807,6 +843,48 @@ impl UiHandle { Some(widget) } + fn browser_network_session(&self) -> NetworkSession { + if let Some(session) = self.browser_network_session.borrow().clone() { + return session; + } + + let session = match build_browser_network_session() { + Ok(session) => session, + Err(error) => { + self.toast(&format!( + "failed to initialize persistent browser profile: {error}; using an ephemeral browser session" + )); + NetworkSession::new_ephemeral() + } + }; + *self.browser_network_session.borrow_mut() = Some(session.clone()); + session + } + + fn browser_widget( + self: &Rc, + workspace_id: taskers_domain::WorkspaceId, + pane_id: taskers_domain::PaneId, + surface: &SurfaceRecord, + ) -> Option { + if surface.kind != PaneKind::Browser { + return None; + } + + if let Some(widgets) = self.browser_surfaces.borrow().get(&surface.id).cloned() { + detach_widget(widgets.root.upcast_ref()); + sync_browser_surface_state(self, surface, &widgets); + return Some(widgets); + } + + let widgets = build_browser_surface_widgets(self, workspace_id, pane_id, surface)?; + self.browser_surfaces + .borrow_mut() + .insert(surface.id, widgets.clone()); + sync_browser_surface_state(self, surface, &widgets); + Some(widgets) + } + fn present_window(&self) { self.window.present(); } @@ -841,6 +919,9 @@ impl UiHandle { self.ghostty_surfaces .borrow_mut() .retain(|id, _| live.contains(id)); + self.browser_surfaces + .borrow_mut() + .retain(|id, _| live.contains(id)); } fn render_model(self: &Rc, model: &AppModel) { @@ -1095,6 +1176,18 @@ impl UiHandle { })), ) }; + let (cached_browser_surface_ids, attached_browser_surface_ids) = { + let browser_surfaces = self.browser_surfaces.borrow(); + ( + sorted_id_strings(browser_surfaces.keys().copied()), + sorted_id_strings(browser_surfaces.iter().filter_map(|(id, widgets)| { + live_layout_host.as_ref().and_then(|layout_host| { + widget_is_descendant_of(widgets.root.upcast_ref(), layout_host) + .then_some(*id) + }) + })), + ) + }; let ( layout_host_child_count, @@ -1165,6 +1258,17 @@ impl UiHandle { widget_contains_window_focus(&self.window, card.root.upcast_ref()) }) }); + let active_browser_uri = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&workspace.active_pane)) + .and_then(|pane| pane.active_surface()) + .filter(|surface| surface.kind == PaneKind::Browser) + .and_then(browser_surface_url) + .map(str::to_string); + let active_browser_widget_type = model + .active_workspace() + .and_then(|workspace| active_browser_widgets(self, workspace)) + .map(|widgets| widgets.web_view.type_().name().to_string()); let all_live_pane_ids = sorted_id_strings( model @@ -1287,6 +1391,8 @@ impl UiHandle { "attached_pane_card_ids": attached_pane_card_ids, "cached_ghostty_surface_ids": cached_ghostty_surface_ids, "attached_ghostty_surface_ids": attached_ghostty_surface_ids, + "cached_browser_surface_ids": cached_browser_surface_ids, + "attached_browser_surface_ids": attached_browser_surface_ids, "layout_host_child_count": layout_host_child_count, "layout_root_widget_type": layout_root_widget_type, "viewport_x": viewport.x, @@ -1299,6 +1405,8 @@ impl UiHandle { "active_pane_focus_widget_type": active_pane_focus_widget_type, "active_pane_focus_has_focus": active_pane_focus_has_focus, "active_pane_card_has_focus": active_pane_card_has_focus, + "active_browser_uri": active_browser_uri, + "active_browser_widget_type": active_browser_widget_type, "overview_mode": self.overview_mode.get(), }); @@ -1440,6 +1548,17 @@ impl UiHandle { }); header_actions.append(&split_down_btn); + let browser_split_btn = Button::with_label(TOOLBAR_ACTION_BROWSER_GLYPH); + browser_split_btn.add_css_class("pane-action"); + browser_split_btn.add_css_class("pane-split-action"); + browser_split_btn.set_tooltip_text(Some("Open browser in split")); + let browser_ui = Rc::clone(self); + let browser_pane_id = pane.id; + browser_split_btn.connect_clicked(move |_| { + open_browser_split(&browser_ui, workspace_id, browser_pane_id, None); + }); + header_actions.append(&browser_split_btn); + let resize_button = Button::with_label(TOOLBAR_ACTION_RESIZE_GLYPH); resize_button.add_css_class("pane-action"); resize_button.add_css_class("pane-window-action"); @@ -1597,6 +1716,17 @@ impl UiHandle { }); content.append(&split_down); + let browser_split = Button::with_label("Open Browser in Split"); + browser_split.add_css_class("flat"); + browser_split.add_css_class("context-item"); + let browser_ui = Rc::clone(&ctx_ui); + let browser_pop = popover.clone(); + browser_split.connect_clicked(move |_| { + browser_pop.popdown(); + open_browser_split(&browser_ui, workspace_id, ctx_pane_id, None); + }); + content.append(&browser_split); + let sep = Separator::new(Orientation::Horizontal); sep.add_css_class("context-separator"); content.append(&sep); @@ -2229,6 +2359,29 @@ fn connect_navigation_shortcuts(ui: &Rc) { return glib::Propagation::Proceed; }; + if shortcuts_ui.shortcut_matches(ShortcutAction::OpenBrowserSplit, key, state) { + open_browser_split(&shortcuts_ui, workspace.id, workspace.active_pane, None); + return glib::Propagation::Stop; + } + + if shortcuts_ui.shortcut_matches(ShortcutAction::FocusBrowserAddress, key, state) + && focus_active_browser_address(&shortcuts_ui, workspace) + { + return glib::Propagation::Stop; + } + + if shortcuts_ui.shortcut_matches(ShortcutAction::ReloadBrowserPage, key, state) + && reload_active_browser(&shortcuts_ui, workspace) + { + return glib::Propagation::Stop; + } + + if shortcuts_ui.shortcut_matches(ShortcutAction::ToggleBrowserDevtools, key, state) + && toggle_active_browser_devtools(&shortcuts_ui, workspace) + { + return glib::Propagation::Stop; + } + if shortcuts_ui.shortcut_matches(ShortcutAction::CloseTerminal, key, state) { shortcuts_ui.dispatch(ControlCommand::ClosePane { workspace_id: workspace.id, @@ -4620,7 +4773,7 @@ fn initialize_terminal_body( .active_surface() .is_some_and(|surface| surface.kind == PaneKind::Browser) { - return initialize_browser_placeholder_body(ui, pane, card); + return initialize_browser_body(ui, workspace_id, pane, card); } if let Some(widget) = ui.terminal_widget(workspace_id, pane) { @@ -4709,66 +4862,32 @@ fn initialize_terminal_body( entry.upcast() } -fn initialize_browser_placeholder_body( +fn initialize_browser_body( ui: &Rc, + workspace_id: taskers_domain::WorkspaceId, pane: &PaneRecord, card: &PaneCardWidgets, ) -> Widget { - let root = GtkBox::new(Orientation::Vertical, 10); - root.set_hexpand(true); - root.set_vexpand(true); - root.set_valign(Align::Center); - root.set_margin_start(20); - root.set_margin_end(20); - root.set_margin_top(20); - root.set_margin_bottom(20); - root.add_css_class("terminal-output"); - - let title = Label::new(Some( - "Browser surfaces currently render only in the native macOS host.", - )); - title.set_wrap(true); - title.set_xalign(0.0); - root.append(&title); - - let detail = Label::new(Some( - "This Linux/GTK shell keeps the browser surface metadata in sync and can hand the URL off to your default browser.", - )); - detail.add_css_class("pane-meta"); - detail.set_wrap(true); - detail.set_xalign(0.0); - root.append(&detail); + let Some(surface) = pane.active_surface() else { + let unavailable = Label::new(Some("Browser surface unavailable.")); + unavailable.add_css_class("pane-meta"); + unavailable.set_xalign(0.0); + card.terminal_host.append(&unavailable); + return unavailable.upcast(); + }; - let mut focus_target: Widget = root.clone().upcast(); - if let Some(url) = pane - .active_surface() - .and_then(browser_surface_url) - .map(str::to_string) - { - let url_label = Label::new(Some(&format!("URL: {url}"))); - url_label.add_css_class("pane-meta"); - url_label.set_wrap(true); - url_label.set_xalign(0.0); - url_label.set_selectable(true); - root.append(&url_label); - - let open_uri = resolved_browser_uri(&url); - let open_button = Button::with_label("Open in Default Browser"); - let open_ui = Rc::clone(ui); - open_button.connect_clicked(move |_| { - if let Err(error) = gtk::gio::AppInfo::launch_default_for_uri( - &open_uri, - None::<>k::gio::AppLaunchContext>, - ) { - open_ui.toast(&format!("failed to open browser URL: {error}")); - } - }); - root.append(&open_button); - focus_target = open_button.upcast(); + if let Some(widgets) = ui.browser_widget(workspace_id, pane.id, surface) { + widgets.root.set_hexpand(true); + widgets.root.set_vexpand(true); + card.terminal_host.append(&widgets.root); + return widgets.web_view.clone().upcast(); } - card.terminal_host.append(&root); - focus_target + let unavailable = Label::new(Some("Browser surface unavailable.")); + unavailable.add_css_class("pane-meta"); + unavailable.set_xalign(0.0); + card.terminal_host.append(&unavailable); + unavailable.upcast() } fn sync_terminal_body( @@ -4777,16 +4896,24 @@ fn sync_terminal_body( pane: &PaneRecord, card: &PaneCardWidgets, ) { - if !terminal_body_needs_refresh( + let needs_refresh = terminal_body_needs_refresh( card.displayed_surface_id.get(), pane.active_surface().map(|surface| surface.id), card.terminal_host.first_child().is_some(), - ) { - return; + ); + + if needs_refresh { + clear_box(&card.terminal_host); + let _ = initialize_terminal_body(ui, workspace_id, pane, card); } - clear_box(&card.terminal_host); - let _ = initialize_terminal_body(ui, workspace_id, pane, card); + if let Some(surface) = pane + .active_surface() + .filter(|surface| surface.kind == PaneKind::Browser) + && let Some(widgets) = ui.browser_surfaces.borrow().get(&surface.id).cloned() + { + sync_browser_surface_state(ui, surface, &widgets); + } } fn terminal_body_needs_refresh( @@ -4797,6 +4924,424 @@ fn terminal_body_needs_refresh( !has_child || displayed_surface_id != next_surface_id } +fn build_browser_surface_widgets( + ui: &Rc, + workspace_id: taskers_domain::WorkspaceId, + pane_id: taskers_domain::PaneId, + surface: &SurfaceRecord, +) -> Option { + let root = GtkBox::new(Orientation::Vertical, 0); + root.set_hexpand(true); + root.set_vexpand(true); + + let toolbar = GtkBox::new(Orientation::Horizontal, 6); + toolbar.set_margin_start(10); + toolbar.set_margin_end(10); + toolbar.set_margin_top(8); + toolbar.set_margin_bottom(8); + root.append(&toolbar); + + let back_button = Button::with_label("\u{2190}"); + back_button.set_tooltip_text(Some("Back")); + toolbar.append(&back_button); + + let forward_button = Button::with_label("\u{2192}"); + forward_button.set_tooltip_text(Some("Forward")); + toolbar.append(&forward_button); + + let reload_button = Button::with_label("\u{21bb}"); + reload_button.set_tooltip_text(Some("Reload")); + toolbar.append(&reload_button); + + let address_entry = Entry::new(); + address_entry.set_hexpand(true); + address_entry.set_placeholder_text(Some("Enter URL or search query")); + toolbar.append(&address_entry); + + let devtools_button = Button::with_label("Devtools"); + devtools_button.set_tooltip_text(Some("Toggle browser devtools")); + toolbar.append(&devtools_button); + + let settings = WebKitSettings::builder() + .enable_back_forward_navigation_gestures(true) + .enable_developer_extras(true) + .build(); + let network_session = ui.browser_network_session(); + let web_view = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .network_session(&network_session) + .settings(&settings) + .build(); + root.append(&web_view); + + let widgets = BrowserSurfaceWidgets { + root, + web_view, + address_entry, + back_button, + forward_button, + reload_button, + devtools_button, + devtools_open: Rc::new(Cell::new(false)), + }; + + let focus_click = gtk::GestureClick::new(); + let focus_ui = Rc::clone(ui); + focus_click.connect_pressed(move |_, _, _, _| { + focus_ui.dispatch(ControlCommand::FocusPane { + workspace_id, + pane_id, + }); + }); + widgets.web_view.add_controller(focus_click); + + let nav_widgets = widgets.clone(); + widgets.back_button.connect_clicked(move |_| { + nav_widgets.web_view.go_back(); + nav_widgets.web_view.grab_focus(); + sync_browser_navigation_controls(&nav_widgets); + }); + + let nav_widgets = widgets.clone(); + widgets.forward_button.connect_clicked(move |_| { + nav_widgets.web_view.go_forward(); + nav_widgets.web_view.grab_focus(); + sync_browser_navigation_controls(&nav_widgets); + }); + + let nav_widgets = widgets.clone(); + widgets.reload_button.connect_clicked(move |_| { + nav_widgets.web_view.reload(); + nav_widgets.web_view.grab_focus(); + }); + + let load_widgets = widgets.clone(); + widgets.address_entry.connect_activate(move |entry| { + let raw = entry.text().to_string(); + if raw.trim().is_empty() { + return; + } + + let target = resolved_browser_uri(&raw); + entry.set_text(&target); + load_widgets.web_view.load_uri(&target); + load_widgets.web_view.grab_focus(); + }); + + let devtools_widgets = widgets.clone(); + widgets.devtools_button.connect_clicked(move |_| { + toggle_browser_devtools(&devtools_widgets); + }); + + let sync_widgets = widgets.clone(); + widgets.web_view.connect_load_changed(move |_, _| { + sync_browser_navigation_controls(&sync_widgets); + }); + + let sync_widgets = widgets.clone(); + widgets.web_view.connect_uri_notify(move |web_view| { + sync_browser_navigation_controls(&sync_widgets); + if sync_widgets.address_entry.has_focus() { + return; + } + if let Some(uri) = web_view.uri().map(|value| value.to_string()) { + sync_widgets.address_entry.set_text(&uri); + } + }); + + if let Some(inspector) = widgets.web_view.inspector() { + let inspector_widgets = widgets.clone(); + inspector.connect_closed(move |_| { + inspector_widgets.devtools_open.set(false); + sync_browser_navigation_controls(&inspector_widgets); + }); + } + + let title_ui = Rc::clone(ui); + let surface_id = surface.id; + widgets.web_view.connect_title_notify(move |web_view| { + let title = web_view.title().map(|value| value.to_string()); + let current = title_ui.app_state.snapshot_model(); + let current = surface_record_by_id(¤t, surface_id) + .and_then(|surface| surface.metadata.title.clone()); + if current == title { + return; + } + let deferred_ui = Rc::clone(&title_ui); + glib::idle_add_local_once(move || { + deferred_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { + surface_id, + patch: PaneMetadataPatch { + title, + ..PaneMetadataPatch::default() + }, + }); + }); + }); + + let url_ui = Rc::clone(ui); + let surface_id = surface.id; + widgets.web_view.connect_uri_notify(move |web_view| { + let url = web_view.uri().map(|value| value.to_string()); + let current = url_ui.app_state.snapshot_model(); + let current = surface_record_by_id(¤t, surface_id) + .and_then(browser_surface_url) + .map(str::to_string); + if current == url { + return; + } + let deferred_ui = Rc::clone(&url_ui); + glib::idle_add_local_once(move || { + deferred_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { + surface_id, + patch: PaneMetadataPatch { + url, + ..PaneMetadataPatch::default() + }, + }); + }); + }); + + let error_ui = Rc::clone(ui); + widgets + .web_view + .connect_load_failed(move |_, _, uri, error| { + error_ui.toast(&format!("failed to load {uri}: {error}")); + false + }); + + let policy_ui = Rc::clone(ui); + widgets + .web_view + .connect_decide_policy(move |_, decision, decision_type| { + match decision_type { + PolicyDecisionType::NavigationAction | PolicyDecisionType::NewWindowAction => { + let Some(nav_decision) = decision.downcast_ref::() + else { + return false; + }; + let Some(uri) = nav_decision + .navigation_action() + .and_then(|action| action.request()) + .and_then(|request| request.uri()) + .map(|uri| uri.to_string()) + else { + return false; + }; + if matches!(decision_type, PolicyDecisionType::NewWindowAction) + || browser_uri_prefers_external(&uri) + { + if let Err(error) = launch_default_browser_uri(&uri) { + policy_ui + .toast(&format!("failed to open external browser URL: {error}")); + } + decision.ignore(); + return true; + } + } + PolicyDecisionType::Response => { + let Some(response_decision) = decision.downcast_ref::() + else { + return false; + }; + if !response_decision.is_mime_type_supported() { + if let Some(uri) = response_decision + .request() + .and_then(|request| request.uri()) + .map(|uri| uri.to_string()) + && let Err(error) = launch_default_browser_uri(&uri) + { + policy_ui + .toast(&format!("failed to open external browser URL: {error}")); + } + decision.ignore(); + return true; + } + } + _ => {} + } + false + }); + + let create_ui = Rc::clone(ui); + widgets + .web_view + .connect_create(move |_, navigation_action| { + if let Some(uri) = navigation_action + .request() + .and_then(|request| request.uri()) + .map(|uri| uri.to_string()) + && let Err(error) = launch_default_browser_uri(&uri) + { + create_ui.toast(&format!("failed to open popup externally: {error}")); + } + None + }); + + sync_browser_surface_state(ui, surface, &widgets); + Some(widgets) +} + +fn sync_browser_surface_state( + _ui: &Rc, + surface: &SurfaceRecord, + widgets: &BrowserSurfaceWidgets, +) { + let target_uri = browser_surface_target_uri(surface); + let current_uri = widgets.web_view.uri().map(|value| value.to_string()); + if current_uri.as_deref() != Some(target_uri.as_str()) { + widgets.web_view.load_uri(&target_uri); + } + + if !widgets.address_entry.has_focus() { + let visible_uri = current_uri.unwrap_or(target_uri); + if widgets.address_entry.text().as_str() != visible_uri { + widgets.address_entry.set_text(&visible_uri); + } + } + + sync_browser_navigation_controls(widgets); +} + +fn sync_browser_navigation_controls(widgets: &BrowserSurfaceWidgets) { + widgets + .back_button + .set_sensitive(widgets.web_view.can_go_back()); + widgets + .forward_button + .set_sensitive(widgets.web_view.can_go_forward()); + widgets.reload_button.set_sensitive(true); + widgets + .devtools_button + .set_sensitive(widgets.web_view.inspector().is_some()); + widgets + .devtools_button + .set_label(if widgets.devtools_open.get() { + "Hide Devtools" + } else { + "Devtools" + }); +} + +fn toggle_browser_devtools(widgets: &BrowserSurfaceWidgets) { + let Some(inspector) = widgets.web_view.inspector() else { + return; + }; + + if widgets.devtools_open.get() { + inspector.close(); + widgets.devtools_open.set(false); + } else { + if inspector.can_attach() { + inspector.attach(); + } + inspector.show(); + widgets.devtools_open.set(true); + } + sync_browser_navigation_controls(widgets); +} + +fn open_browser_split( + ui: &Rc, + workspace_id: taskers_domain::WorkspaceId, + pane_id: taskers_domain::PaneId, + url: Option, +) { + let new_pane_id = match ui.dispatch_with_response(ControlCommand::SplitPane { + workspace_id, + pane_id: Some(pane_id), + axis: taskers_domain::SplitAxis::Horizontal, + }) { + Some(ControlResponse::PaneSplit { pane_id }) => pane_id, + Some(other) => { + ui.toast(&format!("unexpected split response: {other:?}")); + return; + } + None => return, + }; + + let placeholder_surface_id = ui + .app_state + .snapshot_model() + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&new_pane_id)) + .map(|pane| pane.active_surface); + + let browser_surface_id = match ui.dispatch_with_response(ControlCommand::CreateSurface { + workspace_id, + pane_id: new_pane_id, + kind: PaneKind::Browser, + }) { + Some(ControlResponse::SurfaceCreated { surface_id }) => surface_id, + Some(other) => { + ui.toast(&format!("unexpected surface response: {other:?}")); + return; + } + None => return, + }; + + if let Some(url) = url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(str::to_string) + { + ui.dispatch(ControlCommand::UpdateSurfaceMetadata { + surface_id: browser_surface_id, + patch: PaneMetadataPatch { + url: Some(url), + ..PaneMetadataPatch::default() + }, + }); + } + + if let Some(placeholder_surface_id) = placeholder_surface_id { + ui.dispatch(ControlCommand::CloseSurface { + workspace_id, + pane_id: new_pane_id, + surface_id: placeholder_surface_id, + }); + } +} + +fn active_browser_widgets(ui: &UiHandle, workspace: &Workspace) -> Option { + workspace + .panes + .get(&workspace.active_pane) + .and_then(|pane| pane.active_surface()) + .filter(|surface| surface.kind == PaneKind::Browser) + .and_then(|surface| ui.browser_surfaces.borrow().get(&surface.id).cloned()) +} + +fn focus_active_browser_address(ui: &Rc, workspace: &Workspace) -> bool { + let Some(widgets) = active_browser_widgets(ui, workspace) else { + return false; + }; + widgets.address_entry.grab_focus(); + widgets.address_entry.select_region(0, -1); + true +} + +fn reload_active_browser(ui: &Rc, workspace: &Workspace) -> bool { + let Some(widgets) = active_browser_widgets(ui, workspace) else { + return false; + }; + widgets.web_view.reload(); + widgets.web_view.grab_focus(); + true +} + +fn toggle_active_browser_devtools(ui: &Rc, workspace: &Workspace) -> bool { + let Some(widgets) = active_browser_widgets(ui, workspace) else { + return false; + }; + toggle_browser_devtools(&widgets); + true +} + fn create_workspace_window_from_pane( ui: &Rc, workspace_id: taskers_domain::WorkspaceId, @@ -6171,12 +6716,20 @@ fn pane_focus_target( pane_id: taskers_domain::PaneId, card: &PaneCardWidgets, ) -> Widget { - let active_surface_widget = workspace + let active_surface_id = workspace .panes .get(&pane_id) - .and_then(|pane| pane.active_surface().map(|surface| surface.id)) + .and_then(|pane| pane.active_surface().map(|surface| surface.id)); + + let active_surface_widget = active_surface_id .and_then(|surface_id| ui.ghostty_surfaces.borrow().get(&surface_id).cloned()) - .filter(|widget| widget.parent().is_some()); + .filter(|widget| widget.parent().is_some()) + .or_else(|| { + active_surface_id + .and_then(|surface_id| ui.browser_surfaces.borrow().get(&surface_id).cloned()) + .map(|widgets| widgets.web_view.upcast::()) + .filter(|widget| widget.parent().is_some()) + }); active_surface_widget .or_else(|| card.terminal_host.first_child()) @@ -6249,6 +6802,14 @@ fn display_surface_title(surface: &SurfaceRecord) -> String { } } +fn surface_record_by_id(model: &AppModel, surface_id: SurfaceId) -> Option<&SurfaceRecord> { + model + .workspaces + .values() + .flat_map(|workspace| workspace.panes.values()) + .find_map(|pane| pane.surfaces.get(&surface_id)) +} + fn browser_surface_url(surface: &SurfaceRecord) -> Option<&str> { surface .metadata @@ -6258,6 +6819,12 @@ fn browser_surface_url(surface: &SurfaceRecord) -> Option<&str> { .filter(|url| !url.is_empty()) } +fn browser_surface_target_uri(surface: &SurfaceRecord) -> String { + browser_surface_url(surface) + .map(resolved_browser_uri) + .unwrap_or_else(|| "about:blank".into()) +} + fn has_explicit_browser_scheme(value: &str) -> bool { if value.contains("://") { return true; @@ -6308,6 +6875,21 @@ fn is_local_browser_target(value: &str) -> bool { }) } +fn browser_uri_prefers_external(uri: &str) -> bool { + if !has_explicit_browser_scheme(uri) { + return false; + } + + let scheme = uri + .split_once(':') + .map(|(scheme, _)| scheme.to_ascii_lowercase()) + .unwrap_or_default(); + !matches!( + scheme.as_str(), + "http" | "https" | "about" | "data" | "file" + ) +} + fn resolved_browser_uri(raw: &str) -> String { let trimmed = raw.trim(); if has_explicit_browser_scheme(trimmed) { @@ -6325,6 +6907,24 @@ fn resolved_browser_uri(raw: &str) -> String { format!("https://{trimmed}") } +fn launch_default_browser_uri(uri: &str) -> Result<(), glib::Error> { + gtk::gio::AppInfo::launch_default_for_uri(uri, None::<>k::gio::AppLaunchContext>) +} + +fn build_browser_network_session() -> anyhow::Result { + let paths = taskers_paths::TaskersPaths::detect(); + let data_dir = paths.data_dir().join("browser"); + let cache_dir = paths.cache_dir().join("browser"); + fs::create_dir_all(&data_dir)?; + fs::create_dir_all(&cache_dir)?; + let data_dir = data_dir.to_string_lossy().into_owned(); + let cache_dir = cache_dir.to_string_lossy().into_owned(); + Ok(NetworkSession::new( + Some(&data_dir), + Some(&cache_dir), + )) +} + fn editable_surface_title(surface: &SurfaceRecord) -> String { surface .metadata @@ -7954,6 +8554,23 @@ mod tests { assert_eq!(editable_surface_title(&surface), ""); } + #[test] + fn browser_surface_target_uri_defaults_to_about_blank() { + let surface = SurfaceRecord::new(PaneKind::Browser); + + assert_eq!(browser_surface_target_uri(&surface), "about:blank"); + } + + #[test] + fn browser_uri_prefers_external_for_non_embedded_schemes_only() { + assert!(browser_uri_prefers_external("mailto:test@example.com")); + assert!(browser_uri_prefers_external("tel:+123456789")); + assert!(!browser_uri_prefers_external("https://example.com")); + assert!(!browser_uri_prefers_external("http://localhost:3000")); + assert!(!browser_uri_prefers_external("about:blank")); + assert!(!browser_uri_prefers_external("file:///tmp/index.html")); + } + #[test] fn workspace_window_placements_reflow_following_columns_for_preview_width() { let workspace = preview_test_workspace(); diff --git a/crates/taskers-app/src/settings_store.rs b/crates/taskers-app/src/settings_store.rs index 5c861a2..269920c 100644 --- a/crates/taskers-app/src/settings_store.rs +++ b/crates/taskers-app/src/settings_store.rs @@ -11,6 +11,10 @@ use serde::{Deserialize, Serialize}; pub enum ShortcutAction { ToggleOverview, CloseTerminal, + OpenBrowserSplit, + FocusBrowserAddress, + ReloadBrowserPage, + ToggleBrowserDevtools, FocusLeft, FocusRight, FocusUp, @@ -32,9 +36,13 @@ pub enum ShortcutAction { } impl ShortcutAction { - pub const ALL: [Self; 20] = [ + pub const ALL: [Self; 24] = [ Self::ToggleOverview, Self::CloseTerminal, + Self::OpenBrowserSplit, + Self::FocusBrowserAddress, + Self::ReloadBrowserPage, + Self::ToggleBrowserDevtools, Self::FocusLeft, Self::FocusRight, Self::FocusUp, @@ -59,6 +67,10 @@ impl ShortcutAction { match self { Self::ToggleOverview => "toggle_overview", Self::CloseTerminal => "close_terminal", + Self::OpenBrowserSplit => "open_browser_split", + Self::FocusBrowserAddress => "focus_browser_address", + Self::ReloadBrowserPage => "reload_browser_page", + Self::ToggleBrowserDevtools => "toggle_browser_devtools", Self::FocusLeft => "focus_left", Self::FocusRight => "focus_right", Self::FocusUp => "focus_up", @@ -84,6 +96,10 @@ impl ShortcutAction { match self { Self::ToggleOverview => "Toggle overview", Self::CloseTerminal => "Close terminal", + Self::OpenBrowserSplit => "Open browser in split", + Self::FocusBrowserAddress => "Focus browser address bar", + Self::ReloadBrowserPage => "Reload browser page", + Self::ToggleBrowserDevtools => "Toggle browser devtools", Self::FocusLeft => "Focus left", Self::FocusRight => "Focus right", Self::FocusUp => "Focus up", @@ -109,6 +125,12 @@ impl ShortcutAction { match self { Self::ToggleOverview => "Zoom the current workspace out to fit the full column strip.", Self::CloseTerminal => "Close the active pane or active top-level window.", + Self::OpenBrowserSplit => { + "Split the active pane to the right and open a browser surface." + } + Self::FocusBrowserAddress => "Focus the address bar for the active browser surface.", + Self::ReloadBrowserPage => "Reload the active browser surface.", + Self::ToggleBrowserDevtools => "Show or hide devtools for the active browser surface.", Self::FocusLeft => { "Move focus to the column on the left, then fall back to pane focus." } @@ -141,6 +163,10 @@ impl ShortcutAction { pub fn category(self) -> &'static str { match self { Self::ToggleOverview | Self::CloseTerminal => "General", + Self::OpenBrowserSplit + | Self::FocusBrowserAddress + | Self::ReloadBrowserPage + | Self::ToggleBrowserDevtools => "Browser", Self::FocusLeft | Self::FocusRight | Self::FocusUp | Self::FocusDown => "Focus", Self::NewWindowLeft | Self::NewWindowRight @@ -162,6 +188,10 @@ impl ShortcutAction { match self { Self::ToggleOverview => &["o"], Self::CloseTerminal => &["x"], + Self::OpenBrowserSplit => &["l"], + Self::FocusBrowserAddress => &["l"], + Self::ReloadBrowserPage => &["r"], + Self::ToggleBrowserDevtools => &["i"], Self::FocusLeft => &["h", "Left"], Self::FocusRight => &["l", "Right"], Self::FocusUp => &["k", "Up"], @@ -217,6 +247,10 @@ impl ShortcutPreset { Self::PowerUser => match action { ShortcutAction::ToggleOverview => &["o"], ShortcutAction::CloseTerminal => &["x"], + ShortcutAction::OpenBrowserSplit => &["l"], + ShortcutAction::FocusBrowserAddress => &["l"], + ShortcutAction::ReloadBrowserPage => &["r"], + ShortcutAction::ToggleBrowserDevtools => &["i"], ShortcutAction::FocusLeft => &["h", "Left"], ShortcutAction::FocusRight => &["l", "Right"], ShortcutAction::FocusUp => &["k", "Up"], From 60344d4347091f65febb6ee59ecfa3f761cb62c1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 11:56:06 +0100 Subject: [PATCH 05/63] feat: add linux browser smoke coverage --- scripts/smoke_linux_release_launcher.sh | 179 +++++++++++++++++++++++- scripts/smoke_taskers_ui.sh | 178 ++++++++++++++++++++++- 2 files changed, 351 insertions(+), 6 deletions(-) diff --git a/scripts/smoke_linux_release_launcher.sh b/scripts/smoke_linux_release_launcher.sh index b9463fc..55a06bf 100644 --- a/scripts/smoke_linux_release_launcher.sh +++ b/scripts/smoke_linux_release_launcher.sh @@ -19,6 +19,16 @@ choose_display_number() { return 1 } +choose_http_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + wait_for_path() { local path=$1 local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) @@ -35,11 +45,98 @@ wait_for_path() { return 1 } +wait_for_tcp() { + local port=$1 + local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) + + while (( attempts > 0 )); do + if python3 - <<'PY' "$port" +import socket +import sys + +port = int(sys.argv[1]) +with socket.socket() as sock: + sock.settimeout(0.2) + try: + sock.connect(("127.0.0.1", port)) + except OSError: + raise SystemExit(1) +raise SystemExit(0) +PY + then + return 0 + fi + sleep "$POLL_INTERVAL_SECONDS" + attempts=$((attempts - 1)) + done + + printf 'timed out waiting for localhost:%s\n' "$port" >&2 + return 1 +} + +wait_for_browser_state() { + local control_bin=$1 + local socket_path=$2 + local status_path=$3 + local integrity_path=$4 + local expected_surface_id=$5 + local expected_url=$6 + local expected_title=$7 + local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) + + while (( attempts > 0 )); do + "$control_bin" query status --socket "$socket_path" >"$status_path" + if python3 - <<'PY' "$status_path" "$integrity_path" "$expected_surface_id" "$expected_url" "$expected_title" +import json +import sys + +status_path, integrity_path, expected_surface_id, expected_url, expected_title = sys.argv[1:6] +with open(status_path, encoding="utf-8") as handle: + payload = json.load(handle) +with open(integrity_path, encoding="utf-8") as handle: + integrity = json.load(handle) + +model = payload["response"]["Ok"]["session"]["model"] +active_window_id = model["active_window"] +active_workspace_id = model["windows"][active_window_id]["active_workspace"] +workspace = model["workspaces"][active_workspace_id] +pane = workspace["panes"][workspace["active_pane"]] +surface = pane["surfaces"][expected_surface_id] + +ok = ( + pane["active_surface"] == expected_surface_id + and surface["kind"] == "browser" + and surface["metadata"].get("url") == expected_url + and surface["metadata"].get("title") == expected_title + and integrity.get("active_workspace_surface_id") == expected_surface_id + and integrity.get("active_displayed_surface_id") == expected_surface_id + and expected_surface_id in integrity.get("cached_browser_surface_ids", []) + and expected_surface_id in integrity.get("attached_browser_surface_ids", []) + and integrity.get("active_browser_uri") == expected_url +) +raise SystemExit(0 if ok else 1) +PY + then + return 0 + fi + sleep "$POLL_INTERVAL_SECONDS" + attempts=$((attempts - 1)) + done + + printf 'timed out waiting for browser state for surface %s\n' "$expected_surface_id" >&2 + cat "$status_path" >&2 || true + cat "$integrity_path" >&2 || true + return 1 +} + cleanup() { local status=$? if [[ -n "${app_pid:-}" ]]; then kill "$app_pid" >/dev/null 2>&1 || true fi + if [[ -n "${http_pid:-}" ]]; then + kill "$http_pid" >/dev/null 2>&1 || true + fi if [[ -n "${xvfb_pid:-}" ]]; then kill "$xvfb_pid" >/dev/null 2>&1 || true fi @@ -64,11 +161,31 @@ session_path="$temp_dir/session.json" install_root="$temp_dir/install" xdg_data_home="$temp_dir/data" xdg_bin_home="$temp_dir/bin" +integrity_path="$temp_dir/ui-integrity.json" +status_path="$temp_dir/status.json" +browser_split_path="$temp_dir/browser-split.json" +site_dir="$temp_dir/site" +http_port=$(choose_http_port) +browser_url="http://127.0.0.1:${http_port}/index.html" version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" target="$(rustc -vV | sed -n 's/^host: //p')" manifest_path="$temp_dir/taskers-manifest-v${version}.json" bundle_taskersctl="$install_root/$version/$target/bin/taskersctl" +mkdir -p "$site_dir" +cat >"$site_dir/index.html" <<'HTML' + + + + + Taskers Browser Smoke + + +
browser smoke page
+ + +HTML + ( cd "$REPO_ROOT" cargo build -p taskers --bin taskers @@ -78,6 +195,11 @@ bundle_taskersctl="$install_root/$version/$target/bin/taskersctl" --output "$manifest_path" ) >/dev/null +python3 -m http.server "$http_port" --bind 127.0.0.1 --directory "$site_dir" \ + >"$temp_dir/http.log" 2>&1 & +http_pid=$! +wait_for_tcp "$http_port" + Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & xvfb_pid=$! wait_for_path "/tmp/.X11-unix/X${display_number}" @@ -95,6 +217,7 @@ wait_for_path "/tmp/.X11-unix/X${display_number}" export TASKERS_TERMINAL_BACKEND=mock export XDG_BIN_HOME="$xdg_bin_home" export XDG_DATA_HOME="$xdg_data_home" + export TASKERS_UI_INTEGRITY_PATH="$integrity_path" exec "$TARGET_DIR/taskers" \ --demo \ --socket "$socket_path" \ @@ -104,6 +227,7 @@ app_pid=$! wait_for_path "$socket_path" wait_for_path "$session_path" +wait_for_path "$integrity_path" if [[ ! -x "$bundle_taskersctl" ]]; then printf 'expected bundled taskersctl at %s\n' "$bundle_taskersctl" >&2 @@ -113,8 +237,57 @@ fi sleep 5 kill -0 "$app_pid" -"$bundle_taskersctl" workspace new --label "Release Smoke" --socket "$socket_path" >/dev/null -sleep 1 +"$bundle_taskersctl" query status --socket "$socket_path" >"$status_path" +readarray -t ids < <( + python3 - <<'PY' "$status_path" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +model = payload["response"]["Ok"]["session"]["model"] +active_window_id = model["active_window"] +active_workspace_id = model["windows"][active_window_id]["active_workspace"] +workspace = model["workspaces"][active_workspace_id] + +print(active_workspace_id) +print(workspace["active_pane"]) +PY +) + +workspace_id=${ids[0]} +pane_id=${ids[1]} + +"$bundle_taskersctl" pane split \ + --socket "$socket_path" \ + --workspace "$workspace_id" \ + --pane "$pane_id" \ + --axis horizontal \ + --kind browser \ + --url "$browser_url" >"$browser_split_path" + +browser_surface_id=$( + python3 - <<'PY' "$browser_split_path" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +print(payload["surface_id"]) +PY +) + +wait_for_browser_state \ + "$bundle_taskersctl" \ + "$socket_path" \ + "$status_path" \ + "$integrity_path" \ + "$browser_surface_id" \ + "$browser_url" \ + "Taskers Browser Smoke" + kill -0 "$app_pid" -printf '%s\n' 'Taskers launcher smoke passed: release bundle installed and responded to control commands.' +printf '%s\n' 'Taskers launcher smoke passed: release bundle installed and loaded an embedded browser split.' diff --git a/scripts/smoke_taskers_ui.sh b/scripts/smoke_taskers_ui.sh index 4b60ca7..67a3aef 100755 --- a/scripts/smoke_taskers_ui.sh +++ b/scripts/smoke_taskers_ui.sh @@ -19,6 +19,16 @@ choose_display_number() { return 1 } +choose_http_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + wait_for_path() { local path=$1 local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) @@ -35,11 +45,98 @@ wait_for_path() { return 1 } +wait_for_tcp() { + local port=$1 + local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) + + while (( attempts > 0 )); do + if python3 - <<'PY' "$port" +import socket +import sys + +port = int(sys.argv[1]) +with socket.socket() as sock: + sock.settimeout(0.2) + try: + sock.connect(("127.0.0.1", port)) + except OSError: + raise SystemExit(1) +raise SystemExit(0) +PY + then + return 0 + fi + sleep "$POLL_INTERVAL_SECONDS" + attempts=$((attempts - 1)) + done + + printf 'timed out waiting for localhost:%s\n' "$port" >&2 + return 1 +} + +wait_for_browser_state() { + local socket_path=$1 + local status_path=$2 + local integrity_path=$3 + local expected_surface_id=$4 + local expected_url=$5 + local expected_title=$6 + local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) + + while (( attempts > 0 )); do + "$TARGET_DIR/taskersctl" query status --socket "$socket_path" >"$status_path" + if python3 - <<'PY' "$status_path" "$integrity_path" "$expected_surface_id" "$expected_url" "$expected_title" +import json +import sys + +status_path, integrity_path, expected_surface_id, expected_url, expected_title = sys.argv[1:6] +with open(status_path, encoding="utf-8") as handle: + payload = json.load(handle) +with open(integrity_path, encoding="utf-8") as handle: + integrity = json.load(handle) + +model = payload["response"]["Ok"]["session"]["model"] +active_window_id = model["active_window"] +active_workspace_id = model["windows"][active_window_id]["active_workspace"] +workspace = model["workspaces"][active_workspace_id] +pane = workspace["panes"][workspace["active_pane"]] +surface = pane["surfaces"][expected_surface_id] + +ok = ( + pane["active_surface"] == expected_surface_id + and surface["kind"] == "browser" + and surface["metadata"].get("url") == expected_url + and surface["metadata"].get("title") == expected_title + and integrity.get("active_workspace_surface_id") == expected_surface_id + and integrity.get("active_displayed_surface_id") == expected_surface_id + and expected_surface_id in integrity.get("cached_browser_surface_ids", []) + and expected_surface_id in integrity.get("attached_browser_surface_ids", []) + and integrity.get("active_browser_uri") == expected_url +) +raise SystemExit(0 if ok else 1) +PY + then + return 0 + fi + + sleep "$POLL_INTERVAL_SECONDS" + attempts=$((attempts - 1)) + done + + printf 'timed out waiting for browser state for surface %s\n' "$expected_surface_id" >&2 + cat "$status_path" >&2 || true + cat "$integrity_path" >&2 || true + return 1 +} + cleanup() { local status=$? if [[ -n "${app_pid:-}" ]]; then kill "$app_pid" >/dev/null 2>&1 || true fi + if [[ -n "${http_pid:-}" ]]; then + kill "$http_pid" >/dev/null 2>&1 || true + fi if [[ -n "${xvfb_pid:-}" ]]; then kill "$xvfb_pid" >/dev/null 2>&1 || true fi @@ -61,12 +158,37 @@ display_number=$(choose_display_number) display=":${display_number}" socket_path="$temp_dir/taskers.sock" session_path="$temp_dir/session.json" +integrity_path="$temp_dir/ui-integrity.json" +status_path="$temp_dir/status.json" +browser_split_path="$temp_dir/browser-split.json" +site_dir="$temp_dir/site" +http_port=$(choose_http_port) +browser_url="http://127.0.0.1:${http_port}/index.html" + +mkdir -p "$site_dir" +cat >"$site_dir/index.html" <<'HTML' + + + + + Taskers Browser Smoke + + +
browser smoke page
+ + +HTML ( cd "$REPO_ROOT" cargo build -p taskers-gtk -p taskers-cli ) >/dev/null +python3 -m http.server "$http_port" --bind 127.0.0.1 --directory "$site_dir" \ + >"$temp_dir/http.log" 2>&1 & +http_pid=$! +wait_for_tcp "$http_port" + Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & xvfb_pid=$! wait_for_path "/tmp/.X11-unix/X${display_number}" @@ -79,6 +201,7 @@ wait_for_path "/tmp/.X11-unix/X${display_number}" export LIBGL_ALWAYS_SOFTWARE=1 export TASKERS_NON_UNIQUE=1 export TASKERS_TERMINAL_BACKEND=mock + export TASKERS_UI_INTEGRITY_PATH="$integrity_path" exec "$TARGET_DIR/taskers-gtk" \ --demo \ --socket "$socket_path" \ @@ -88,12 +211,61 @@ app_pid=$! wait_for_path "$socket_path" wait_for_path "$session_path" +wait_for_path "$integrity_path" sleep 5 kill -0 "$app_pid" -"$TARGET_DIR/taskersctl" workspace new --label Smoke --socket "$socket_path" >/dev/null -sleep 1 +"$TARGET_DIR/taskersctl" query status --socket "$socket_path" >"$status_path" +readarray -t ids < <( + python3 - <<'PY' "$status_path" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +model = payload["response"]["Ok"]["session"]["model"] +active_window_id = model["active_window"] +active_workspace_id = model["windows"][active_window_id]["active_workspace"] +workspace = model["workspaces"][active_workspace_id] + +print(active_workspace_id) +print(workspace["active_pane"]) +PY +) + +workspace_id=${ids[0]} +pane_id=${ids[1]} + +"$TARGET_DIR/taskersctl" pane split \ + --socket "$socket_path" \ + --workspace "$workspace_id" \ + --pane "$pane_id" \ + --axis horizontal \ + --kind browser \ + --url "$browser_url" >"$browser_split_path" + +browser_surface_id=$( + python3 - <<'PY' "$browser_split_path" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +print(payload["surface_id"]) +PY +) + +wait_for_browser_state \ + "$socket_path" \ + "$status_path" \ + "$integrity_path" \ + "$browser_surface_id" \ + "$browser_url" \ + "Taskers Browser Smoke" + kill -0 "$app_pid" -printf '%s\n' 'Taskers UI smoke passed: app stayed up and accepted a control command.' +printf '%s\n' 'Taskers UI smoke passed: embedded browser split loaded and synced metadata.' From 2705bef85dd5c388d3e80863a802a67b547a0a52 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 11:58:43 +0100 Subject: [PATCH 06/63] chore: require webkitgtk 6.0 for linux builds and installs --- .github/workflows/release-assets.yml | 10 +++++++++- README.md | 7 +++++++ docs/release.md | 9 +++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 708ff75..f4f0645 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -31,7 +31,15 @@ jobs: - name: Install Linux bundle tools run: | sudo apt-get update - sudo apt-get install -y meson ninja-build python3-gi xvfb libgtk-4-dev libadwaita-1-dev + sudo apt-get install -y \ + meson \ + ninja-build \ + python3-gi \ + xvfb \ + libgtk-4-dev \ + libadwaita-1-dev \ + libjavascriptcoregtk-6.0-dev \ + libwebkitgtk-6.0-dev - name: Install pinned blueprint-compiler run: | diff --git a/README.md b/README.md index 9821e85..4851a5c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ taskers --demo ``` The first launch downloads the exact version-matched Linux bundle from the tagged GitHub release. +The Linux app now requires the host WebKitGTK 6.0 runtime in addition to GTK4/libadwaita. macOS: @@ -24,6 +25,12 @@ macOS: ## Develop +On Ubuntu 24.04, install the Linux UI dependencies first: + +```bash +sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev +``` + ```bash cargo run -p taskers-gtk --bin taskers-gtk -- --demo ``` diff --git a/docs/release.md b/docs/release.md index dcf2f3c..14bbeef 100644 --- a/docs/release.md +++ b/docs/release.md @@ -23,6 +23,12 @@ bash scripts/capture_demo_screenshots.sh ## 3. Run Local Verification +- On Ubuntu 24.04, install the Linux UI build dependencies first: + +```bash +sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev +``` + - Run the full test suite: ```bash @@ -36,6 +42,8 @@ bash scripts/smoke_taskers_ui.sh bash scripts/smoke_taskers_focus_churn.sh ``` +- The GTK smoke suite now expects a working embedded browser load through WebKitGTK 6.0, not just a placeholder pane. + - Build the Linux app bundle that the published launcher expects: ```bash @@ -130,6 +138,7 @@ taskers --demo ``` - Confirm the published Linux launcher downloads the exact version-matched bundle on first launch. +- Confirm the published Linux launcher can open an embedded browser split after install. - Confirm macOS installs from the published DMG and launches correctly after dragging `Taskers.app` into `Applications`. - Confirm `cargo install taskers --locked` fails on macOS with guidance to use the GitHub Releases DMG. - Confirm `cargo install taskers-cli --bin taskersctl --locked` still works as the standalone helper path. From ec6b34ee960a25386153b93e4d2dbc759eb09228 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 16:06:45 +0100 Subject: [PATCH 07/63] refactor: split new top-level windows from focused extents --- crates/taskers-domain/src/model.rs | 115 ++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 414f03b..f54de5e 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -18,6 +18,17 @@ pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720; pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420; pub const KEYBOARD_RESIZE_STEP: i32 = 80; +fn split_top_level_extent(extent: i32, min_extent: i32) -> (i32, i32) { + let extent = extent.max(min_extent); + if extent < min_extent * 2 { + return (min_extent, min_extent); + } + + let retained_extent = (extent + 1) / 2; + let new_extent = extent - retained_extent; + (retained_extent.max(min_extent), new_extent.max(min_extent)) +} + #[derive(Debug, Error)] pub enum DomainError { #[error("window {0} was not found")] @@ -1056,7 +1067,21 @@ impl AppModel { match direction { Direction::Left | Direction::Right => { - let new_column = WorkspaceColumnRecord::new(new_window_id); + let source_width = workspace + .columns + .get(&source_column_id) + .map(|column| column.width) + .expect("active column should exist"); + let (retained_width, new_width) = + split_top_level_extent(source_width, MIN_WORKSPACE_WINDOW_WIDTH); + let column = workspace + .columns + .get_mut(&source_column_id) + .expect("active column should exist"); + column.width = retained_width; + + let mut new_column = WorkspaceColumnRecord::new(new_window_id); + new_column.width = new_width; let insert_index = if matches!(direction, Direction::Left) { source_column_index } else { @@ -1065,6 +1090,24 @@ impl AppModel { workspace.insert_column_at(insert_index, new_column); } Direction::Up | Direction::Down => { + let source_window_height = workspace + .windows + .get(&workspace.active_window) + .map(|window| window.height) + .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; + let (retained_height, new_height) = + split_top_level_extent(source_window_height, MIN_WORKSPACE_WINDOW_HEIGHT); + let source_window = workspace + .windows + .get_mut(&workspace.active_window) + .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; + source_window.height = retained_height; + let new_window = workspace + .windows + .get_mut(&new_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(new_window_id))?; + new_window.height = new_height; + let column = workspace .columns .get_mut(&source_column_id) @@ -2018,13 +2061,17 @@ mod tests { assert_eq!(workspace.windows.len(), 3); assert_eq!(workspace.columns.len(), 2); assert_eq!(workspace.active_pane, stacked_pane); + assert_eq!(right_column.width, MIN_WORKSPACE_WINDOW_WIDTH); assert_eq!(right_column.window_order.len(), 2); assert_ne!(workspace.active_window, first_window_id); assert!( workspace .columns .values() - .any(|column| column.window_order == vec![first_window_id]) + .any(|column| { + column.window_order == vec![first_window_id] + && column.width == MIN_WORKSPACE_WINDOW_WIDTH + }) ); let upper_window_id = right_column.window_order[0]; assert_eq!( @@ -2035,6 +2082,70 @@ mod tests { .active_pane, right_pane ); + assert_eq!( + workspace + .windows + .get(&upper_window_id) + .expect("window") + .height, + (DEFAULT_WORKSPACE_WINDOW_HEIGHT + 1) / 2 + ); + assert_eq!( + workspace + .windows + .get(&workspace.active_window) + .expect("window") + .height, + DEFAULT_WORKSPACE_WINDOW_HEIGHT / 2 + ); + } + + #[test] + fn creating_workspace_window_clamps_split_column_width_to_minimum() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let workspace = model.active_workspace().expect("workspace"); + let column_id = workspace.active_column_id().expect("active column"); + + model + .set_workspace_column_width(workspace_id, column_id, MIN_WORKSPACE_WINDOW_WIDTH + 80) + .expect("set width"); + model + .create_workspace_window(workspace_id, Direction::Right) + .expect("window created"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let widths = workspace + .columns + .values() + .map(|column| column.width) + .collect::>(); + assert_eq!(widths, vec![MIN_WORKSPACE_WINDOW_WIDTH, MIN_WORKSPACE_WINDOW_WIDTH]); + } + + #[test] + fn creating_workspace_window_clamps_split_window_height_to_minimum() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let window_id = model + .active_workspace() + .map(|workspace| workspace.active_window) + .expect("active window"); + + model + .set_workspace_window_height(workspace_id, window_id, MIN_WORKSPACE_WINDOW_HEIGHT + 50) + .expect("set height"); + model + .create_workspace_window(workspace_id, Direction::Down) + .expect("window created"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let heights = workspace + .windows + .values() + .map(|window| window.height) + .collect::>(); + assert_eq!(heights, vec![MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_HEIGHT]); } #[test] From f8dae71d7205a9de3a7514eb63cf508736530f77 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 16:08:03 +0100 Subject: [PATCH 08/63] feat: fit top-level workspace windows to viewport --- crates/taskers-app/src/main.rs | 582 ++++++++++++++++++++++++++++++--- 1 file changed, 538 insertions(+), 44 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index ac35ccc..a89280d 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -53,8 +53,8 @@ use terminal_transitions::{ derive_pane_frames, plan_workspace_transition, retarget_transition_plan, }; use webkit6::{ - LoadEvent, NavigationPolicyDecision, NetworkSession, PolicyDecisionType, - ResponsePolicyDecision, Settings as WebKitSettings, WebView, prelude::*, + NavigationPolicyDecision, NetworkSession, PolicyDecisionType, ResponsePolicyDecision, + Settings as WebKitSettings, WebView, prelude::*, }; #[derive(Debug, Clone, Parser)] @@ -105,6 +105,7 @@ struct UiHandle { suppress_viewport_events: RefCell, pending_viewport: RefCell>, pending_viewport_source: RefCell>, + pending_viewport_refresh_source: RefCell>, pending_focus_source: RefCell>, desktop_notifications: RefCell>, overview_mode: Cell, @@ -358,6 +359,8 @@ const TOOLBAR_ACTION_FOCUS_GLYPH: &str = "\u{25ce}"; struct WorkspaceRenderContext { overview_mode: bool, overview_scale: f64, + viewport_width: i32, + viewport_height: i32, top_level_resize_preview: Option, } @@ -430,6 +433,7 @@ impl UiHandle { suppress_viewport_events: RefCell::new(false), pending_viewport: RefCell::new(None), pending_viewport_source: RefCell::new(None), + pending_viewport_refresh_source: RefCell::new(None), pending_focus_source: RefCell::new(None), desktop_notifications: RefCell::new(HashSet::new()), overview_mode: Cell::new(false), @@ -1073,6 +1077,23 @@ impl UiHandle { self.queue_viewport_persist(workspace.id, current_workspace_viewport(&shell)); } + fn queue_viewport_layout_refresh(self: &Rc) { + let Some(_) = self.shell.borrow().as_ref() else { + return; + }; + if let Some(source) = self.pending_viewport_refresh_source.borrow_mut().take() { + source.remove(); + } + + let refresh_ui = Rc::clone(self); + let source = glib::timeout_add_local(Duration::from_millis(20), move || { + *refresh_ui.pending_viewport_refresh_source.borrow_mut() = None; + refresh_ui.refresh(false); + glib::ControlFlow::Break + }); + *self.pending_viewport_refresh_source.borrow_mut() = Some(source); + } + fn set_workspace_viewport(&self, shell: &ShellWidgets, viewport: &WorkspaceViewport) { let h_adjustment = shell.layout_scroll.hadjustment(); let v_adjustment = shell.layout_scroll.vadjustment(); @@ -2679,6 +2700,26 @@ fn build_shell_scaffold(ui: &Rc) -> ShellWidgets { layout_scroll.vadjustment().connect_value_changed(move |_| { vertical_ui.queue_active_workspace_viewport_persist(); }); + let horizontal_refresh_ui = Rc::clone(ui); + layout_scroll.hadjustment().connect_changed(move |_| { + horizontal_refresh_ui.queue_viewport_layout_refresh(); + }); + let vertical_refresh_ui = Rc::clone(ui); + layout_scroll.vadjustment().connect_changed(move |_| { + vertical_refresh_ui.queue_viewport_layout_refresh(); + }); + let horizontal_page_refresh_ui = Rc::clone(ui); + layout_scroll + .hadjustment() + .connect_notify_local(Some("page-size"), move |_, _| { + horizontal_page_refresh_ui.queue_viewport_layout_refresh(); + }); + let vertical_page_refresh_ui = Rc::clone(ui); + layout_scroll + .vadjustment() + .connect_notify_local(Some("page-size"), move |_, _| { + vertical_page_refresh_ui.queue_viewport_layout_refresh(); + }); main_column.append(&layout_scroll); @@ -4521,6 +4562,89 @@ fn build_split_layout_widget( } } +fn commit_top_level_resize( + ui: &Rc, + workspace_id: taskers_domain::WorkspaceId, + workspace_column_id: WorkspaceColumnId, + workspace_window_id: WorkspaceWindowId, + edge: ResizeHandleEdge, + target_size: i32, +) { + let Some(shell) = ui.shell.borrow().as_ref().cloned() else { + return; + }; + let before = ui.app_state.snapshot_model(); + let Some(workspace) = before.workspaces.get(&workspace_id) else { + return; + }; + let preview = TopLevelResizePreview { + workspace_id, + target: match edge { + ResizeHandleEdge::Right => TopLevelResizePreviewTarget::ColumnWidth { + workspace_column_id, + width: target_size, + }, + ResizeHandleEdge::Bottom => TopLevelResizePreviewTarget::WindowHeight { + workspace_window_id, + height: target_size, + }, + }, + }; + let placements = workspace_window_placements( + workspace, + workspace_viewport_width(ui.as_ref(), Some(&shell)), + workspace_viewport_height(ui.as_ref(), Some(&shell)), + Some(preview), + ); + + let commands = match edge { + ResizeHandleEdge::Right => workspace + .columns + .values() + .filter_map(|column| { + placements.iter().find(|placement| placement.column_id == column.id).map( + |placement| ControlCommand::SetWorkspaceColumnWidth { + workspace_id, + workspace_column_id: column.id, + width: placement.frame.width, + }, + ) + }) + .collect::>(), + ResizeHandleEdge::Bottom => { + let Some(column_id) = workspace.column_for_window(workspace_window_id) else { + return; + }; + let Some(column) = workspace.columns.get(&column_id) else { + return; + }; + column + .window_order + .iter() + .filter_map(|window_id| { + placements.iter().find(|placement| placement.window_id == *window_id).map( + |placement| ControlCommand::SetWorkspaceWindowHeight { + workspace_id, + workspace_window_id: *window_id, + height: placement.frame.height, + }, + ) + }) + .collect::>() + } + }; + + for command in commands { + if let Err(error) = ui.app_state.dispatch(command) { + ui.toast(&error.to_string()); + return; + } + } + + let after = ui.app_state.snapshot_model(); + ui.refresh(before != after); +} + fn attach_workspace_window_resize_handles( ui: &Rc, overlay: &Overlay, @@ -4593,27 +4717,11 @@ fn build_workspace_window_resize_handle( let active_handle_for_begin = handle.clone(); let active_handle_for_end = handle.clone(); drag.connect_drag_begin(move |_, _, _| { - let start = drag_begin_ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .map(|workspace| match edge { - ResizeHandleEdge::Right => workspace - .columns - .get(&workspace_column_id) - .map(|column| column.width) - .unwrap_or(display_frame.width), - ResizeHandleEdge::Bottom => workspace - .windows - .get(&workspace_window_id) - .map(|window| window.height) - .unwrap_or(display_frame.height), - }) - .unwrap_or(match edge { - ResizeHandleEdge::Right => display_frame.width, - ResizeHandleEdge::Bottom => display_frame.height, - }); + let _ = &drag_begin_ui; + let start = match edge { + ResizeHandleEdge::Right => display_frame.width, + ResizeHandleEdge::Bottom => display_frame.height, + }; start_size_for_begin.set(start); current_size_for_begin.set(start); active_handle_for_begin.add_css_class("workspace-window-resize-handle-active"); @@ -4657,19 +4765,14 @@ fn build_workspace_window_resize_handle( drag_end_ui.sync_layout_state(&model); return; } - let command = match edge { - ResizeHandleEdge::Right => ControlCommand::SetWorkspaceColumnWidth { - workspace_id, - workspace_column_id, - width: current_size.get(), - }, - ResizeHandleEdge::Bottom => ControlCommand::SetWorkspaceWindowHeight { - workspace_id, - workspace_window_id, - height: current_size.get(), - }, - }; - drag_end_ui.dispatch(command); + commit_top_level_resize( + &drag_end_ui, + workspace_id, + workspace_column_id, + workspace_window_id, + edge, + current_size.get(), + ); }); handle.add_controller(drag); @@ -6851,7 +6954,7 @@ fn has_explicit_browser_scheme(value: &str) -> bool { matches!( scheme.to_ascii_lowercase().as_str(), - "about" | "data" | "file" | "javascript" | "mailto" + "about" | "data" | "file" | "javascript" | "mailto" | "tel" ) } @@ -7352,7 +7455,158 @@ fn scale_window_frame(frame: WindowFrame, scale: f64) -> WindowFrame { } } -fn workspace_window_placements( +fn distribute_weighted_total(weights: &[i32], total: i32) -> Vec { + if weights.is_empty() { + return Vec::new(); + } + + let weights = weights + .iter() + .map(|weight| (*weight).max(1)) + .collect::>(); + let weight_sum = weights.iter().map(|weight| i64::from(*weight)).sum::(); + if weight_sum <= 0 { + let base = total / weights.len() as i32; + let remainder = total - (base * weights.len() as i32); + return (0..weights.len()) + .map(|index| base + i32::from(index < remainder.max(0) as usize)) + .collect(); + } + + let mut distributed = Vec::with_capacity(weights.len()); + let mut allocated = 0; + let mut remainders = Vec::with_capacity(weights.len()); + for (index, weight) in weights.into_iter().enumerate() { + let scaled = i64::from(total) * i64::from(weight); + let base = (scaled / weight_sum) as i32; + distributed.push(base); + allocated += base; + remainders.push((index, scaled % weight_sum)); + } + + let mut remaining = total - allocated; + remainders.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + for (index, _) in remainders.into_iter().take(remaining.max(0) as usize) { + distributed[index] += 1; + remaining -= 1; + if remaining <= 0 { + break; + } + } + + distributed +} + +fn fit_track_extents(preferred_extents: &[i32], available_total: i32, min_extent: i32) -> Vec { + if preferred_extents.is_empty() { + return Vec::new(); + } + + let count = preferred_extents.len() as i32; + let min_total = min_extent.saturating_mul(count); + if available_total <= min_total { + return vec![min_extent; preferred_extents.len()]; + } + + let preferred_extents = preferred_extents + .iter() + .map(|extent| (*extent).max(1)) + .collect::>(); + let mut result = vec![0; preferred_extents.len()]; + let mut active = (0..preferred_extents.len()).collect::>(); + let mut remaining_total = available_total; + + loop { + if active.is_empty() { + break; + } + + let remaining_weight = active + .iter() + .map(|index| i64::from(preferred_extents[*index])) + .sum::() + .max(1); + let below_minimum = active + .iter() + .copied() + .filter(|index| { + (f64::from(remaining_total) * f64::from(preferred_extents[*index])) + / (remaining_weight as f64) + < f64::from(min_extent) + }) + .collect::>(); + + if below_minimum.is_empty() { + let distributed = distribute_weighted_total( + &active + .iter() + .map(|index| preferred_extents[*index]) + .collect::>(), + remaining_total, + ); + for (slot, index) in active.iter().enumerate() { + result[*index] = distributed[slot]; + } + break; + } + + for index in below_minimum { + result[index] = min_extent; + remaining_total -= min_extent; + active.retain(|candidate| *candidate != index); + } + } + + result +} + +fn fit_track_extents_with_fixed( + preferred_extents: &[i32], + available_total: i32, + min_extent: i32, + fixed: Option<(usize, i32)>, +) -> Vec { + let Some((fixed_index, fixed_extent)) = fixed else { + return fit_track_extents(preferred_extents, available_total, min_extent); + }; + if preferred_extents.is_empty() { + return Vec::new(); + } + + let count = preferred_extents.len() as i32; + let min_total = min_extent.saturating_mul(count); + if available_total <= min_total { + return vec![min_extent; preferred_extents.len()]; + } + + let max_fixed_extent = + available_total - min_extent.saturating_mul((preferred_extents.len() - 1) as i32); + let fixed_extent = fixed_extent.clamp(min_extent, max_fixed_extent.max(min_extent)); + let remaining_preferred = preferred_extents + .iter() + .enumerate() + .filter_map(|(index, extent)| (index != fixed_index).then_some(*extent)) + .collect::>(); + let remaining = fit_track_extents( + &remaining_preferred, + available_total - fixed_extent, + min_extent, + ); + + let mut result = Vec::with_capacity(preferred_extents.len()); + let mut remaining_iter = remaining.into_iter(); + for index in 0..preferred_extents.len() { + if index == fixed_index { + result.push(fixed_extent); + } else { + result.push(remaining_iter.next().unwrap_or(min_extent)); + } + } + result +} + +#[cfg(test)] +fn preferred_workspace_window_placements( workspace: &Workspace, top_level_resize_preview: Option, ) -> Vec { @@ -7405,11 +7659,144 @@ fn workspace_window_placements( placements } +fn workspace_window_placements( + workspace: &Workspace, + viewport_width: i32, + viewport_height: i32, + top_level_resize_preview: Option, +) -> Vec { + let ordered_columns = workspace.columns.values().collect::>(); + if ordered_columns.is_empty() { + return Vec::new(); + } + + let horizontal_gap_total = + DEFAULT_WORKSPACE_WINDOW_GAP * ordered_columns.len().saturating_sub(1) as i32; + let available_width = (viewport_width - horizontal_gap_total).max(0); + let preferred_column_widths = ordered_columns + .iter() + .map(|column| match top_level_resize_preview { + Some(TopLevelResizePreview { + workspace_id: _, + target: + TopLevelResizePreviewTarget::ColumnWidth { + workspace_column_id, + width, + }, + }) if workspace_column_id == column.id => width.max(MIN_WORKSPACE_WINDOW_WIDTH), + _ => column.width.max(1), + }) + .collect::>(); + let fixed_column = match top_level_resize_preview { + Some(TopLevelResizePreview { + workspace_id: _, + target: + TopLevelResizePreviewTarget::ColumnWidth { + workspace_column_id, + width, + }, + }) => ordered_columns + .iter() + .position(|column| column.id == workspace_column_id) + .map(|index| (index, width.max(MIN_WORKSPACE_WINDOW_WIDTH))), + _ => None, + }; + let column_widths = fit_track_extents_with_fixed( + &preferred_column_widths, + available_width, + MIN_WORKSPACE_WINDOW_WIDTH, + fixed_column, + ); + + let mut placements = Vec::new(); + let mut x = 0; + for (column_index, column) in ordered_columns.into_iter().enumerate() { + let column_width = column_widths + .get(column_index) + .copied() + .unwrap_or(MIN_WORKSPACE_WINDOW_WIDTH); + let vertical_gap_total = + DEFAULT_WORKSPACE_WINDOW_GAP * column.window_order.len().saturating_sub(1) as i32; + let available_height = (viewport_height - vertical_gap_total).max(0); + let preferred_window_heights = column + .window_order + .iter() + .filter_map(|window_id| { + workspace.windows.get(window_id).map(|window| match top_level_resize_preview { + Some(TopLevelResizePreview { + workspace_id: _, + target: + TopLevelResizePreviewTarget::WindowHeight { + workspace_window_id, + height, + }, + }) if workspace_window_id == *window_id => { + height.max(MIN_WORKSPACE_WINDOW_HEIGHT) + } + _ => window.height.max(MIN_WORKSPACE_WINDOW_HEIGHT), + }) + }) + .collect::>(); + let fixed_window = match top_level_resize_preview { + Some(TopLevelResizePreview { + workspace_id: _, + target: + TopLevelResizePreviewTarget::WindowHeight { + workspace_window_id, + height, + }, + }) => column + .window_order + .iter() + .position(|window_id| *window_id == workspace_window_id) + .map(|index| (index, height.max(MIN_WORKSPACE_WINDOW_HEIGHT))), + _ => None, + }; + let window_heights = fit_track_extents_with_fixed( + &preferred_window_heights, + available_height, + MIN_WORKSPACE_WINDOW_HEIGHT, + fixed_window, + ); + + let mut y = 0; + for (window_index, window_id) in column.window_order.iter().enumerate() { + if !workspace.windows.contains_key(window_id) { + continue; + } + let window_height = window_heights + .get(window_index) + .copied() + .unwrap_or(MIN_WORKSPACE_WINDOW_HEIGHT); + placements.push(WorkspaceWindowPlacement { + window_id: *window_id, + column_id: column.id, + frame: WindowFrame { + x, + y, + width: column_width, + height: window_height, + }, + }); + y += window_height + DEFAULT_WORKSPACE_WINDOW_GAP; + } + + x += column_width + DEFAULT_WORKSPACE_WINDOW_GAP; + } + + placements +} + fn workspace_display_window_placements( workspace: &Workspace, render_context: WorkspaceRenderContext, ) -> Vec { - workspace_window_placements(workspace, render_context.top_level_resize_preview) + workspace_window_placements( + workspace, + render_context.viewport_width, + render_context.viewport_height, + render_context.top_level_resize_preview, + ) .into_iter() .map(|mut placement| { if render_context.overview_mode { @@ -7457,19 +7844,22 @@ fn workspace_render_context( return WorkspaceRenderContext { overview_mode: false, overview_scale: 1.0, + viewport_width, + viewport_height, top_level_resize_preview: ui.active_top_level_resize_preview(workspace.id), }; } - let base_frames = workspace_window_placements(workspace, None) + let base_frames = + workspace_window_placements(workspace, viewport_width, viewport_height, None) .into_iter() .map(|placement| placement.frame) .collect::>(); let base_metrics = canvas_metrics_from_frames(&base_frames); let content_width = (base_metrics.width - (WORKSPACE_CANVAS_PADDING * 2)).max(1); let content_height = (base_metrics.height - (WORKSPACE_CANVAS_PADDING * 2)).max(1); - let available_width = (viewport_width - (WORKSPACE_CANVAS_PADDING * 2)).max(1) as f64; - let available_height = (viewport_height - (WORKSPACE_CANVAS_PADDING * 2)).max(1) as f64; + let available_width = viewport_width.max(1) as f64; + let available_height = viewport_height.max(1) as f64; let overview_scale = (available_width / f64::from(content_width)) .min(available_height / f64::from(content_height)) .clamp(0.05, 1.0); @@ -7477,6 +7867,8 @@ fn workspace_render_context( WorkspaceRenderContext { overview_mode: true, overview_scale, + viewport_width, + viewport_height, top_level_resize_preview: None, } } @@ -8428,6 +8820,34 @@ fn humanize_theme_name(name: &str) -> String { mod tests { use super::*; + fn single_window_test_workspace() -> Workspace { + let pane = PaneRecord::new(PaneKind::Terminal); + let window = taskers_domain::WorkspaceWindowRecord { + id: WorkspaceWindowId::new(), + height: 620, + layout: LayoutNode::leaf(pane.id), + active_pane: pane.id, + }; + let column = taskers_domain::WorkspaceColumnRecord { + id: WorkspaceColumnId::new(), + width: 840, + window_order: vec![window.id], + active_window: window.id, + }; + + Workspace { + id: taskers_domain::WorkspaceId::new(), + label: "Single window".into(), + columns: [(column.id, column)].into_iter().collect(), + windows: [(window.id, window.clone())].into_iter().collect(), + active_window: window.id, + panes: [(pane.id, pane)].into_iter().collect(), + active_pane: window.active_pane, + viewport: WorkspaceViewport::default(), + notifications: Vec::new(), + } + } + fn preview_test_workspace() -> Workspace { let left_pane = PaneRecord::new(PaneKind::Terminal); let top_right_pane = PaneRecord::new(PaneKind::Terminal); @@ -8588,7 +9008,7 @@ mod tests { }, }; - let placements = workspace_window_placements(&workspace, Some(preview)); + let placements = preferred_workspace_window_placements(&workspace, Some(preview)); assert_eq!(placements[0].frame.width, 1080); assert_eq!(placements[1].frame.x, 1080 + DEFAULT_WORKSPACE_WINDOW_GAP); @@ -8607,7 +9027,7 @@ mod tests { }, }; - let placements = workspace_window_placements(&workspace, Some(preview)); + let placements = preferred_workspace_window_placements(&workspace, Some(preview)); assert_eq!(placements[1].frame.height, MIN_WORKSPACE_WINDOW_HEIGHT); assert_eq!( @@ -8616,6 +9036,80 @@ mod tests { ); } + #[test] + fn workspace_window_placements_fit_single_window_to_viewport() { + let workspace = single_window_test_workspace(); + + let placements = workspace_window_placements(&workspace, 1320, 880, None); + + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].frame.width, 1320); + assert_eq!(placements[0].frame.height, 880); + } + + #[test] + fn workspace_window_placements_fit_columns_to_viewport_width() { + let workspace = preview_test_workspace(); + + let placements = workspace_window_placements(&workspace, 1600, 1002, None); + + assert_eq!(placements[0].frame.width, 746); + assert_eq!(placements[1].frame.x, 746 + DEFAULT_WORKSPACE_WINDOW_GAP); + assert_eq!(placements[1].frame.width, 852); + assert_eq!( + placements[0].frame.width + placements[1].frame.width + DEFAULT_WORKSPACE_WINDOW_GAP, + 1600 + ); + } + + #[test] + fn workspace_window_placements_fit_stacked_windows_to_viewport_height() { + let workspace = preview_test_workspace(); + + let placements = workspace_window_placements(&workspace, 1600, 1002, None); + + assert_eq!(placements[1].frame.height, 509); + assert_eq!(placements[2].frame.y, 509 + DEFAULT_WORKSPACE_WINDOW_GAP); + assert_eq!(placements[2].frame.height, 491); + assert_eq!( + placements[1].frame.height + placements[2].frame.height + DEFAULT_WORKSPACE_WINDOW_GAP, + 1002 + ); + } + + #[test] + fn workspace_window_placements_only_overflow_when_minimums_exceed_viewport() { + let workspace = preview_test_workspace(); + + let fitting = workspace_window_placements( + &workspace, + (MIN_WORKSPACE_WINDOW_WIDTH * 2) + DEFAULT_WORKSPACE_WINDOW_GAP, + 1002, + None, + ); + assert_eq!(fitting[0].frame.width, MIN_WORKSPACE_WINDOW_WIDTH); + assert_eq!(fitting[1].frame.width, MIN_WORKSPACE_WINDOW_WIDTH); + assert_eq!( + fitting[0].frame.width + fitting[1].frame.width + DEFAULT_WORKSPACE_WINDOW_GAP, + (MIN_WORKSPACE_WINDOW_WIDTH * 2) + DEFAULT_WORKSPACE_WINDOW_GAP + ); + + let overflowing = workspace_window_placements( + &workspace, + (MIN_WORKSPACE_WINDOW_WIDTH * 2) + DEFAULT_WORKSPACE_WINDOW_GAP - 1, + 1002, + None, + ); + assert_eq!(overflowing[0].frame.width, MIN_WORKSPACE_WINDOW_WIDTH); + assert_eq!(overflowing[1].frame.width, MIN_WORKSPACE_WINDOW_WIDTH); + assert_eq!( + overflowing[0].frame.width + + overflowing[1].frame.width + + DEFAULT_WORKSPACE_WINDOW_GAP, + (MIN_WORKSPACE_WINDOW_WIDTH * 2) + DEFAULT_WORKSPACE_WINDOW_GAP + ); + } + #[test] fn sidebar_split_position_stays_above_minimum_width() { assert_eq!( From b211f36ba407522c8c44f1c376143102acca7fa0 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 16:46:43 +0100 Subject: [PATCH 09/63] style: increase new tab button visibility --- crates/taskers-app/src/theme.rs | 40 ++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/taskers-app/src/theme.rs b/crates/taskers-app/src/theme.rs index cf42527..d6e288b 100644 --- a/crates/taskers-app/src/theme.rs +++ b/crates/taskers-app/src/theme.rs @@ -1259,11 +1259,26 @@ pub fn generate_css(p: &ThemePalette) -> String { min-height: 18px; }} - .surface-tab-close:hover, - .surface-tab-add:hover {{ + .surface-tab-close:hover {{ background: {border_06}; color: {text_subtle}; }} + + .surface-tab-add {{ + color: {text_bright}; + background: {accent_14}; + border: 1px solid {accent_35}; + min-width: 22px; + min-height: 20px; + font-size: 0.82rem; + font-weight: 700; + }} + + .surface-tab-add:hover {{ + background: {accent_22}; + border-color: {accent_48}; + color: {text_bright}; + }} ", border_03 = rgba(p.border, 0.03), border_04 = rgba(p.border, 0.04), @@ -1276,7 +1291,9 @@ pub fn generate_css(p: &ThemePalette) -> String { text_dim = p.text_dim.to_hex(), text_subtle = p.text_subtle.to_hex(), accent_14 = rgba(p.accent, 0.14), + accent_22 = rgba(p.accent, 0.22), accent_35 = rgba(p.accent, 0.35), + accent_48 = rgba(p.accent, 0.48), busy_08 = rgba(p.busy, 0.08), busy_22 = rgba(p.busy, 0.22), completed_08 = rgba(p.completed, 0.08), @@ -1353,18 +1370,21 @@ pub fn generate_css(p: &ThemePalette) -> String { }} .inline-tab-add {{ - min-width: 20px; + min-width: 22px; min-height: 20px; padding: 0; - font-size: 0.74rem; - color: {text_faint}; - background: transparent; + font-size: 0.82rem; + font-weight: 700; + color: {text_bright}; + background: {inline_add_bg}; + border: 1px solid {inline_add_border}; border-radius: 4px; }} .inline-tab-add:hover {{ - background: {inline_close_hover}; - color: {text_subtle}; + background: {inline_add_hover}; + border-color: {inline_add_hover_border}; + color: {text_bright}; }} ", inline_bg = rgba(p.border, 0.03), @@ -1372,6 +1392,10 @@ pub fn generate_css(p: &ThemePalette) -> String { inline_hover = rgba(p.border, 0.06), inline_active_bg = rgba(p.accent, 0.14), inline_active_border = rgba(p.accent, 0.35), + inline_add_bg = rgba(p.accent, 0.14), + inline_add_border = rgba(p.accent, 0.35), + inline_add_hover = rgba(p.accent, 0.22), + inline_add_hover_border = rgba(p.accent, 0.48), inline_close_hover = rgba(p.border, 0.06), text_bright = p.text_bright.to_hex(), text_dim = p.text_dim.to_hex(), From be983406beeaf2de9d69597f499997caa0ee4611 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 22:48:53 +0100 Subject: [PATCH 10/63] chore: bootstrap greenfield taskers rewrite --- .gitignore | 1 + greenfield/Cargo.lock | 5014 ++++++++++++++++++++ greenfield/Cargo.toml | 25 + greenfield/README.md | 20 + greenfield/crates/taskers-core/Cargo.toml | 11 + greenfield/crates/taskers-core/src/lib.rs | 516 ++ greenfield/crates/taskers-host/Cargo.toml | 12 + greenfield/crates/taskers-host/src/lib.rs | 117 + greenfield/crates/taskers-shell/Cargo.toml | 12 + greenfield/crates/taskers-shell/src/lib.rs | 403 ++ greenfield/crates/taskers/Cargo.toml | 18 + greenfield/crates/taskers/src/main.rs | 53 + 12 files changed, 6202 insertions(+) create mode 100644 greenfield/Cargo.lock create mode 100644 greenfield/Cargo.toml create mode 100644 greenfield/README.md create mode 100644 greenfield/crates/taskers-core/Cargo.toml create mode 100644 greenfield/crates/taskers-core/src/lib.rs create mode 100644 greenfield/crates/taskers-host/Cargo.toml create mode 100644 greenfield/crates/taskers-host/src/lib.rs create mode 100644 greenfield/crates/taskers-shell/Cargo.toml create mode 100644 greenfield/crates/taskers-shell/src/lib.rs create mode 100644 greenfield/crates/taskers/Cargo.toml create mode 100644 greenfield/crates/taskers/src/main.rs diff --git a/.gitignore b/.gitignore index e369eb3..3624bae 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ dist/ build/ macos/Taskers.xcodeproj/ +greenfield/target/ diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock new file mode 100644 index 0000000..9307734 --- /dev/null +++ b/greenfield/Cargo.lock @@ -0,0 +1,5014 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.11.0", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-foundation", + "core-graphics-types", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-desktop", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-signals", + "dioxus-stores", + "dioxus-web", + "manganis", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +dependencies = [ + "dioxus-cli-config", + "http", + "infer", + "jni 0.21.1", + "ndk", + "ndk-context", + "ndk-sys", + "percent-encoding", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-desktop" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" +dependencies = [ + "async-trait", + "base64", + "bytes", + "cocoa", + "core-foundation", + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "dunce", + "futures-channel", + "futures-util", + "generational-box", + "global-hotkey", + "infer", + "jni 0.21.1", + "lazy-js-bundle", + "libc", + "muda", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "objc_id", + "percent-encoding", + "rand 0.9.2", + "rfd", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "signal-hook", + "slab", + "subtle", + "tao", + "thiserror 2.0.18", + "tokio", + "tracing", + "tray-icon", + "tungstenite", + "webbrowser", + "wry", +] + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "serde", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-logger" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +dependencies = [ + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-web" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap", + "selectors", +] + +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "manganis" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow 0.7.15", +] + +[[package]] +name = "manganis-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.0", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.0", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.5+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "pollster", + "raw-window-handle 0.6.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "subsecond" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading 0.8.9", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +dependencies = [ + "serde", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics 0.25.0", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "taskers" +version = "0.1.0-alpha.1" +dependencies = [ + "dioxus", + "dioxus-desktop", + "taskers-core", + "taskers-host", + "taskers-shell", +] + +[[package]] +name = "taskers-core" +version = "0.1.0-alpha.1" +dependencies = [ + "indexmap", + "parking_lot", +] + +[[package]] +name = "taskers-host" +version = "0.1.0-alpha.1" +dependencies = [ + "anyhow", + "dioxus-desktop", + "taskers-core", +] + +[[package]] +name = "taskers-shell" +version = "0.1.0-alpha.1" +dependencies = [ + "dioxus", + "taskers-core", + "taskers-host", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "rustls", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" +dependencies = [ + "core-foundation", + "jni 0.22.4", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml new file mode 100644 index 0000000..4dfa222 --- /dev/null +++ b/greenfield/Cargo.toml @@ -0,0 +1,25 @@ +[workspace] +members = [ + "crates/taskers", + "crates/taskers-core", + "crates/taskers-host", + "crates/taskers-shell", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT OR Apache-2.0" +repository = "https://github.com/OneNoted/taskers" +version = "0.1.0-alpha.1" + +[workspace.dependencies] +anyhow = "1" +dioxus = { version = "0.7.3", features = ["desktop"] } +dioxus-desktop = "0.7.3" +indexmap = "2" +parking_lot = "0.12" +taskers-core = { path = "crates/taskers-core" } +taskers-host = { path = "crates/taskers-host" } +taskers-shell = { path = "crates/taskers-shell" } + diff --git a/greenfield/README.md b/greenfield/README.md new file mode 100644 index 0000000..95173b9 --- /dev/null +++ b/greenfield/README.md @@ -0,0 +1,20 @@ +# Taskers Greenfield Rewrite + +This nested workspace is the bootstrap implementation for the Dioxus-based rewrite. + +It is intentionally isolated from the legacy GTK/AppKit workspace at the repo root so the +new architecture can evolve without disturbing the existing product. + +Current scope: + +- shared Rust core for workspace/pane/surface state +- Dioxus desktop shell with CSS-driven chrome +- native browser child-webview portal mounted against the Dioxus window +- explicit terminal host seam for future Ghostty integration + +Run it from this workspace: + +```bash +cargo run -p taskers +``` + diff --git a/greenfield/crates/taskers-core/Cargo.toml b/greenfield/crates/taskers-core/Cargo.toml new file mode 100644 index 0000000..12ab367 --- /dev/null +++ b/greenfield/crates/taskers-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "taskers-core" +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +indexmap.workspace = true +parking_lot.workspace = true + diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs new file mode 100644 index 0000000..b1cc56e --- /dev/null +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -0,0 +1,516 @@ +use indexmap::IndexMap; +use parking_lot::Mutex; +use std::{fmt, sync::Arc}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PaneId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SurfaceId(pub u64); + +impl fmt::Display for PaneId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "pane-{}", self.0) + } +} + +impl fmt::Display for SurfaceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "surface-{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitAxis { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceKind { + Terminal, + Browser, +} + +impl SurfaceKind { + pub fn label(self) -> &'static str { + match self { + Self::Terminal => "Terminal", + Self::Browser => "Browser", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PixelSize { + pub width: i32, + pub height: i32, +} + +impl PixelSize { + pub const fn new(width: i32, height: i32) -> Self { + Self { width, height } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Frame { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +impl Frame { + pub const fn new(x: i32, y: i32, width: i32, height: i32) -> Self { + Self { + x, + y, + width, + height, + } + } + + pub fn inset_top(self, amount: i32) -> Self { + let clamped = amount.clamp(0, self.height.saturating_sub(1)); + Self { + x: self.x, + y: self.y + clamped, + width: self.width, + height: (self.height - clamped).max(1), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LayoutMetrics { + pub sidebar_width: i32, + pub toolbar_height: i32, + pub workspace_padding: i32, + pub split_gap: i32, + pub pane_header_height: i32, +} + +impl Default for LayoutMetrics { + fn default() -> Self { + Self { + sidebar_width: 248, + toolbar_height: 64, + workspace_padding: 16, + split_gap: 12, + pane_header_height: 38, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceSnapshot { + pub id: SurfaceId, + pub kind: SurfaceKind, + pub title: String, + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneSnapshot { + pub id: PaneId, + pub active: bool, + pub surface: SurfaceSnapshot, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LayoutNodeSnapshot { + Pane(PaneSnapshot), + Split { + axis: SplitAxis, + ratio: f32, + first: Box, + second: Box, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortalSurfacePlan { + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub kind: SurfaceKind, + pub title: String, + pub url: Option, + pub frame: Frame, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfacePortalPlan { + pub window: Frame, + pub content: Frame, + pub panes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ShellSnapshot { + pub revision: u64, + pub workspace_title: String, + pub workspace_count: usize, + pub active_pane: PaneId, + pub layout: LayoutNodeSnapshot, + pub portal: SurfacePortalPlan, + pub metrics: LayoutMetrics, +} + +#[derive(Debug, Clone)] +struct SurfaceRecord { + id: SurfaceId, + kind: SurfaceKind, + title: String, + url: Option, +} + +#[derive(Debug, Clone)] +struct PaneRecord { + id: PaneId, + surface: SurfaceRecord, +} + +#[derive(Debug, Clone)] +enum LayoutNode { + Leaf(PaneId), + Split { + axis: SplitAxis, + ratio_millis: u16, + first: Box, + second: Box, + }, +} + +impl LayoutNode { + fn split_leaf( + &mut self, + target: PaneId, + axis: SplitAxis, + new_pane: PaneId, + ratio_millis: u16, + ) -> bool { + match self { + Self::Leaf(existing) if *existing == target => { + let old = *existing; + *self = Self::Split { + axis, + ratio_millis, + first: Box::new(Self::Leaf(old)), + second: Box::new(Self::Leaf(new_pane)), + }; + true + } + Self::Split { first, second, .. } => { + first.split_leaf(target, axis, new_pane, ratio_millis) + || second.split_leaf(target, axis, new_pane, ratio_millis) + } + Self::Leaf(_) => false, + } + } +} + +#[derive(Debug, Clone)] +struct AppModel { + workspace_title: String, + active_pane: PaneId, + panes: IndexMap, + layout: LayoutNode, + window_size: PixelSize, +} + +#[derive(Debug)] +struct TaskersCore { + next_id: u64, + revision: u64, + metrics: LayoutMetrics, + model: AppModel, +} + +impl TaskersCore { + fn demo() -> Self { + let metrics = LayoutMetrics::default(); + let mut next_id = 1; + let first_pane = PaneId(next_id); + next_id += 1; + let first_surface = SurfaceId(next_id); + next_id += 1; + + let surface = SurfaceRecord { + id: first_surface, + kind: SurfaceKind::Terminal, + title: "Agent shell".into(), + url: None, + }; + let pane = PaneRecord { + id: first_pane, + surface, + }; + + let mut panes = IndexMap::new(); + panes.insert(first_pane, pane); + + Self { + next_id, + revision: 1, + metrics, + model: AppModel { + workspace_title: "Main".into(), + active_pane: first_pane, + panes, + layout: LayoutNode::Leaf(first_pane), + window_size: PixelSize::new(1440, 900), + }, + } + } + + fn revision(&self) -> u64 { + self.revision + } + + fn snapshot(&self) -> ShellSnapshot { + let layout = self.snapshot_layout(&self.model.layout); + let content = self.content_frame(); + let portal = SurfacePortalPlan { + window: Frame::new( + 0, + 0, + self.model.window_size.width, + self.model.window_size.height, + ), + content, + panes: self.collect_surface_plans(&self.model.layout, content), + }; + + ShellSnapshot { + revision: self.revision, + workspace_title: self.model.workspace_title.clone(), + workspace_count: 1, + active_pane: self.model.active_pane, + layout, + portal, + metrics: self.metrics, + } + } + + fn snapshot_layout(&self, node: &LayoutNode) -> LayoutNodeSnapshot { + match node { + LayoutNode::Leaf(pane_id) => { + let pane = self + .model + .panes + .get(pane_id) + .expect("layout pane should exist"); + LayoutNodeSnapshot::Pane(PaneSnapshot { + id: pane.id, + active: pane.id == self.model.active_pane, + surface: SurfaceSnapshot { + id: pane.surface.id, + kind: pane.surface.kind, + title: pane.surface.title.clone(), + url: pane.surface.url.clone(), + }, + }) + } + LayoutNode::Split { + axis, + ratio_millis, + first, + second, + } => LayoutNodeSnapshot::Split { + axis: *axis, + ratio: f32::from(*ratio_millis) / 1000.0, + first: Box::new(self.snapshot_layout(first)), + second: Box::new(self.snapshot_layout(second)), + }, + } + } + + fn content_frame(&self) -> Frame { + let metrics = self.metrics; + let padding = metrics.workspace_padding; + let x = metrics.sidebar_width + padding; + let y = metrics.toolbar_height + padding; + let width = (self.model.window_size.width - metrics.sidebar_width - (padding * 2)).max(320); + let height = + (self.model.window_size.height - metrics.toolbar_height - (padding * 2)).max(240); + Frame::new(x, y, width, height) + } + + fn collect_surface_plans(&self, node: &LayoutNode, frame: Frame) -> Vec { + let mut panes = Vec::new(); + self.collect_surface_plans_into(node, frame, &mut panes); + panes + } + + fn collect_surface_plans_into( + &self, + node: &LayoutNode, + frame: Frame, + out: &mut Vec, + ) { + match node { + LayoutNode::Leaf(pane_id) => { + if let Some(pane) = self.model.panes.get(pane_id) { + out.push(PortalSurfacePlan { + pane_id: pane.id, + surface_id: pane.surface.id, + kind: pane.surface.kind, + title: pane.surface.title.clone(), + url: pane.surface.url.clone(), + frame: frame.inset_top(self.metrics.pane_header_height), + }); + } + } + LayoutNode::Split { + axis, + ratio_millis, + first, + second, + } => { + let (first_frame, second_frame) = + split_frame(frame, *axis, *ratio_millis, self.metrics.split_gap); + self.collect_surface_plans_into(first, first_frame, out); + self.collect_surface_plans_into(second, second_frame, out); + } + } + } + + fn split_active(&mut self, kind: SurfaceKind, axis: SplitAxis) { + let new_pane = PaneId(self.next_id); + self.next_id += 1; + let new_surface = SurfaceId(self.next_id); + self.next_id += 1; + + let title = match kind { + SurfaceKind::Terminal => format!("Task {}", new_pane.0), + SurfaceKind::Browser => "Browser".into(), + }; + let url = match kind { + SurfaceKind::Browser => Some("https://dioxuslabs.com/learn/0.7/".into()), + SurfaceKind::Terminal => None, + }; + + let pane = PaneRecord { + id: new_pane, + surface: SurfaceRecord { + id: new_surface, + kind, + title, + url, + }, + }; + self.model.panes.insert(new_pane, pane); + + if self + .model + .layout + .split_leaf(self.model.active_pane, axis, new_pane, 500) + { + self.model.active_pane = new_pane; + self.revision += 1; + } + } + + fn focus_pane(&mut self, pane_id: PaneId) { + if self.model.panes.contains_key(&pane_id) && self.model.active_pane != pane_id { + self.model.active_pane = pane_id; + self.revision += 1; + } + } + + fn set_window_size(&mut self, size: PixelSize) { + if self.model.window_size != size { + self.model.window_size = size; + self.revision += 1; + } + } +} + +fn split_frame(frame: Frame, axis: SplitAxis, ratio_millis: u16, gap: i32) -> (Frame, Frame) { + let ratio = f32::from(ratio_millis.clamp(100, 900)) / 1000.0; + + match axis { + SplitAxis::Horizontal => { + let available = (frame.width - gap).max(2); + let first_width = ((available as f32) * ratio).round() as i32; + let second_width = (available - first_width).max(1); + let first_width = first_width.max(1); + + ( + Frame::new(frame.x, frame.y, first_width, frame.height), + Frame::new( + frame.x + first_width + gap, + frame.y, + second_width, + frame.height, + ), + ) + } + SplitAxis::Vertical => { + let available = (frame.height - gap).max(2); + let first_height = ((available as f32) * ratio).round() as i32; + let second_height = (available - first_height).max(1); + let first_height = first_height.max(1); + + ( + Frame::new(frame.x, frame.y, frame.width, first_height), + Frame::new( + frame.x, + frame.y + first_height + gap, + frame.width, + second_height, + ), + ) + } + } +} + +#[derive(Clone)] +pub struct SharedCore { + inner: Arc>, +} + +impl SharedCore { + pub fn demo() -> Self { + Self { + inner: Arc::new(Mutex::new(TaskersCore::demo())), + } + } + + pub fn revision(&self) -> u64 { + self.inner.lock().revision() + } + + pub fn snapshot(&self) -> ShellSnapshot { + self.inner.lock().snapshot() + } + + pub fn set_window_size(&self, size: PixelSize) { + self.inner.lock().set_window_size(size); + } + + pub fn split_with_browser(&self) { + self.inner + .lock() + .split_active(SurfaceKind::Browser, SplitAxis::Horizontal); + } + + pub fn split_with_terminal(&self) { + self.inner + .lock() + .split_active(SurfaceKind::Terminal, SplitAxis::Vertical); + } + + pub fn focus_pane(&self, pane_id: PaneId) { + self.inner.lock().focus_pane(pane_id); + } +} + +impl PartialEq for SharedCore { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for SharedCore {} diff --git a/greenfield/crates/taskers-host/Cargo.toml b/greenfield/crates/taskers-host/Cargo.toml new file mode 100644 index 0000000..eaf98cf --- /dev/null +++ b/greenfield/crates/taskers-host/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "taskers-host" +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +anyhow.workspace = true +dioxus-desktop.workspace = true +taskers-core.workspace = true + diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs new file mode 100644 index 0000000..da36152 --- /dev/null +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -0,0 +1,117 @@ +use anyhow::{Context, Result}; +use dioxus_desktop::{ + tao::{ + dpi::{LogicalPosition, LogicalSize}, + window::Window, + }, + wry::{Rect, WebView, WebViewBuilder}, +}; +use std::{cell::RefCell, collections::HashMap, sync::Arc}; +use taskers_core::{Frame, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceKind}; + +thread_local! { + static HOST_RUNTIME: RefCell = RefCell::new(HostRuntimeState::default()); +} + +#[derive(Default)] +struct HostRuntimeState { + window: Option>, + browser_surfaces: HashMap, +} + +struct BrowserSurface { + webview: WebView, + url: String, +} + +pub fn attach_window(window: Arc) -> Result<()> { + HOST_RUNTIME.with(|slot| { + let mut state = slot.borrow_mut(); + state.window = Some(window); + Ok(()) + }) +} + +pub fn sync_snapshot(snapshot: &ShellSnapshot) -> Result<()> { + HOST_RUNTIME.with(|slot| { + let mut state = slot.borrow_mut(); + let Some(window) = state.window.clone() else { + return Ok(()); + }; + + let desired: Vec<_> = snapshot + .portal + .panes + .iter() + .filter(|pane| pane.kind == SurfaceKind::Browser) + .cloned() + .collect(); + + let desired_ids: HashMap = desired + .iter() + .cloned() + .map(|plan| (plan.surface_id, plan)) + .collect(); + + state + .browser_surfaces + .retain(|surface_id, _| desired_ids.contains_key(surface_id)); + + for plan in desired { + match state.browser_surfaces.get_mut(&plan.surface_id) { + Some(surface) => surface.sync(&plan)?, + None => { + let surface = BrowserSurface::new(&window, &plan)?; + state.browser_surfaces.insert(plan.surface_id, surface); + } + } + } + + Ok(()) + }) +} + +pub fn terminal_host_status() -> &'static str { + "Ghostty integration still needs a native portal adapter in the rewrite." +} + +impl BrowserSurface { + fn new(window: &Arc, plan: &PortalSurfacePlan) -> Result { + let url = plan + .url + .clone() + .unwrap_or_else(|| "https://dioxuslabs.com/learn/0.7/".into()); + + let webview = WebViewBuilder::new() + .with_url(&url) + .with_bounds(rect_from_frame(plan.frame)) + .build_as_child(window.as_ref()) + .with_context(|| format!("failed to create browser surface {}", plan.surface_id.0))?; + + Ok(Self { webview, url }) + } + + fn sync(&mut self, plan: &PortalSurfacePlan) -> Result<()> { + self.webview + .set_bounds(rect_from_frame(plan.frame)) + .context("failed to update browser bounds")?; + + if let Some(url) = &plan.url + && self.url != *url + { + self.webview.load_url(url).with_context(|| { + format!("failed to navigate browser surface {}", plan.surface_id.0) + })?; + self.url = url.clone(); + } + + Ok(()) + } +} + +fn rect_from_frame(frame: Frame) -> Rect { + Rect { + position: LogicalPosition::new(frame.x, frame.y).into(), + size: LogicalSize::new(frame.width.max(1), frame.height.max(1)).into(), + } +} diff --git a/greenfield/crates/taskers-shell/Cargo.toml b/greenfield/crates/taskers-shell/Cargo.toml new file mode 100644 index 0000000..bea18f1 --- /dev/null +++ b/greenfield/crates/taskers-shell/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "taskers-shell" +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +dioxus.workspace = true +taskers-core.workspace = true +taskers-host.workspace = true + diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs new file mode 100644 index 0000000..c622e1f --- /dev/null +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -0,0 +1,403 @@ +use dioxus::prelude::*; +use std::sync::Arc; +use taskers_core::{LayoutNodeSnapshot, PaneId, SharedCore, SplitAxis, SurfaceKind}; + +const APP_CSS: &str = r#" +html, body, #main { + margin: 0; + width: 100%; + height: 100%; + background: + radial-gradient(circle at top right, rgba(94, 173, 255, 0.18), transparent 32%), + linear-gradient(180deg, #0b1020 0%, #0d1326 52%, #10182f 100%); + color: #f4f7fb; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +button { + font: inherit; +} +.app-shell { + width: 100vw; + height: 100vh; + display: flex; + overflow: hidden; +} +.sidebar { + width: 248px; + padding: 18px 16px; + background: rgba(8, 13, 28, 0.78); + border-right: 1px solid rgba(163, 191, 255, 0.12); + backdrop-filter: blur(28px); + display: flex; + flex-direction: column; + gap: 16px; +} +.brand { + display: flex; + flex-direction: column; + gap: 4px; +} +.eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #8fb7ff; +} +.brand h1 { + margin: 0; + font-size: 28px; + line-height: 1; +} +.sidebar-card { + padding: 14px; + border-radius: 16px; + background: rgba(18, 28, 53, 0.78); + border: 1px solid rgba(163, 191, 255, 0.1); +} +.workspace-pill { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(58, 104, 206, 0.35), rgba(35, 48, 92, 0.45)); + border: 1px solid rgba(163, 191, 255, 0.18); +} +.workspace-pill strong { + font-size: 15px; +} +.workspace-pill span, .sidebar-card p, .toolbar-subtitle { + color: #b4c7ec; +} +.main-column { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; +} +.toolbar { + height: 64px; + padding: 12px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-bottom: 1px solid rgba(163, 191, 255, 0.08); + background: rgba(8, 12, 24, 0.48); + backdrop-filter: blur(20px); +} +.toolbar-title { + display: flex; + flex-direction: column; + gap: 2px; +} +.toolbar-title strong { + font-size: 16px; +} +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} +.toolbar button { + border: 0; + border-radius: 999px; + padding: 10px 14px; + background: #d7e7ff; + color: #102040; + font-weight: 700; + cursor: pointer; +} +.toolbar button.secondary { + background: rgba(255, 255, 255, 0.08); + color: #eef4ff; +} +.workspace-canvas { + flex: 1; + min-height: 0; + padding: 16px; +} +.split-container { + width: 100%; + height: 100%; + display: flex; + gap: 12px; + min-width: 0; + min-height: 0; +} +.split-child { + min-width: 0; + min-height: 0; +} +.pane { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-radius: 20px; + background: rgba(10, 16, 32, 0.92); + border: 1px solid rgba(163, 191, 255, 0.08); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + overflow: hidden; +} +.pane-active { + border-color: rgba(130, 187, 255, 0.55); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.05), + 0 0 0 1px rgba(61, 151, 255, 0.25); +} +.pane-header { + height: 38px; + min-height: 38px; + padding: 0 14px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(163, 191, 255, 0.08); + background: linear-gradient(180deg, rgba(22, 33, 63, 0.92), rgba(14, 22, 42, 0.92)); +} +.pane-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.pane-title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kind-badge { + border-radius: 999px; + padding: 4px 8px; + background: rgba(143, 183, 255, 0.12); + color: #a9c7ff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.pane-body { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; +} +.surface-placeholder { + width: 100%; + height: 100%; + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} +.surface-placeholder.browser { + background: + linear-gradient(135deg, rgba(30, 61, 120, 0.18), rgba(13, 20, 39, 0.05)), + radial-gradient(circle at bottom right, rgba(87, 197, 255, 0.12), transparent 30%); +} +.surface-placeholder.terminal { + background: + linear-gradient(180deg, rgba(9, 12, 21, 0.9), rgba(12, 18, 34, 0.96)); +} +.placeholder-note { + max-width: 520px; + color: #b4c7ec; + line-height: 1.5; +} +.terminal-lines { + margin: 0; + padding: 16px; + border-radius: 16px; + background: rgba(4, 6, 12, 0.84); + border: 1px solid rgba(91, 114, 165, 0.22); + color: #9ff3b0; + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 13px; + white-space: pre-wrap; +} +@media (max-width: 900px) { + .sidebar { display: none; } + .workspace-canvas { padding: 12px; } + .toolbar { padding: 12px; } +} +"#; + +pub fn app() -> Element { + let core = consume_context::(); + let revision = use_signal(|| core.revision()); + let _ = revision(); + let snapshot = core.snapshot(); + let terminal_status = taskers_host::terminal_host_status(); + + let focus = { + let core = core.clone(); + Arc::new(move |pane_id: PaneId| { + core.focus_pane(pane_id); + if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { + eprintln!("taskers host sync failed after focus: {error}"); + } + }) + }; + + let split_terminal = { + let core = core.clone(); + let mut revision = revision; + move |_| { + core.split_with_terminal(); + if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { + eprintln!("taskers host sync failed after terminal split: {error}"); + } + revision.set(core.revision()); + } + }; + + let split_browser = { + let core = core.clone(); + let mut revision = revision; + move |_| { + core.split_with_browser(); + if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { + eprintln!("taskers host sync failed after browser split: {error}"); + } + revision.set(core.revision()); + } + }; + + rsx! { + style { "{APP_CSS}" } + div { class: "app-shell", + aside { class: "sidebar", + div { class: "brand", + span { class: "eyebrow", "Greenfield rewrite" } + h1 { "Taskers" } + } + div { class: "workspace-pill", + strong { "{snapshot.workspace_title}" } + span { "{snapshot.workspace_count} workspace · revision {snapshot.revision}" } + } + div { class: "sidebar-card", + div { class: "eyebrow", "Surface portal" } + p { "Browser panes are mounted as native child webviews against the Dioxus window. Terminal panes already reserve the same host slot shape." } + } + } + + main { class: "main-column", + header { class: "toolbar", + div { class: "toolbar-title", + strong { "Unified shell bootstrap" } + div { class: "toolbar-subtitle", + "Dioxus chrome + native surface portal" + } + } + div { class: "toolbar-actions", + button { class: "secondary", onclick: split_terminal, "Split Terminal" } + button { onclick: split_browser, "Split Browser" } + } + } + + div { class: "workspace-canvas", + {render_layout(&snapshot.layout, focus.clone(), terminal_status)} + } + } + } + } +} + +fn render_layout( + node: &LayoutNodeSnapshot, + focus: Arc, + terminal_status: &'static str, +) -> Element { + match node { + LayoutNodeSnapshot::Split { + axis, + ratio, + first, + second, + } => { + let direction = match axis { + SplitAxis::Horizontal => "row", + SplitAxis::Vertical => "column", + }; + let first_weight = (*ratio).clamp(0.1, 0.9); + let second_weight = (1.0 - first_weight).clamp(0.1, 0.9); + let first_style = format!("flex: {first_weight} 1 0%;"); + let second_style = format!("flex: {second_weight} 1 0%;"); + + rsx! { + div { class: "split-container", style: "flex-direction: {direction};", + div { class: "split-child", style: "{first_style}", + {render_layout(first, focus.clone(), terminal_status)} + } + div { class: "split-child", style: "{second_style}", + {render_layout(second, focus.clone(), terminal_status)} + } + } + } + } + LayoutNodeSnapshot::Pane(pane) => { + let pane_class = if pane.active { + "pane pane-active" + } else { + "pane" + }; + let pane_id = pane.id; + let focus_this = focus.clone(); + let kind_label = pane.surface.kind.label(); + let placeholder = match pane.surface.kind { + SurfaceKind::Browser => { + let url = pane + .surface + .url + .clone() + .unwrap_or_else(|| "about:blank".into()); + rsx! { + div { class: "surface-placeholder browser", + div { class: "eyebrow", "Native browser surface" } + p { class: "placeholder-note", + "This pane is backed by a real child webview mounted by the host runtime." + } + p { class: "placeholder-note", + "Current URL: {url}" + } + } + } + } + SurfaceKind::Terminal => rsx! { + div { class: "surface-placeholder terminal", + div { class: "eyebrow", "Terminal host seam" } + p { class: "placeholder-note", + "{terminal_status}" + } + pre { class: "terminal-lines", + "$ jj status\n" + "Working copy (@): chore: bootstrap greenfield taskers rewrite\n" + "$ cargo run -p taskers\n" + "Launching Dioxus shell with native surface portal..." + } + } + }, + }; + + rsx! { + div { class: "{pane_class}", onclick: move |_| (focus_this)(pane_id), + div { class: "pane-header", + div { class: "pane-title", + span { class: "kind-badge", "{kind_label}" } + strong { "{pane.surface.title}" } + } + span { class: "eyebrow", "{pane.id}" } + } + div { class: "pane-body", + {placeholder} + } + } + } + } + } +} diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml new file mode 100644 index 0000000..7a47e12 --- /dev/null +++ b/greenfield/crates/taskers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "taskers" +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "taskers" +path = "src/main.rs" + +[dependencies] +dioxus.workspace = true +dioxus-desktop.workspace = true +taskers-core.workspace = true +taskers-host.workspace = true +taskers-shell.workspace = true + diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs new file mode 100644 index 0000000..48d4853 --- /dev/null +++ b/greenfield/crates/taskers/src/main.rs @@ -0,0 +1,53 @@ +use dioxus::LaunchBuilder; +use dioxus_desktop::{ + Config, WindowBuilder, + tao::{ + dpi::LogicalSize, + event::{Event, WindowEvent}, + }, +}; +use taskers_core::{PixelSize, SharedCore}; + +fn main() { + let core = SharedCore::demo(); + let core_for_window = core.clone(); + let core_for_events = core.clone(); + + LaunchBuilder::desktop() + .with_context(core.clone()) + .with_cfg( + Config::new() + .with_window( + WindowBuilder::new() + .with_title("Taskers") + .with_inner_size(LogicalSize::new(1440.0, 900.0)), + ) + .with_on_window(move |window, _dom| { + let size = window.inner_size(); + core_for_window + .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); + + if let Err(error) = taskers_host::attach_window(window.clone()) { + eprintln!("taskers host attach failed: {error}"); + } + if let Err(error) = taskers_host::sync_snapshot(&core_for_window.snapshot()) { + eprintln!("taskers host initial sync failed: {error}"); + } + }) + .with_custom_event_handler(move |event, _target| { + if let Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } = event + { + core_for_events + .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); + if let Err(error) = taskers_host::sync_snapshot(&core_for_events.snapshot()) + { + eprintln!("taskers host resize sync failed: {error}"); + } + } + }), + ) + .launch(taskers_shell::app); +} From 9681c97282e9ad825a94d584b8545f8c7b596101 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 18 Mar 2026 23:00:09 +0100 Subject: [PATCH 11/63] feat: add greenfield linux portal runtime --- greenfield/Cargo.lock | 127 +++++ greenfield/Cargo.toml | 5 +- greenfield/README.md | 6 +- greenfield/crates/taskers-core/Cargo.toml | 2 +- greenfield/crates/taskers-core/src/lib.rs | 568 ++++++++++++++++++--- greenfield/crates/taskers-host/Cargo.toml | 2 +- greenfield/crates/taskers-host/src/lib.rs | 272 +++++++--- greenfield/crates/taskers-shell/src/lib.rs | 184 +++++-- greenfield/crates/taskers/Cargo.toml | 3 +- greenfield/crates/taskers/src/main.rs | 80 ++- 10 files changed, 1047 insertions(+), 202 deletions(-) diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index 9307734..2c60c61 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -197,6 +197,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cocoa" version = "0.26.1" @@ -493,6 +499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -982,6 +989,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1081,6 +1094,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2258,6 +2282,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2697,6 +2733,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3262,6 +3319,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "servo_arc" version = "0.2.0" @@ -3303,6 +3371,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3615,6 +3699,8 @@ dependencies = [ "dioxus-desktop", "taskers-core", "taskers-host", + "taskers-paths", + "taskers-runtime", "taskers-shell", ] @@ -3624,6 +3710,19 @@ version = "0.1.0-alpha.1" dependencies = [ "indexmap", "parking_lot", + "tokio", +] + +[[package]] +name = "taskers-domain" +version = "0.3.0" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "uuid", ] [[package]] @@ -3632,9 +3731,26 @@ version = "0.1.0-alpha.1" dependencies = [ "anyhow", "dioxus-desktop", + "gtk", "taskers-core", ] +[[package]] +name = "taskers-paths" +version = "0.3.0" + +[[package]] +name = "taskers-runtime" +version = "0.3.0" +dependencies = [ + "anyhow", + "base64", + "libc", + "portable-pty", + "taskers-domain", + "taskers-paths", +] + [[package]] name = "taskers-shell" version = "0.1.0-alpha.1" @@ -4012,7 +4128,9 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4723,6 +4841,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml index 4dfa222..e591d0b 100644 --- a/greenfield/Cargo.toml +++ b/greenfield/Cargo.toml @@ -17,9 +17,12 @@ version = "0.1.0-alpha.1" anyhow = "1" dioxus = { version = "0.7.3", features = ["desktop"] } dioxus-desktop = "0.7.3" +gtk = "0.18" indexmap = "2" parking_lot = "0.12" +tokio = { version = "1.50.0", features = ["sync"] } taskers-core = { path = "crates/taskers-core" } taskers-host = { path = "crates/taskers-host" } +taskers-paths = { path = "../crates/taskers-paths" } +taskers-runtime = { path = "../crates/taskers-runtime" } taskers-shell = { path = "crates/taskers-shell" } - diff --git a/greenfield/README.md b/greenfield/README.md index 95173b9..6c43ea6 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -9,12 +9,12 @@ Current scope: - shared Rust core for workspace/pane/surface state - Dioxus desktop shell with CSS-driven chrome -- native browser child-webview portal mounted against the Dioxus window -- explicit terminal host seam for future Ghostty integration +- Linux GTK portal runtime that mounts browser panes into the Dioxus window +- startup/runtime bootstrap that scrubs inherited terminal env and installs shell integration +- explicit terminal host fallback until the Ghostty GTK4 bridge is reconciled with the GTK3 Dioxus host Run it from this workspace: ```bash cargo run -p taskers ``` - diff --git a/greenfield/crates/taskers-core/Cargo.toml b/greenfield/crates/taskers-core/Cargo.toml index 12ab367..f732bd2 100644 --- a/greenfield/crates/taskers-core/Cargo.toml +++ b/greenfield/crates/taskers-core/Cargo.toml @@ -8,4 +8,4 @@ version.workspace = true [dependencies] indexmap.workspace = true parking_lot.workspace = true - +tokio.workspace = true diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index b1cc56e..f147d17 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -1,6 +1,7 @@ use indexmap::IndexMap; use parking_lot::Mutex; -use std::{fmt, sync::Arc}; +use std::{collections::BTreeMap, fmt, sync::Arc}; +use tokio::sync::watch; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PaneId(pub u64); @@ -41,6 +42,75 @@ impl SurfaceKind { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeCapability { + Ready, + Fallback { message: String }, + Unavailable { message: String }, +} + +impl RuntimeCapability { + pub fn message(&self) -> Option<&str> { + match self { + Self::Ready => None, + Self::Fallback { message } | Self::Unavailable { message } => Some(message), + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Ready => "Ready", + Self::Fallback { .. } => "Fallback", + Self::Unavailable { .. } => "Unavailable", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeStatus { + pub ghostty_runtime: RuntimeCapability, + pub shell_integration: RuntimeCapability, + pub terminal_host: RuntimeCapability, +} + +impl Default for RuntimeStatus { + fn default() -> Self { + let unavailable = || RuntimeCapability::Unavailable { + message: "Not configured yet.".into(), + }; + Self { + ghostty_runtime: unavailable(), + shell_integration: unavailable(), + terminal_host: unavailable(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalDefaults { + pub cols: u16, + pub rows: u16, + pub command_argv: Vec, + pub env: BTreeMap, +} + +impl Default for TerminalDefaults { + fn default() -> Self { + Self { + cols: 120, + rows: 40, + command_argv: vec!["/bin/sh".into()], + env: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BootstrapModel { + pub runtime_status: RuntimeStatus, + pub terminal_defaults: TerminalDefaults, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PixelSize { pub width: i32, @@ -109,6 +179,7 @@ pub struct SurfaceSnapshot { pub kind: SurfaceKind, pub title: String, pub url: Option, + pub cwd: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -129,14 +200,43 @@ pub enum LayoutNodeSnapshot { }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserMountSpec { + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalMountSpec { + pub title: String, + pub cwd: Option, + pub cols: u16, + pub rows: u16, + pub command_argv: Vec, + pub env: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SurfaceMountSpec { + Browser(BrowserMountSpec), + Terminal(TerminalMountSpec), +} + +impl SurfaceMountSpec { + pub fn kind(&self) -> SurfaceKind { + match self { + Self::Browser(_) => SurfaceKind::Browser, + Self::Terminal(_) => SurfaceKind::Terminal, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PortalSurfacePlan { pub pane_id: PaneId, pub surface_id: SurfaceId, - pub kind: SurfaceKind, - pub title: String, - pub url: Option, + pub active: bool, pub frame: Frame, + pub mount: SurfaceMountSpec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -155,6 +255,16 @@ pub struct ShellSnapshot { pub layout: LayoutNodeSnapshot, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, + pub runtime_status: RuntimeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostEvent { + PaneFocused { pane_id: PaneId }, + SurfaceClosed { pane_id: PaneId, surface_id: SurfaceId }, + SurfaceTitleChanged { surface_id: SurfaceId, title: String }, + SurfaceUrlChanged { surface_id: SurfaceId, url: String }, + SurfaceCwdChanged { surface_id: SurfaceId, cwd: String }, } #[derive(Debug, Clone)] @@ -163,6 +273,7 @@ struct SurfaceRecord { kind: SurfaceKind, title: String, url: Option, + cwd: Option, } #[derive(Debug, Clone)] @@ -208,6 +319,40 @@ impl LayoutNode { Self::Leaf(_) => false, } } + + fn remove_leaf(self, target: PaneId) -> Option { + match self { + Self::Leaf(existing) if existing == target => None, + Self::Leaf(existing) => Some(Self::Leaf(existing)), + Self::Split { + axis, + ratio_millis, + first, + second, + } => { + let first = first.remove_leaf(target); + let second = second.remove_leaf(target); + match (first, second) { + (Some(first), Some(second)) => Some(Self::Split { + axis, + ratio_millis, + first: Box::new(first), + second: Box::new(second), + }), + (Some(first), None) => Some(first), + (None, Some(second)) => Some(second), + (None, None) => None, + } + } + } + } + + fn first_leaf_id(&self) -> PaneId { + match self { + Self::Leaf(pane_id) => *pane_id, + Self::Split { first, .. } => first.first_leaf_id(), + } + } } #[derive(Debug, Clone)] @@ -225,43 +370,33 @@ struct TaskersCore { revision: u64, metrics: LayoutMetrics, model: AppModel, + runtime_status: RuntimeStatus, + terminal_defaults: TerminalDefaults, } impl TaskersCore { - fn demo() -> Self { + fn with_bootstrap(bootstrap: BootstrapModel) -> Self { let metrics = LayoutMetrics::default(); - let mut next_id = 1; - let first_pane = PaneId(next_id); - next_id += 1; - let first_surface = SurfaceId(next_id); - next_id += 1; - - let surface = SurfaceRecord { - id: first_surface, - kind: SurfaceKind::Terminal, - title: "Agent shell".into(), - url: None, - }; - let pane = PaneRecord { - id: first_pane, - surface, - }; - - let mut panes = IndexMap::new(); - panes.insert(first_pane, pane); - - Self { - next_id, + let mut core = Self { + next_id: 1, revision: 1, metrics, model: AppModel { workspace_title: "Main".into(), - active_pane: first_pane, - panes, - layout: LayoutNode::Leaf(first_pane), + active_pane: PaneId(0), + panes: IndexMap::new(), + layout: LayoutNode::Leaf(PaneId(0)), window_size: PixelSize::new(1440, 900), }, - } + runtime_status: bootstrap.runtime_status, + terminal_defaults: bootstrap.terminal_defaults, + }; + + let pane = core.make_surface(SurfaceKind::Terminal, "Agent shell".into(), None, None); + core.model.active_pane = pane.id; + core.model.layout = LayoutNode::Leaf(pane.id); + core.model.panes.insert(pane.id, pane); + core } fn revision(&self) -> u64 { @@ -290,6 +425,7 @@ impl TaskersCore { layout, portal, metrics: self.metrics, + runtime_status: self.runtime_status.clone(), } } @@ -309,6 +445,7 @@ impl TaskersCore { kind: pane.surface.kind, title: pane.surface.title.clone(), url: pane.surface.url.clone(), + cwd: pane.surface.cwd.clone(), }, }) } @@ -355,10 +492,9 @@ impl TaskersCore { out.push(PortalSurfacePlan { pane_id: pane.id, surface_id: pane.surface.id, - kind: pane.surface.kind, - title: pane.surface.title.clone(), - url: pane.surface.url.clone(), + active: pane.id == self.model.active_pane, frame: frame.inset_top(self.metrics.pane_header_height), + mount: self.mount_spec_for(pane), }); } } @@ -376,53 +512,188 @@ impl TaskersCore { } } - fn split_active(&mut self, kind: SurfaceKind, axis: SplitAxis) { - let new_pane = PaneId(self.next_id); - self.next_id += 1; - let new_surface = SurfaceId(self.next_id); - self.next_id += 1; - - let title = match kind { - SurfaceKind::Terminal => format!("Task {}", new_pane.0), - SurfaceKind::Browser => "Browser".into(), - }; - let url = match kind { - SurfaceKind::Browser => Some("https://dioxuslabs.com/learn/0.7/".into()), - SurfaceKind::Terminal => None, - }; + fn mount_spec_for(&self, pane: &PaneRecord) -> SurfaceMountSpec { + match pane.surface.kind { + SurfaceKind::Browser => SurfaceMountSpec::Browser(BrowserMountSpec { + url: pane + .surface + .url + .clone() + .unwrap_or_else(|| "https://dioxuslabs.com/learn/0.7/".into()), + }), + SurfaceKind::Terminal => { + let mut env = self.terminal_defaults.env.clone(); + env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); + env.insert("TASKERS_SURFACE_ID".into(), pane.surface.id.to_string()); + env.insert("TASKERS_WORKSPACE_ID".into(), "main".into()); + SurfaceMountSpec::Terminal(TerminalMountSpec { + title: pane.surface.title.clone(), + cwd: pane.surface.cwd.clone(), + cols: self.terminal_defaults.cols, + rows: self.terminal_defaults.rows, + command_argv: self.terminal_defaults.command_argv.clone(), + env, + }) + } + } + } - let pane = PaneRecord { - id: new_pane, - surface: SurfaceRecord { - id: new_surface, + fn split_active(&mut self, kind: SurfaceKind, axis: SplitAxis) -> bool { + let pane = match kind { + SurfaceKind::Terminal => self.make_surface(kind, self.next_terminal_title(), None, None), + SurfaceKind::Browser => self.make_surface( kind, - title, - url, - }, + "Browser".into(), + Some("https://dioxuslabs.com/learn/0.7/".into()), + None, + ), }; - self.model.panes.insert(new_pane, pane); + let pane_id = pane.id; + self.model.panes.insert(pane.id, pane); if self .model .layout - .split_leaf(self.model.active_pane, axis, new_pane, 500) + .split_leaf(self.model.active_pane, axis, pane_id, 500) { - self.model.active_pane = new_pane; + self.model.active_pane = pane_id; self.revision += 1; + true + } else { + false } } - fn focus_pane(&mut self, pane_id: PaneId) { + fn focus_pane(&mut self, pane_id: PaneId) -> bool { if self.model.panes.contains_key(&pane_id) && self.model.active_pane != pane_id { self.model.active_pane = pane_id; self.revision += 1; + true + } else { + false } } - fn set_window_size(&mut self, size: PixelSize) { + fn set_window_size(&mut self, size: PixelSize) -> bool { if self.model.window_size != size { self.model.window_size = size; self.revision += 1; + true + } else { + false + } + } + + fn apply_host_event(&mut self, event: HostEvent) -> bool { + match event { + HostEvent::PaneFocused { pane_id } => self.focus_pane(pane_id), + HostEvent::SurfaceClosed { + pane_id, + surface_id, + } => self.close_surface(pane_id, surface_id), + HostEvent::SurfaceTitleChanged { surface_id, title } => { + self.update_surface(surface_id, |surface| { + if surface.title != title { + surface.title = title.clone(); + true + } else { + false + } + }) + } + HostEvent::SurfaceUrlChanged { surface_id, url } => { + self.update_surface(surface_id, |surface| { + if surface.url.as_deref() != Some(url.as_str()) { + surface.url = Some(url.clone()); + true + } else { + false + } + }) + } + HostEvent::SurfaceCwdChanged { surface_id, cwd } => { + self.update_surface(surface_id, |surface| { + if surface.cwd.as_deref() != Some(cwd.as_str()) { + surface.cwd = Some(cwd.clone()); + true + } else { + false + } + }) + } + } + } + + fn close_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { + let Some(pane) = self.model.panes.get(&pane_id) else { + return false; + }; + if pane.surface.id != surface_id { + return false; + } + + self.model.panes.shift_remove(&pane_id); + self.model.layout = match self.model.layout.clone().remove_leaf(pane_id) { + Some(layout) => layout, + None => { + let replacement = self.make_surface( + SurfaceKind::Terminal, + "Agent shell".into(), + None, + None, + ); + let replacement_id = replacement.id; + self.model.panes.insert(replacement_id, replacement); + LayoutNode::Leaf(replacement_id) + } + }; + + if !self.model.panes.contains_key(&self.model.active_pane) { + self.model.active_pane = self.model.layout.first_leaf_id(); + } + + self.revision += 1; + true + } + + fn update_surface( + &mut self, + surface_id: SurfaceId, + mut update: impl FnMut(&mut SurfaceRecord) -> bool, + ) -> bool { + for pane in self.model.panes.values_mut() { + if pane.surface.id == surface_id && update(&mut pane.surface) { + self.revision += 1; + return true; + } + } + false + } + + fn next_terminal_title(&self) -> String { + format!("Task {}", self.next_id) + } + + fn make_surface( + &mut self, + kind: SurfaceKind, + title: String, + url: Option, + cwd: Option, + ) -> PaneRecord { + let pane_id = PaneId(self.next_id); + self.next_id += 1; + let surface_id = SurfaceId(self.next_id); + self.next_id += 1; + PaneRecord { + id: pane_id, + surface: SurfaceRecord { + id: surface_id, + kind, + title, + url, + cwd, + }, } } } @@ -469,41 +740,60 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio_millis: u16, gap: i32) -> (F #[derive(Clone)] pub struct SharedCore { inner: Arc>, + revisions: watch::Sender, } impl SharedCore { - pub fn demo() -> Self { + pub fn bootstrap(bootstrap: BootstrapModel) -> Self { + let core = TaskersCore::with_bootstrap(bootstrap); + let (revisions, _) = watch::channel(core.revision()); Self { - inner: Arc::new(Mutex::new(TaskersCore::demo())), + inner: Arc::new(Mutex::new(core)), + revisions, } } + pub fn demo() -> Self { + Self::bootstrap(BootstrapModel::default()) + } + pub fn revision(&self) -> u64 { self.inner.lock().revision() } + pub fn subscribe_revisions(&self) -> watch::Receiver { + self.revisions.subscribe() + } + pub fn snapshot(&self) -> ShellSnapshot { self.inner.lock().snapshot() } pub fn set_window_size(&self, size: PixelSize) { - self.inner.lock().set_window_size(size); + self.mutate(|core| core.set_window_size(size)); } pub fn split_with_browser(&self) { - self.inner - .lock() - .split_active(SurfaceKind::Browser, SplitAxis::Horizontal); + self.mutate(|core| core.split_active(SurfaceKind::Browser, SplitAxis::Horizontal)); } pub fn split_with_terminal(&self) { - self.inner - .lock() - .split_active(SurfaceKind::Terminal, SplitAxis::Vertical); + self.mutate(|core| core.split_active(SurfaceKind::Terminal, SplitAxis::Vertical)); } pub fn focus_pane(&self, pane_id: PaneId) { - self.inner.lock().focus_pane(pane_id); + self.mutate(|core| core.focus_pane(pane_id)); + } + + pub fn apply_host_event(&self, event: HostEvent) { + self.mutate(|core| core.apply_host_event(event)); + } + + fn mutate(&self, update: impl FnOnce(&mut TaskersCore) -> bool) { + let mut core = self.inner.lock(); + if update(&mut core) { + let _ = self.revisions.send(core.revision()); + } } } @@ -514,3 +804,141 @@ impl PartialEq for SharedCore { } impl Eq for SharedCore {} + +#[cfg(test)] +mod tests { + use super::{ + BootstrapModel, BrowserMountSpec, HostEvent, RuntimeCapability, RuntimeStatus, SharedCore, + SurfaceMountSpec, SurfaceKind, TerminalDefaults, + }; + use std::collections::BTreeMap; + + fn bootstrap() -> BootstrapModel { + let mut env = BTreeMap::new(); + env.insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); + BootstrapModel { + runtime_status: RuntimeStatus { + ghostty_runtime: RuntimeCapability::Ready, + shell_integration: RuntimeCapability::Ready, + terminal_host: RuntimeCapability::Fallback { + message: "GTK4 Ghostty bridge cannot mount into the GTK3 Dioxus host yet." + .into(), + }, + }, + terminal_defaults: TerminalDefaults { + cols: 132, + rows: 48, + command_argv: vec!["/bin/zsh".into(), "-i".into()], + env, + }, + } + } + + #[test] + fn terminal_mount_spec_inherits_shell_defaults() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let terminal = snapshot + .portal + .panes + .into_iter() + .find(|pane| pane.mount.kind() == SurfaceKind::Terminal) + .expect("terminal surface plan"); + + match terminal.mount { + SurfaceMountSpec::Terminal(spec) => { + assert_eq!(spec.command_argv, vec!["/bin/zsh", "-i"]); + assert_eq!(spec.cols, 132); + assert_eq!(spec.rows, 48); + assert_eq!( + spec.env.get("TASKERS_SOCKET").map(String::as_str), + Some("/tmp/taskers.sock") + ); + assert_eq!( + spec.env.get("TASKERS_PANE_ID").map(String::as_str), + Some(terminal.pane_id.to_string().as_str()) + ); + } + SurfaceMountSpec::Browser(_) => panic!("expected terminal mount spec"), + } + } + + #[test] + fn browser_host_events_update_surface_metadata() { + let core = SharedCore::bootstrap(bootstrap()); + core.split_with_browser(); + + let browser = core + .snapshot() + .portal + .panes + .into_iter() + .find(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(_))) + .expect("browser pane"); + + core.apply_host_event(HostEvent::SurfaceTitleChanged { + surface_id: browser.surface_id, + title: "Taskers Docs".into(), + }); + core.apply_host_event(HostEvent::SurfaceUrlChanged { + surface_id: browser.surface_id, + url: "https://taskers.invalid/docs".into(), + }); + + let snapshot = core.snapshot(); + let pane = match snapshot.layout { + super::LayoutNodeSnapshot::Split { second, .. } => second, + _ => panic!("expected split layout"), + }; + let pane = match *pane { + super::LayoutNodeSnapshot::Pane(pane) => pane, + _ => panic!("expected pane node"), + }; + assert_eq!(pane.surface.title, "Taskers Docs"); + assert_eq!( + pane.surface.url.as_deref(), + Some("https://taskers.invalid/docs") + ); + } + + #[test] + fn closing_a_split_surface_collapses_the_layout() { + let core = SharedCore::bootstrap(bootstrap()); + core.split_with_browser(); + let browser = core + .snapshot() + .portal + .panes + .into_iter() + .find(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(BrowserMountSpec { .. }))) + .expect("browser pane"); + + core.apply_host_event(HostEvent::SurfaceClosed { + pane_id: browser.pane_id, + surface_id: browser.surface_id, + }); + + let snapshot = core.snapshot(); + assert!(matches!(snapshot.layout, super::LayoutNodeSnapshot::Pane(_))); + assert_eq!(snapshot.portal.panes.len(), 1); + } + + #[test] + fn runtime_status_round_trips_through_snapshot() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + + assert!(matches!( + snapshot.runtime_status.ghostty_runtime, + RuntimeCapability::Ready + )); + assert!(matches!( + snapshot.runtime_status.shell_integration, + RuntimeCapability::Ready + )); + assert!(matches!( + snapshot.runtime_status.terminal_host, + RuntimeCapability::Fallback { .. } + )); + } +} diff --git a/greenfield/crates/taskers-host/Cargo.toml b/greenfield/crates/taskers-host/Cargo.toml index eaf98cf..13bd87f 100644 --- a/greenfield/crates/taskers-host/Cargo.toml +++ b/greenfield/crates/taskers-host/Cargo.toml @@ -8,5 +8,5 @@ version.workspace = true [dependencies] anyhow.workspace = true dioxus-desktop.workspace = true +gtk.workspace = true taskers-core.workspace = true - diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index da36152..b3c2b7b 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,13 +1,9 @@ -use anyhow::{Context, Result}; -use dioxus_desktop::{ - tao::{ - dpi::{LogicalPosition, LogicalSize}, - window::Window, - }, - wry::{Rect, WebView, WebViewBuilder}, -}; -use std::{cell::RefCell, collections::HashMap, sync::Arc}; -use taskers_core::{Frame, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceKind}; +use anyhow::Result; +use dioxus_desktop::tao::window::Window; +use std::{cell::RefCell, sync::Arc}; +use taskers_core::{HostEvent, RuntimeCapability, ShellSnapshot}; + +type HostEventSink = Arc; thread_local! { static HOST_RUNTIME: RefCell = RefCell::new(HostRuntimeState::default()); @@ -16,18 +12,16 @@ thread_local! { #[derive(Default)] struct HostRuntimeState { window: Option>, - browser_surfaces: HashMap, -} - -struct BrowserSurface { - webview: WebView, - url: String, + event_sink: Option, + #[cfg(target_os = "linux")] + linux: linux::LinuxHostRuntime, } -pub fn attach_window(window: Arc) -> Result<()> { +pub fn attach_window(window: Arc, event_sink: HostEventSink) -> Result<()> { HOST_RUNTIME.with(|slot| { let mut state = slot.borrow_mut(); state.window = Some(window); + state.event_sink = Some(event_sink); Ok(()) }) } @@ -39,79 +33,211 @@ pub fn sync_snapshot(snapshot: &ShellSnapshot) -> Result<()> { return Ok(()); }; - let desired: Vec<_> = snapshot - .portal - .panes - .iter() - .filter(|pane| pane.kind == SurfaceKind::Browser) - .cloned() - .collect(); - - let desired_ids: HashMap = desired - .iter() - .cloned() - .map(|plan| (plan.surface_id, plan)) - .collect(); - - state - .browser_surfaces - .retain(|surface_id, _| desired_ids.contains_key(surface_id)); - - for plan in desired { - match state.browser_surfaces.get_mut(&plan.surface_id) { - Some(surface) => surface.sync(&plan)?, - None => { - let surface = BrowserSurface::new(&window, &plan)?; - state.browser_surfaces.insert(plan.surface_id, surface); - } + #[cfg(target_os = "linux")] + { + if let Some(event_sink) = state.event_sink.clone() { + return state.linux.sync_snapshot(&window, snapshot, &event_sink); } } + let _ = snapshot; Ok(()) }) } -pub fn terminal_host_status() -> &'static str { - "Ghostty integration still needs a native portal adapter in the rewrite." +pub fn terminal_host_capability() -> RuntimeCapability { + #[cfg(target_os = "linux")] + { + RuntimeCapability::Fallback { + message: "Linux Ghostty embedding is still blocked here: the existing bridge exposes GTK4 widgets, but the Dioxus desktop host is GTK3.".into(), + } + } + + #[cfg(not(target_os = "linux"))] + { + RuntimeCapability::Unavailable { + message: "This greenfield checkpoint only wires the Linux host runtime.".into(), + } + } } -impl BrowserSurface { - fn new(window: &Arc, plan: &PortalSurfacePlan) -> Result { - let url = plan - .url - .clone() - .unwrap_or_else(|| "https://dioxuslabs.com/learn/0.7/".into()); +#[cfg(target_os = "linux")] +mod linux { + use super::HostEventSink; + use anyhow::{Context, Result}; + use dioxus_desktop::{ + tao::{ + dpi::{LogicalPosition, LogicalSize}, + platform::unix::WindowExtUnix, + window::Window, + }, + wry::{PageLoadEvent, Rect, WebView, WebViewBuilder, WebViewBuilderExtUnix, WebViewExtUnix}, + }; + use gtk::{Fixed, Overlay, Widget, glib::Propagation, prelude::*}; + use std::{collections::HashMap, sync::Arc}; + use taskers_core::{BrowserMountSpec, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceMountSpec}; + + #[derive(Default)] + pub struct LinuxHostRuntime { + portal_overlay: Option, + portal_fixed: Option, + browser_surfaces: HashMap, + } - let webview = WebViewBuilder::new() - .with_url(&url) - .with_bounds(rect_from_frame(plan.frame)) - .build_as_child(window.as_ref()) - .with_context(|| format!("failed to create browser surface {}", plan.surface_id.0))?; + impl LinuxHostRuntime { + pub fn sync_snapshot( + &mut self, + window: &Arc, + snapshot: &ShellSnapshot, + event_sink: &HostEventSink, + ) -> Result<()> { + let Some(fixed) = self.ensure_portal_layer(window)? else { + return Ok(()); + }; + + let desired: Vec<_> = snapshot + .portal + .panes + .iter() + .filter(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(_))) + .cloned() + .collect(); + + let desired_ids = desired + .iter() + .map(|plan| plan.surface_id) + .collect::>(); + + self.browser_surfaces + .retain(|surface_id, _| desired_ids.contains(surface_id)); + + for plan in desired { + match self.browser_surfaces.get_mut(&plan.surface_id) { + Some(surface) => surface.sync(&plan)?, + None => { + let surface = BrowserSurface::new(&fixed, &plan, event_sink)?; + self.browser_surfaces.insert(plan.surface_id, surface); + } + } + } - Ok(Self { webview, url }) + Ok(()) + } + + fn ensure_portal_layer(&mut self, window: &Arc) -> Result> { + if let Some(fixed) = &self.portal_fixed { + return Ok(Some(fixed.clone())); + } + + let Some(vbox) = window.default_vbox() else { + return Ok(None); + }; + let children = vbox.children(); + let Some(webview_child) = children.into_iter().next_back() else { + return Ok(None); + }; + + let overlay = Overlay::new(); + overlay.set_hexpand(true); + overlay.set_vexpand(true); + + let fixed = Fixed::new(); + fixed.set_hexpand(true); + fixed.set_vexpand(true); + + vbox.remove(&webview_child); + overlay.add(&webview_child); + overlay.add_overlay(&fixed); + vbox.pack_start(&overlay, true, true, 0); + overlay.show_all(); + + self.portal_overlay = Some(overlay); + self.portal_fixed = Some(fixed.clone()); + Ok(Some(fixed)) + } } - fn sync(&mut self, plan: &PortalSurfacePlan) -> Result<()> { - self.webview - .set_bounds(rect_from_frame(plan.frame)) - .context("failed to update browser bounds")?; + struct BrowserSurface { + webview: WebView, + url: String, + } - if let Some(url) = &plan.url - && self.url != *url - { - self.webview.load_url(url).with_context(|| { - format!("failed to navigate browser surface {}", plan.surface_id.0) - })?; - self.url = url.clone(); + impl BrowserSurface { + fn new(fixed: &Fixed, plan: &PortalSurfacePlan, event_sink: &HostEventSink) -> Result { + let BrowserMountSpec { url } = browser_spec(plan)?.clone(); + let surface_id = plan.surface_id; + let pane_id = plan.pane_id; + + let title_sink = event_sink.clone(); + let url_sink = event_sink.clone(); + let webview = WebViewBuilder::new() + .with_url(&url) + .with_bounds(rect_from_frame(plan.frame)) + .with_document_title_changed_handler(move |title| { + (title_sink)(HostEvent::SurfaceTitleChanged { surface_id, title }); + }) + .with_on_page_load_handler(move |event, url| { + if matches!(event, PageLoadEvent::Finished) { + (url_sink)(HostEvent::SurfaceUrlChanged { surface_id, url }); + } + }) + .build_gtk(fixed) + .with_context(|| format!("failed to create browser surface {}", plan.surface_id.0))?; + + let widget = webview.webview(); + let focus_sink = event_sink.clone(); + widget.connect_focus_in_event(move |_, _| { + (focus_sink)(HostEvent::PaneFocused { pane_id }); + Propagation::Proceed + }); + + if plan.active { + let _ = webview.focus(); + } + + Ok(Self { webview, url }) } - Ok(()) + fn sync(&mut self, plan: &PortalSurfacePlan) -> Result<()> { + self.webview + .set_bounds(rect_from_frame(plan.frame)) + .context("failed to update browser bounds")?; + + let BrowserMountSpec { url } = browser_spec(plan)?; + if self.url != *url { + self.webview + .load_url(url) + .with_context(|| format!("failed to navigate browser surface {}", plan.surface_id.0))?; + self.url = url.clone(); + } + + if plan.active { + let _ = self.webview.focus(); + } + + Ok(()) + } + } + + fn browser_spec(plan: &PortalSurfacePlan) -> Result<&BrowserMountSpec> { + match &plan.mount { + SurfaceMountSpec::Browser(spec) => Ok(spec), + SurfaceMountSpec::Terminal(_) => anyhow::bail!( + "surface {} is not a browser mount", + plan.surface_id.0 + ), + } + } + + fn rect_from_frame(frame: taskers_core::Frame) -> Rect { + Rect { + position: LogicalPosition::new(frame.x, frame.y).into(), + size: LogicalSize::new(frame.width.max(1), frame.height.max(1)).into(), + } } -} -fn rect_from_frame(frame: Frame) -> Rect { - Rect { - position: LogicalPosition::new(frame.x, frame.y).into(), - size: LogicalSize::new(frame.width.max(1), frame.height.max(1)).into(), + #[allow(dead_code)] + fn _widget_debug_name(widget: &Widget) -> &'static str { + widget.type_().name() } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index c622e1f..247f33b 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -1,6 +1,9 @@ use dioxus::prelude::*; use std::sync::Arc; -use taskers_core::{LayoutNodeSnapshot, PaneId, SharedCore, SplitAxis, SurfaceKind}; +use taskers_core::{ + LayoutNodeSnapshot, PaneId, RuntimeCapability, RuntimeStatus, SharedCore, SplitAxis, + SurfaceKind, +}; const APP_CSS: &str = r#" html, body, #main { @@ -14,9 +17,7 @@ html, body, #main { font-family: "IBM Plex Sans", "Segoe UI", sans-serif; } * { box-sizing: border-box; } -button { - font: inherit; -} +button { font: inherit; } .app-shell { width: 100vw; height: 100vh; @@ -24,7 +25,7 @@ button { overflow: hidden; } .sidebar { - width: 248px; + width: 276px; padding: 18px 16px; background: rgba(8, 13, 28, 0.78); border-right: 1px solid rgba(163, 191, 255, 0.12); @@ -55,6 +56,9 @@ button { border-radius: 16px; background: rgba(18, 28, 53, 0.78); border: 1px solid rgba(163, 191, 255, 0.1); + display: flex; + flex-direction: column; + gap: 10px; } .workspace-pill { display: flex; @@ -65,12 +69,44 @@ button { background: linear-gradient(135deg, rgba(58, 104, 206, 0.35), rgba(35, 48, 92, 0.45)); border: 1px solid rgba(163, 191, 255, 0.18); } -.workspace-pill strong { - font-size: 15px; -} -.workspace-pill span, .sidebar-card p, .toolbar-subtitle { +.workspace-pill strong { font-size: 15px; } +.workspace-pill span, .sidebar-card p, .toolbar-subtitle, .status-note, .placeholder-note { color: #b4c7ec; } +.status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.status-label { + font-size: 13px; + color: #dce8ff; +} +.status-badge { + border-radius: 999px; + padding: 4px 9px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.status-badge.ready { + background: rgba(91, 224, 160, 0.14); + color: #8cf1be; +} +.status-badge.fallback { + background: rgba(255, 197, 87, 0.14); + color: #ffd37f; +} +.status-badge.unavailable { + background: rgba(255, 128, 128, 0.14); + color: #ffb4b4; +} +.status-note { + font-size: 12px; + line-height: 1.45; +} .main-column { min-width: 0; flex: 1; @@ -93,9 +129,7 @@ button { flex-direction: column; gap: 2px; } -.toolbar-title strong { - font-size: 16px; -} +.toolbar-title strong { font-size: 16px; } .toolbar-actions { display: flex; align-items: center; @@ -204,21 +238,27 @@ button { background: linear-gradient(180deg, rgba(9, 12, 21, 0.9), rgba(12, 18, 34, 0.96)); } -.placeholder-note { - max-width: 520px; - color: #b4c7ec; - line-height: 1.5; +.surface-backdrop { + width: 100%; + height: 100%; + border-radius: 0 0 20px 20px; + border: 1px dashed rgba(144, 184, 255, 0.12); } -.terminal-lines { - margin: 0; - padding: 16px; - border-radius: 16px; - background: rgba(4, 6, 12, 0.84); - border: 1px solid rgba(91, 114, 165, 0.22); - color: #9ff3b0; - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 13px; - white-space: pre-wrap; +.surface-backdrop.browser { + background: + linear-gradient(180deg, rgba(11, 16, 29, 0.15), rgba(11, 16, 29, 0.02)); +} +.surface-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.surface-chip { + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + background: rgba(255, 255, 255, 0.06); + color: #d9e8ff; } @media (max-width: 900px) { .sidebar { display: none; } @@ -230,9 +270,22 @@ button { pub fn app() -> Element { let core = consume_context::(); let revision = use_signal(|| core.revision()); + + { + let core = core.clone(); + let mut revision = revision; + use_hook(move || { + let mut revisions = core.subscribe_revisions(); + spawn(async move { + while revisions.changed().await.is_ok() { + revision.set(*revisions.borrow()); + } + }); + }); + } + let _ = revision(); let snapshot = core.snapshot(); - let terminal_status = taskers_host::terminal_host_status(); let focus = { let core = core.clone(); @@ -281,8 +334,14 @@ pub fn app() -> Element { span { "{snapshot.workspace_count} workspace · revision {snapshot.revision}" } } div { class: "sidebar-card", - div { class: "eyebrow", "Surface portal" } - p { "Browser panes are mounted as native child webviews against the Dioxus window. Terminal panes already reserve the same host slot shape." } + div { class: "eyebrow", "Portal runtime" } + p { "Browser panes are mounted through the Linux GTK portal layer. Terminal startup is bootstrapped too, but native Ghostty mounting is still blocked by the GTK3/GTK4 split." } + } + div { class: "sidebar-card", + div { class: "eyebrow", "Runtime status" } + {render_runtime_capability("Ghostty runtime", &snapshot.runtime_status.ghostty_runtime)} + {render_runtime_capability("Shell integration", &snapshot.runtime_status.shell_integration)} + {render_runtime_capability("Terminal host", &snapshot.runtime_status.terminal_host)} } } @@ -291,7 +350,7 @@ pub fn app() -> Element { div { class: "toolbar-title", strong { "Unified shell bootstrap" } div { class: "toolbar-subtitle", - "Dioxus chrome + native surface portal" + "Dioxus chrome + Linux GTK portal runtime" } } div { class: "toolbar-actions", @@ -301,17 +360,37 @@ pub fn app() -> Element { } div { class: "workspace-canvas", - {render_layout(&snapshot.layout, focus.clone(), terminal_status)} + {render_layout(&snapshot.layout, focus.clone(), &snapshot.runtime_status)} } } } } } +fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability) -> Element { + let class = match capability { + RuntimeCapability::Ready => "status-badge ready", + RuntimeCapability::Fallback { .. } => "status-badge fallback", + RuntimeCapability::Unavailable { .. } => "status-badge unavailable", + }; + + rsx! { + div { + div { class: "status-row", + span { class: "status-label", "{label}" } + span { class: "{class}", "{capability.label()}" } + } + if let Some(message) = capability.message() { + div { class: "status-note", "{message}" } + } + } + } +} + fn render_layout( node: &LayoutNodeSnapshot, focus: Arc, - terminal_status: &'static str, + runtime_status: &RuntimeStatus, ) -> Element { match node { LayoutNodeSnapshot::Split { @@ -332,10 +411,10 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, focus.clone(), terminal_status)} + {render_layout(first, focus.clone(), runtime_status)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, focus.clone(), terminal_status)} + {render_layout(second, focus.clone(), runtime_status)} } } } @@ -360,28 +439,35 @@ fn render_layout( div { class: "surface-placeholder browser", div { class: "eyebrow", "Native browser surface" } p { class: "placeholder-note", - "This pane is backed by a real child webview mounted by the host runtime." + "This pane is mounted through the Linux GTK portal overlay." } - p { class: "placeholder-note", - "Current URL: {url}" + div { class: "surface-meta", + span { class: "surface-chip", "URL: {url}" } } + div { class: "surface-backdrop browser" } } } } - SurfaceKind::Terminal => rsx! { - div { class: "surface-placeholder terminal", - div { class: "eyebrow", "Terminal host seam" } - p { class: "placeholder-note", - "{terminal_status}" - } - pre { class: "terminal-lines", - "$ jj status\n" - "Working copy (@): chore: bootstrap greenfield taskers rewrite\n" - "$ cargo run -p taskers\n" - "Launching Dioxus shell with native surface portal..." + SurfaceKind::Terminal => { + let host_message = runtime_status + .terminal_host + .message() + .unwrap_or("Terminal hosting is ready."); + rsx! { + div { class: "surface-placeholder terminal", + div { class: "eyebrow", "Terminal runtime" } + p { class: "placeholder-note", + "{host_message}" + } + if let Some(cwd) = &pane.surface.cwd { + div { class: "surface-meta", + span { class: "surface-chip", "cwd: {cwd}" } + } + } + div { class: "surface-backdrop" } } } - }, + } }; rsx! { diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index 7a47e12..34804b6 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -14,5 +14,6 @@ dioxus.workspace = true dioxus-desktop.workspace = true taskers-core.workspace = true taskers-host.workspace = true +taskers-paths.workspace = true +taskers-runtime.workspace = true taskers-shell.workspace = true - diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 48d4853..cf29097 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -6,10 +6,22 @@ use dioxus_desktop::{ event::{Event, WindowEvent}, }, }; -use taskers_core::{PixelSize, SharedCore}; +use std::collections::BTreeMap; +use std::sync::Arc; +use taskers_core::{ + BootstrapModel, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, TerminalDefaults, +}; +use taskers_paths::default_ghostty_runtime_dir; +use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; fn main() { - let core = SharedCore::demo(); + scrub_inherited_terminal_env(); + + let (terminal_defaults, runtime_status) = bootstrap_runtime(); + let core = SharedCore::bootstrap(BootstrapModel { + runtime_status, + terminal_defaults, + }); let core_for_window = core.clone(); let core_for_events = core.clone(); @@ -27,7 +39,14 @@ fn main() { core_for_window .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); - if let Err(error) = taskers_host::attach_window(window.clone()) { + let event_sink = Arc::new({ + let core = core_for_window.clone(); + move |event| { + core.apply_host_event(event); + } + }); + + if let Err(error) = taskers_host::attach_window(window.clone(), event_sink) { eprintln!("taskers host attach failed: {error}"); } if let Err(error) = taskers_host::sync_snapshot(&core_for_window.snapshot()) { @@ -51,3 +70,58 @@ fn main() { ) .launch(taskers_shell::app); } + +fn bootstrap_runtime() -> (TerminalDefaults, RuntimeStatus) { + let ghostty_runtime = probe_ghostty_runtime(); + + let (shell_launch, shell_integration) = match install_shell_integration(None) { + Ok(integration) => (integration.launch_spec(), RuntimeCapability::Ready), + Err(error) => ( + ShellLaunchSpec::fallback(), + RuntimeCapability::Fallback { + message: format!("Shell integration unavailable: {error}"), + }, + ), + }; + + ( + terminal_defaults_from(shell_launch), + RuntimeStatus { + ghostty_runtime, + shell_integration, + terminal_host: taskers_host::terminal_host_capability(), + }, + ) +} + +fn probe_ghostty_runtime() -> RuntimeCapability { + let runtime_dir = default_ghostty_runtime_dir(); + let bridge = runtime_dir.join("lib").join("libtaskers_ghostty_bridge.so"); + + if bridge.exists() { + RuntimeCapability::Ready + } else { + RuntimeCapability::Fallback { + message: format!( + "Ghostty runtime bootstrap is deferred in this checkpoint to avoid mixing GTK3 and GTK4 in one process. Expected runtime asset: {}", + bridge.display() + ), + } + } +} + +fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { + let mut argv = Vec::with_capacity(shell_launch.args.len() + 1); + argv.push(shell_launch.program.display().to_string()); + argv.extend(shell_launch.args); + + let mut env = BTreeMap::new(); + env.extend(shell_launch.env); + + TerminalDefaults { + cols: 120, + rows: 40, + command_argv: argv, + env, + } +} From 2a678143b4e149e445c800c2d7d6386c0972f4a3 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 10:46:48 +0100 Subject: [PATCH 12/63] feat: centralize greenfield snapshot sync and diagnostics --- greenfield/Cargo.lock | 3 +- greenfield/crates/taskers-core/src/lib.rs | 10 +- greenfield/crates/taskers-host/src/lib.rs | 281 +++++++++++++++++++-- greenfield/crates/taskers-shell/Cargo.toml | 2 - greenfield/crates/taskers-shell/src/lib.rs | 25 +- greenfield/crates/taskers/Cargo.toml | 2 + greenfield/crates/taskers/src/main.rs | 183 +++++++++++++- 7 files changed, 452 insertions(+), 54 deletions(-) diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index 2c60c61..d9e2f66 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -3697,11 +3697,13 @@ version = "0.1.0-alpha.1" dependencies = [ "dioxus", "dioxus-desktop", + "gtk", "taskers-core", "taskers-host", "taskers-paths", "taskers-runtime", "taskers-shell", + "tokio", ] [[package]] @@ -3757,7 +3759,6 @@ version = "0.1.0-alpha.1" dependencies = [ "dioxus", "taskers-core", - "taskers-host", ] [[package]] diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index f147d17..7be342b 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -1,7 +1,7 @@ use indexmap::IndexMap; use parking_lot::Mutex; use std::{collections::BTreeMap, fmt, sync::Arc}; -use tokio::sync::watch; +use tokio::sync::{broadcast, watch}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PaneId(pub u64); @@ -741,15 +741,18 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio_millis: u16, gap: i32) -> (F pub struct SharedCore { inner: Arc>, revisions: watch::Sender, + revision_events: broadcast::Sender, } impl SharedCore { pub fn bootstrap(bootstrap: BootstrapModel) -> Self { let core = TaskersCore::with_bootstrap(bootstrap); let (revisions, _) = watch::channel(core.revision()); + let (revision_events, _) = broadcast::channel(256); Self { inner: Arc::new(Mutex::new(core)), revisions, + revision_events, } } @@ -765,6 +768,10 @@ impl SharedCore { self.revisions.subscribe() } + pub fn subscribe_revision_events(&self) -> broadcast::Receiver { + self.revision_events.subscribe() + } + pub fn snapshot(&self) -> ShellSnapshot { self.inner.lock().snapshot() } @@ -793,6 +800,7 @@ impl SharedCore { let mut core = self.inner.lock(); if update(&mut core) { let _ = self.revisions.send(core.revision()); + let _ = self.revision_events.send(core.revision()); } } } diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index b3c2b7b..1a0bb84 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,9 +1,82 @@ use anyhow::Result; use dioxus_desktop::tao::window::Window; -use std::{cell::RefCell, sync::Arc}; -use taskers_core::{HostEvent, RuntimeCapability, ShellSnapshot}; +use std::{ + cell::RefCell, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use taskers_core::{HostEvent, PaneId, RuntimeCapability, ShellSnapshot, SurfaceId}; + +type HostEventSink = Arc; +pub type DiagnosticsSink = Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticCategory { + Startup, + Window, + Sync, + HostEvent, + SurfaceLifecycle, + BrowserMetadata, + Smoke, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosticRecord { + pub timestamp_ms: u128, + pub revision: Option, + pub category: DiagnosticCategory, + pub message: String, + pub pane_id: Option, + pub surface_id: Option, +} + +impl DiagnosticRecord { + pub fn new( + category: DiagnosticCategory, + revision: Option, + message: impl Into, + ) -> Self { + Self { + timestamp_ms: current_timestamp_ms(), + revision, + category, + message: message.into(), + pane_id: None, + surface_id: None, + } + } + + pub fn with_pane(mut self, pane_id: PaneId) -> Self { + self.pane_id = Some(pane_id); + self + } + + pub fn with_surface(mut self, surface_id: SurfaceId) -> Self { + self.surface_id = Some(surface_id); + self + } -type HostEventSink = Arc; + pub fn format_line(&self) -> String { + let revision = self + .revision + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".into()); + let pane = self + .pane_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".into()); + let surface = self + .surface_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".into()); + + format!( + "ts_ms={} category={:?} revision={} pane={} surface={} message={}", + self.timestamp_ms, self.category, revision, pane, surface, self.message + ) + } +} thread_local! { static HOST_RUNTIME: RefCell = RefCell::new(HostRuntimeState::default()); @@ -13,15 +86,25 @@ thread_local! { struct HostRuntimeState { window: Option>, event_sink: Option, + diagnostics: Option, #[cfg(target_os = "linux")] linux: linux::LinuxHostRuntime, } -pub fn attach_window(window: Arc, event_sink: HostEventSink) -> Result<()> { +pub fn attach_window( + window: Arc, + event_sink: HostEventSink, + diagnostics: Option, +) -> Result<()> { HOST_RUNTIME.with(|slot| { let mut state = slot.borrow_mut(); state.window = Some(window); state.event_sink = Some(event_sink); + state.diagnostics = diagnostics.clone(); + emit_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new(DiagnosticCategory::Window, None, "host window attached"), + ); Ok(()) }) } @@ -36,7 +119,10 @@ pub fn sync_snapshot(snapshot: &ShellSnapshot) -> Result<()> { #[cfg(target_os = "linux")] { if let Some(event_sink) = state.event_sink.clone() { - return state.linux.sync_snapshot(&window, snapshot, &event_sink); + let diagnostics = state.diagnostics.clone(); + return state + .linux + .sync_snapshot(&window, snapshot, &event_sink, diagnostics.as_ref()); } } @@ -61,9 +147,22 @@ pub fn terminal_host_capability() -> RuntimeCapability { } } +fn emit_diagnostic(sink: Option<&DiagnosticsSink>, record: DiagnosticRecord) { + if let Some(sink) = sink { + sink(record); + } +} + +fn current_timestamp_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + #[cfg(target_os = "linux")] mod linux { - use super::HostEventSink; + use super::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, HostEventSink, emit_diagnostic}; use anyhow::{Context, Result}; use dioxus_desktop::{ tao::{ @@ -74,7 +173,7 @@ mod linux { wry::{PageLoadEvent, Rect, WebView, WebViewBuilder, WebViewBuilderExtUnix, WebViewExtUnix}, }; use gtk::{Fixed, Overlay, Widget, glib::Propagation, prelude::*}; - use std::{collections::HashMap, sync::Arc}; + use std::{collections::{HashMap, HashSet}, sync::Arc}; use taskers_core::{BrowserMountSpec, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceMountSpec}; #[derive(Default)] @@ -90,8 +189,18 @@ mod linux { window: &Arc, snapshot: &ShellSnapshot, event_sink: &HostEventSink, + diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { - let Some(fixed) = self.ensure_portal_layer(window)? else { + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(snapshot.revision), + format!("host sync start panes={}", snapshot.portal.panes.len()), + ), + ); + + let Some(fixed) = self.ensure_portal_layer(window, diagnostics)? else { return Ok(()); }; @@ -103,19 +212,30 @@ mod linux { .cloned() .collect(); - let desired_ids = desired - .iter() - .map(|plan| plan.surface_id) - .collect::>(); - - self.browser_surfaces - .retain(|surface_id, _| desired_ids.contains(surface_id)); + let diff = browser_surface_diff( + self.browser_surfaces.keys().copied(), + desired.iter().map(|plan| plan.surface_id), + ); + + for surface_id in diff.removed { + self.browser_surfaces.remove(&surface_id); + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(snapshot.revision), + "browser surface removed", + ) + .with_surface(surface_id), + ); + } for plan in desired { match self.browser_surfaces.get_mut(&plan.surface_id) { - Some(surface) => surface.sync(&plan)?, + Some(surface) => surface.sync(&plan, snapshot.revision, diagnostics)?, None => { - let surface = BrowserSurface::new(&fixed, &plan, event_sink)?; + let surface = + BrowserSurface::new(&fixed, &plan, snapshot.revision, event_sink, diagnostics)?; self.browser_surfaces.insert(plan.surface_id, surface); } } @@ -124,7 +244,11 @@ mod linux { Ok(()) } - fn ensure_portal_layer(&mut self, window: &Arc) -> Result> { + fn ensure_portal_layer( + &mut self, + window: &Arc, + diagnostics: Option<&DiagnosticsSink>, + ) -> Result> { if let Some(fixed) = &self.portal_fixed { return Ok(Some(fixed.clone())); } @@ -153,6 +277,14 @@ mod linux { self.portal_overlay = Some(overlay); self.portal_fixed = Some(fixed.clone()); + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Window, + None, + "created linux portal overlay layer", + ), + ); Ok(Some(fixed)) } } @@ -163,21 +295,49 @@ mod linux { } impl BrowserSurface { - fn new(fixed: &Fixed, plan: &PortalSurfacePlan, event_sink: &HostEventSink) -> Result { + fn new( + fixed: &Fixed, + plan: &PortalSurfacePlan, + revision: u64, + event_sink: &HostEventSink, + diagnostics: Option<&DiagnosticsSink>, + ) -> Result { let BrowserMountSpec { url } = browser_spec(plan)?.clone(); let surface_id = plan.surface_id; let pane_id = plan.pane_id; let title_sink = event_sink.clone(); + let title_diag = diagnostics.cloned(); let url_sink = event_sink.clone(); + let url_diag = diagnostics.cloned(); let webview = WebViewBuilder::new() .with_url(&url) .with_bounds(rect_from_frame(plan.frame)) .with_document_title_changed_handler(move |title| { + emit_diagnostic( + title_diag.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::BrowserMetadata, + None, + format!("browser title observed: {title}"), + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); (title_sink)(HostEvent::SurfaceTitleChanged { surface_id, title }); }) .with_on_page_load_handler(move |event, url| { if matches!(event, PageLoadEvent::Finished) { + emit_diagnostic( + url_diag.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::BrowserMetadata, + None, + format!("browser url observed: {url}"), + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); (url_sink)(HostEvent::SurfaceUrlChanged { surface_id, url }); } }) @@ -186,7 +346,18 @@ mod linux { let widget = webview.webview(); let focus_sink = event_sink.clone(); + let focus_diag = diagnostics.cloned(); widget.connect_focus_in_event(move |_, _| { + emit_diagnostic( + focus_diag.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "browser focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); (focus_sink)(HostEvent::PaneFocused { pane_id }); Propagation::Proceed }); @@ -195,10 +366,26 @@ mod linux { let _ = webview.focus(); } + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "browser surface created", + ) + .with_pane(plan.pane_id) + .with_surface(plan.surface_id), + ); + Ok(Self { webview, url }) } - fn sync(&mut self, plan: &PortalSurfacePlan) -> Result<()> { + fn sync( + &mut self, + plan: &PortalSurfacePlan, + revision: u64, + diagnostics: Option<&DiagnosticsSink>, + ) -> Result<()> { self.webview .set_bounds(rect_from_frame(plan.frame)) .context("failed to update browser bounds")?; @@ -215,10 +402,39 @@ mod linux { let _ = self.webview.focus(); } + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "browser surface updated", + ) + .with_pane(plan.pane_id) + .with_surface(plan.surface_id), + ); + Ok(()) } } + #[derive(Debug, PartialEq, Eq)] + struct BrowserSurfaceDiff { + removed: Vec, + } + + fn browser_surface_diff( + existing: impl IntoIterator, + desired: impl IntoIterator, + ) -> BrowserSurfaceDiff { + let desired = desired.into_iter().collect::>(); + let removed = existing + .into_iter() + .filter(|surface_id| !desired.contains(surface_id)) + .collect::>(); + + BrowserSurfaceDiff { removed } + } + fn browser_spec(plan: &PortalSurfacePlan) -> Result<&BrowserMountSpec> { match &plan.mount { SurfaceMountSpec::Browser(spec) => Ok(spec), @@ -240,4 +456,29 @@ mod linux { fn _widget_debug_name(widget: &Widget) -> &'static str { widget.type_().name() } + + #[cfg(test)] + mod tests { + use super::{BrowserSurfaceDiff, browser_surface_diff}; + use taskers_core::SurfaceId; + + #[test] + fn browser_surface_diff_returns_removed_ids() { + let diff = + browser_surface_diff([SurfaceId(1), SurfaceId(2), SurfaceId(3)], [SurfaceId(2)]); + + assert_eq!( + diff, + BrowserSurfaceDiff { + removed: vec![SurfaceId(1), SurfaceId(3)], + } + ); + } + + #[test] + fn browser_surface_diff_keeps_matching_ids() { + let diff = browser_surface_diff([SurfaceId(7)], [SurfaceId(7)]); + assert_eq!(diff, BrowserSurfaceDiff { removed: vec![] }); + } + } } diff --git a/greenfield/crates/taskers-shell/Cargo.toml b/greenfield/crates/taskers-shell/Cargo.toml index bea18f1..a5b02b8 100644 --- a/greenfield/crates/taskers-shell/Cargo.toml +++ b/greenfield/crates/taskers-shell/Cargo.toml @@ -8,5 +8,3 @@ version.workspace = true [dependencies] dioxus.workspace = true taskers-core.workspace = true -taskers-host.workspace = true - diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 247f33b..83f6b63 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -289,36 +289,17 @@ pub fn app() -> Element { let focus = { let core = core.clone(); - Arc::new(move |pane_id: PaneId| { - core.focus_pane(pane_id); - if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { - eprintln!("taskers host sync failed after focus: {error}"); - } - }) + Arc::new(move |pane_id: PaneId| core.focus_pane(pane_id)) }; let split_terminal = { let core = core.clone(); - let mut revision = revision; - move |_| { - core.split_with_terminal(); - if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { - eprintln!("taskers host sync failed after terminal split: {error}"); - } - revision.set(core.revision()); - } + move |_| core.split_with_terminal() }; let split_browser = { let core = core.clone(); - let mut revision = revision; - move |_| { - core.split_with_browser(); - if let Err(error) = taskers_host::sync_snapshot(&core.snapshot()) { - eprintln!("taskers host sync failed after browser split: {error}"); - } - revision.set(core.revision()); - } + move |_| core.split_with_browser() }; rsx! { diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index 34804b6..d68d981 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -12,8 +12,10 @@ path = "src/main.rs" [dependencies] dioxus.workspace = true dioxus-desktop.workspace = true +gtk.workspace = true taskers-core.workspace = true taskers-host.workspace = true taskers-paths.workspace = true taskers-runtime.workspace = true taskers-shell.workspace = true +tokio.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index cf29097..86d32ca 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -6,24 +6,37 @@ use dioxus_desktop::{ event::{Event, WindowEvent}, }, }; -use std::collections::BTreeMap; -use std::sync::Arc; +use gtk::glib; +use std::{ + collections::BTreeMap, + fs::File, + io::{self, Write}, + path::PathBuf, + sync::{Arc, Mutex}, + thread, +}; use taskers_core::{ BootstrapModel, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, TerminalDefaults, }; +use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink}; use taskers_paths::default_ghostty_runtime_dir; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; fn main() { scrub_inherited_terminal_env(); + let diagnostics = DiagnosticsWriter::from_env(); let (terminal_defaults, runtime_status) = bootstrap_runtime(); + log_runtime_status(diagnostics.as_ref(), &runtime_status); let core = SharedCore::bootstrap(BootstrapModel { runtime_status, terminal_defaults, }); let core_for_window = core.clone(); let core_for_events = core.clone(); + let diagnostics_for_window = diagnostics.clone(); + let diagnostics_for_events = diagnostics.clone(); + spawn_revision_sync_relay(core.clone(), diagnostics.clone()); LaunchBuilder::desktop() .with_context(core.clone()) @@ -45,11 +58,44 @@ fn main() { core.apply_host_event(event); } }); + let diagnostics_sink = diagnostics_for_window.as_ref().map(DiagnosticsWriter::sink); - if let Err(error) = taskers_host::attach_window(window.clone(), event_sink) { + if let Err(error) = + taskers_host::attach_window(window.clone(), event_sink, diagnostics_sink) + { + log_diagnostic( + diagnostics_for_window.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Window, + None, + format!("host attach failed: {error}"), + ), + ); eprintln!("taskers host attach failed: {error}"); } - if let Err(error) = taskers_host::sync_snapshot(&core_for_window.snapshot()) { + + let snapshot = core_for_window.snapshot(); + log_diagnostic( + diagnostics_for_window.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Window, + Some(snapshot.revision), + format!( + "initial snapshot panes={} active={}", + snapshot.portal.panes.len(), + snapshot.active_pane + ), + ), + ); + if let Err(error) = taskers_host::sync_snapshot(&snapshot) { + log_diagnostic( + diagnostics_for_window.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(snapshot.revision), + format!("initial sync failed: {error}"), + ), + ); eprintln!("taskers host initial sync failed: {error}"); } }) @@ -61,10 +107,17 @@ fn main() { { core_for_events .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); - if let Err(error) = taskers_host::sync_snapshot(&core_for_events.snapshot()) - { - eprintln!("taskers host resize sync failed: {error}"); - } + log_diagnostic( + diagnostics_for_events.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Window, + Some(core_for_events.revision()), + format!( + "window resized width={} height={}", + size.width, size.height + ), + ), + ); } }), ) @@ -125,3 +178,117 @@ fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { env, } } + +fn spawn_revision_sync_relay(core: SharedCore, diagnostics: Option) { + let mut revisions = core.subscribe_revision_events(); + thread::spawn(move || loop { + match revisions.blocking_recv() { + Ok(revision) => { + let snapshot = core.snapshot(); + let diagnostics = diagnostics.clone(); + glib::MainContext::default().invoke(move || { + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!( + "syncing snapshot panes={} active={}", + snapshot.portal.panes.len(), + snapshot.active_pane + ), + ), + ); + if let Err(error) = taskers_host::sync_snapshot(&snapshot) { + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!("snapshot sync failed: {error}"), + ), + ); + eprintln!("taskers host sync failed for revision {revision}: {error}"); + } + }); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Sync, + None, + format!("revision relay lagged; skipped {skipped} events"), + ), + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + }); +} + +fn log_runtime_status(diagnostics: Option<&DiagnosticsWriter>, status: &RuntimeStatus) { + let summary = format!( + "runtime status ghostty={} shell={} terminal={}", + status.ghostty_runtime.label(), + status.shell_integration.label(), + status.terminal_host.label(), + ); + log_diagnostic( + diagnostics, + DiagnosticRecord::new(DiagnosticCategory::Startup, None, summary), + ); +} + +fn log_diagnostic(diagnostics: Option<&DiagnosticsWriter>, record: DiagnosticRecord) { + if let Some(diagnostics) = diagnostics { + diagnostics.write(record); + } +} + +#[derive(Clone)] +struct DiagnosticsWriter { + target: DiagnosticsTarget, +} + +#[derive(Clone)] +enum DiagnosticsTarget { + Stderr, + File(Arc>), +} + +impl DiagnosticsWriter { + fn from_env() -> Option { + let value = std::env::var_os("TASKERS_GREENFIELD_DIAGNOSTIC_LOG")?; + if value == "stderr" { + return Some(Self { + target: DiagnosticsTarget::Stderr, + }); + } + + let path = PathBuf::from(value); + let file = File::create(path).ok()?; + Some(Self { + target: DiagnosticsTarget::File(Arc::new(Mutex::new(file))), + }) + } + + fn sink(&self) -> DiagnosticsSink { + let diagnostics = self.clone(); + Arc::new(move |record| diagnostics.write(record)) + } + + fn write(&self, record: DiagnosticRecord) { + let line = format!("{}\n", record.format_line()); + match &self.target { + DiagnosticsTarget::Stderr => { + let _ = io::stderr().lock().write_all(line.as_bytes()); + } + DiagnosticsTarget::File(file) => { + if let Ok(mut file) = file.lock() { + let _ = file.write_all(line.as_bytes()); + } + } + } + } +} From 4e6f408a8952e6aec2086bfa5dcaf9f359cdb3c4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 10:57:09 +0100 Subject: [PATCH 13/63] feat: add greenfield scripted smoke baseline mode --- greenfield/Cargo.lock | 121 +++++++++++++++ greenfield/Cargo.toml | 1 + greenfield/crates/taskers/Cargo.toml | 1 + greenfield/crates/taskers/src/main.rs | 213 ++++++++++++++++++++++++-- 4 files changed, 326 insertions(+), 10 deletions(-) diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index d9e2f66..dff3c5d 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -203,6 +253,46 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cocoa" version = "0.26.1" @@ -232,6 +322,12 @@ dependencies = [ "objc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1797,6 +1893,12 @@ dependencies = [ "cfb", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -2458,6 +2560,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.76" @@ -3555,6 +3663,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subsecond" version = "0.7.3" @@ -3695,6 +3809,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" name = "taskers" version = "0.1.0-alpha.1" dependencies = [ + "clap", "dioxus", "dioxus-desktop", "gtk", @@ -4123,6 +4238,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml index e591d0b..d1d8977 100644 --- a/greenfield/Cargo.toml +++ b/greenfield/Cargo.toml @@ -15,6 +15,7 @@ version = "0.1.0-alpha.1" [workspace.dependencies] anyhow = "1" +clap = { version = "4", features = ["derive"] } dioxus = { version = "0.7.3", features = ["desktop"] } dioxus-desktop = "0.7.3" gtk = "0.18" diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index d68d981..31afd16 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -10,6 +10,7 @@ name = "taskers" path = "src/main.rs" [dependencies] +clap.workspace = true dioxus.workspace = true dioxus-desktop.workspace = true gtk.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 86d32ca..5ed408f 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -1,3 +1,4 @@ +use clap::{Parser, ValueEnum}; use dioxus::LaunchBuilder; use dioxus_desktop::{ Config, WindowBuilder, @@ -14,18 +15,41 @@ use std::{ path::PathBuf, sync::{Arc, Mutex}, thread, + time::{Duration, Instant}, }; use taskers_core::{ - BootstrapModel, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, TerminalDefaults, + BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, + SurfaceKind, TerminalDefaults, }; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink}; use taskers_paths::default_ghostty_runtime_dir; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; +#[derive(Debug, Clone, Parser)] +#[command(name = "taskers")] +#[command(about = "Greenfield Taskers desktop baseline")] +struct Cli { + #[arg(long, value_enum)] + smoke_script: Option, + #[arg(long)] + diagnostic_log: Option, + #[arg(long)] + quit_after_ms: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum SmokeScript { + Baseline, +} + fn main() { + let cli = Cli::parse(); scrub_inherited_terminal_env(); - let diagnostics = DiagnosticsWriter::from_env(); + let diagnostics = DiagnosticsWriter::from_cli(&cli); + let smoke_script = cli.smoke_script; + let smoke_quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); + let (terminal_defaults, runtime_status) = bootstrap_runtime(); log_runtime_status(diagnostics.as_ref(), &runtime_status); let core = SharedCore::bootstrap(BootstrapModel { @@ -98,6 +122,15 @@ fn main() { ); eprintln!("taskers host initial sync failed: {error}"); } + + if let Some(script) = smoke_script { + spawn_smoke_script( + script, + core_for_window.clone(), + diagnostics_for_window.clone(), + smoke_quit_after_ms, + ); + } }) .with_custom_event_handler(move |event, _target| { if let Event::WindowEvent { @@ -227,6 +260,153 @@ fn spawn_revision_sync_relay(core: SharedCore, diagnostics: Option, + quit_after_ms: u64, +) { + thread::spawn(move || { + let started_at = Instant::now(); + match script { + SmokeScript::Baseline => run_baseline_smoke(core.clone(), diagnostics.as_ref()), + } + + let remaining = Duration::from_millis(quit_after_ms).saturating_sub(started_at.elapsed()); + if !remaining.is_zero() { + thread::sleep(remaining); + } + + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(core.revision()), + format!("smoke script exiting after {}ms", quit_after_ms), + ), + ); + let _ = io::stderr().lock().flush(); + std::process::exit(0); + }); +} + +fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new(DiagnosticCategory::Smoke, Some(core.revision()), "baseline smoke started"), + ); + + thread::sleep(Duration::from_millis(300)); + core.split_with_browser(); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(core.revision()), + "split browser pane", + ), + ); + + if let Some(title) = wait_for_browser_title(&core, Duration::from_secs(4)) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(core.revision()), + format!("browser metadata observed title={title}"), + ), + ); + } else { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(core.revision()), + "browser metadata timed out", + ), + ); + } + + core.split_with_terminal(); + let snapshot = core.snapshot(); + let terminal_status = snapshot.runtime_status.terminal_host.label(); + let terminal_message = snapshot + .runtime_status + .terminal_host + .message() + .unwrap_or("no terminal host note"); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(snapshot.revision), + format!( + "split terminal pane terminal_host={} message={terminal_message}", + terminal_status + ), + ), + ); + + let (browser_count, terminal_count) = surface_counts(&snapshot.layout); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(snapshot.revision), + format!( + "final snapshot panes={} browsers={} terminals={} active={}", + snapshot.portal.panes.len(), + browser_count, + terminal_count, + snapshot.active_pane + ), + ), + ); +} + +fn wait_for_browser_title(core: &SharedCore, timeout: Duration) -> Option { + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + let snapshot = core.snapshot(); + if let Some(title) = first_browser_title(&snapshot.layout) + .filter(|title| title != "Browser") + { + return Some(title); + } + thread::sleep(Duration::from_millis(100)); + } + None +} + +fn first_browser_title(node: &LayoutNodeSnapshot) -> Option { + match node { + LayoutNodeSnapshot::Pane(pane) if pane.surface.kind == SurfaceKind::Browser => { + Some(pane.surface.title.clone()) + } + LayoutNodeSnapshot::Pane(_) => None, + LayoutNodeSnapshot::Split { first, second, .. } => { + first_browser_title(first).or_else(|| first_browser_title(second)) + } + } +} + +fn surface_counts(node: &LayoutNodeSnapshot) -> (usize, usize) { + match node { + LayoutNodeSnapshot::Pane(pane) => match pane.surface.kind { + SurfaceKind::Browser => (1, 0), + SurfaceKind::Terminal => (0, 1), + }, + LayoutNodeSnapshot::Split { first, second, .. } => { + let (first_browser, first_terminal) = surface_counts(first); + let (second_browser, second_terminal) = surface_counts(second); + ( + first_browser + second_browser, + first_terminal + second_terminal, + ) + } + } +} + fn log_runtime_status(diagnostics: Option<&DiagnosticsWriter>, status: &RuntimeStatus) { let summary = format!( "runtime status ghostty={} shell={} terminal={}", @@ -258,19 +438,32 @@ enum DiagnosticsTarget { } impl DiagnosticsWriter { - fn from_env() -> Option { - let value = std::env::var_os("TASKERS_GREENFIELD_DIAGNOSTIC_LOG")?; - if value == "stderr" { + fn from_cli(cli: &Cli) -> Option { + let target = cli + .diagnostic_log + .clone() + .or_else(|| { + std::env::var("TASKERS_GREENFIELD_DIAGNOSTIC_LOG") + .ok() + .filter(|value| !value.is_empty()) + }) + .or_else(|| cli.smoke_script.map(|_| "stderr".into()))?; + + if target == "stderr" { return Some(Self { target: DiagnosticsTarget::Stderr, }); } - let path = PathBuf::from(value); - let file = File::create(path).ok()?; - Some(Self { - target: DiagnosticsTarget::File(Arc::new(Mutex::new(file))), - }) + match File::create(PathBuf::from(&target)) { + Ok(file) => Some(Self { + target: DiagnosticsTarget::File(Arc::new(Mutex::new(file))), + }), + Err(error) => { + eprintln!("taskers diagnostics log path failed: {error}"); + None + } + } } fn sink(&self) -> DiagnosticsSink { From 3ac1bef26cd1599389e1fc63daeaba20039454cc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 11:00:36 +0100 Subject: [PATCH 14/63] docs: add greenfield baseline comparison runbook --- greenfield/README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/greenfield/README.md b/greenfield/README.md index 6c43ea6..8eeb2b6 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -18,3 +18,39 @@ Run it from this workspace: ```bash cargo run -p taskers ``` + +Run the scripted baseline smoke with isolated XDG and runtime paths: + +```bash +TMPDIR="$(mktemp -d)" +XDG_CONFIG_HOME="$TMPDIR/config" \ +XDG_DATA_HOME="$TMPDIR/data" \ +XDG_STATE_HOME="$TMPDIR/state" \ +XDG_CACHE_HOME="$TMPDIR/cache" \ +TASKERS_RUNTIME_DIR="$TMPDIR/runtime" \ +TASKERS_GHOSTTY_RUNTIME_DIR="$TMPDIR/ghostty" \ +cargo run -p taskers -- --smoke-script baseline --diagnostic-log stderr --quit-after-ms 5000 +``` + +Diagnostics can also be written to a file: + +```bash +cargo run -p taskers -- --smoke-script baseline --diagnostic-log /tmp/taskers-greenfield.log +``` + +Baseline comparison checklist: + +- Startup logs runtime capability states instead of silently falling back. +- Initial window attach and snapshot sync are recorded. +- Browser pane creation is logged through the GTK portal runtime. +- Browser title metadata is observed from the native surface. +- Terminal split records the current explicit fallback state instead of pretending native hosting works. +- Final smoke output records pane counts, active pane, and exit timing. + +Manual interactive checklist: + +- Launch `cargo run -p taskers`. +- Split one browser pane and one terminal pane. +- Resize the window and confirm the browser surface stays aligned with the shell chrome. +- Move focus between panes and confirm the active-pane highlight follows. +- Confirm the terminal pane still reports the GTK3/GTK4 Ghostty fallback honestly. From b22802336685a68d26c5165b4746c8338c4b25f3 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 11:07:04 +0100 Subject: [PATCH 15/63] refactor: extract shared taskers shell theme and action surface --- greenfield/crates/taskers-core/src/lib.rs | 46 +- greenfield/crates/taskers-shell/src/lib.rs | 470 +++++----------- greenfield/crates/taskers-shell/src/theme.rs | 554 +++++++++++++++++++ 3 files changed, 744 insertions(+), 326 deletions(-) create mode 100644 greenfield/crates/taskers-shell/src/theme.rs diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 7be342b..694271c 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -267,6 +267,14 @@ pub enum HostEvent { SurfaceCwdChanged { surface_id: SurfaceId, cwd: String }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellAction { + SplitBrowser { pane_id: Option }, + SplitTerminal { pane_id: Option }, + FocusPane { pane_id: PaneId }, + ClosePane { pane_id: PaneId, surface_id: SurfaceId }, +} + #[derive(Debug, Clone)] struct SurfaceRecord { id: SurfaceId, @@ -538,7 +546,11 @@ impl TaskersCore { } } - fn split_active(&mut self, kind: SurfaceKind, axis: SplitAxis) -> bool { + fn split_pane(&mut self, target: PaneId, kind: SurfaceKind, axis: SplitAxis) -> bool { + if !self.model.panes.contains_key(&target) { + return false; + } + let pane = match kind { SurfaceKind::Terminal => self.make_surface(kind, self.next_terminal_title(), None, None), SurfaceKind::Browser => self.make_surface( @@ -554,7 +566,7 @@ impl TaskersCore { if self .model .layout - .split_leaf(self.model.active_pane, axis, pane_id, 500) + .split_leaf(target, axis, pane_id, 500) { self.model.active_pane = pane_id; self.revision += 1; @@ -624,6 +636,26 @@ impl TaskersCore { } } + fn dispatch_shell_action(&mut self, action: ShellAction) -> bool { + match action { + ShellAction::SplitBrowser { pane_id } => self.split_pane( + pane_id.unwrap_or(self.model.active_pane), + SurfaceKind::Browser, + SplitAxis::Horizontal, + ), + ShellAction::SplitTerminal { pane_id } => self.split_pane( + pane_id.unwrap_or(self.model.active_pane), + SurfaceKind::Terminal, + SplitAxis::Vertical, + ), + ShellAction::FocusPane { pane_id } => self.focus_pane(pane_id), + ShellAction::ClosePane { + pane_id, + surface_id, + } => self.close_surface(pane_id, surface_id), + } + } + fn close_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { let Some(pane) = self.model.panes.get(&pane_id) else { return false; @@ -780,16 +812,20 @@ impl SharedCore { self.mutate(|core| core.set_window_size(size)); } + pub fn dispatch_shell_action(&self, action: ShellAction) { + self.mutate(|core| core.dispatch_shell_action(action)); + } + pub fn split_with_browser(&self) { - self.mutate(|core| core.split_active(SurfaceKind::Browser, SplitAxis::Horizontal)); + self.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); } pub fn split_with_terminal(&self) { - self.mutate(|core| core.split_active(SurfaceKind::Terminal, SplitAxis::Vertical)); + self.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }); } pub fn focus_pane(&self, pane_id: PaneId) { - self.mutate(|core| core.focus_pane(pane_id)); + self.dispatch_shell_action(ShellAction::FocusPane { pane_id }); } pub fn apply_host_event(&self, event: HostEvent) { diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 83f6b63..c130779 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -1,271 +1,14 @@ +mod theme; + use dioxus::prelude::*; -use std::sync::Arc; use taskers_core::{ - LayoutNodeSnapshot, PaneId, RuntimeCapability, RuntimeStatus, SharedCore, SplitAxis, + LayoutNodeSnapshot, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, SplitAxis, SurfaceKind, }; -const APP_CSS: &str = r#" -html, body, #main { - margin: 0; - width: 100%; - height: 100%; - background: - radial-gradient(circle at top right, rgba(94, 173, 255, 0.18), transparent 32%), - linear-gradient(180deg, #0b1020 0%, #0d1326 52%, #10182f 100%); - color: #f4f7fb; - font-family: "IBM Plex Sans", "Segoe UI", sans-serif; -} -* { box-sizing: border-box; } -button { font: inherit; } -.app-shell { - width: 100vw; - height: 100vh; - display: flex; - overflow: hidden; -} -.sidebar { - width: 276px; - padding: 18px 16px; - background: rgba(8, 13, 28, 0.78); - border-right: 1px solid rgba(163, 191, 255, 0.12); - backdrop-filter: blur(28px); - display: flex; - flex-direction: column; - gap: 16px; -} -.brand { - display: flex; - flex-direction: column; - gap: 4px; -} -.eyebrow { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: #8fb7ff; -} -.brand h1 { - margin: 0; - font-size: 28px; - line-height: 1; -} -.sidebar-card { - padding: 14px; - border-radius: 16px; - background: rgba(18, 28, 53, 0.78); - border: 1px solid rgba(163, 191, 255, 0.1); - display: flex; - flex-direction: column; - gap: 10px; -} -.workspace-pill { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px; - border-radius: 14px; - background: linear-gradient(135deg, rgba(58, 104, 206, 0.35), rgba(35, 48, 92, 0.45)); - border: 1px solid rgba(163, 191, 255, 0.18); -} -.workspace-pill strong { font-size: 15px; } -.workspace-pill span, .sidebar-card p, .toolbar-subtitle, .status-note, .placeholder-note { - color: #b4c7ec; -} -.status-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} -.status-label { - font-size: 13px; - color: #dce8ff; -} -.status-badge { - border-radius: 999px; - padding: 4px 9px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.status-badge.ready { - background: rgba(91, 224, 160, 0.14); - color: #8cf1be; -} -.status-badge.fallback { - background: rgba(255, 197, 87, 0.14); - color: #ffd37f; -} -.status-badge.unavailable { - background: rgba(255, 128, 128, 0.14); - color: #ffb4b4; -} -.status-note { - font-size: 12px; - line-height: 1.45; -} -.main-column { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; -} -.toolbar { - height: 64px; - padding: 12px 18px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - border-bottom: 1px solid rgba(163, 191, 255, 0.08); - background: rgba(8, 12, 24, 0.48); - backdrop-filter: blur(20px); -} -.toolbar-title { - display: flex; - flex-direction: column; - gap: 2px; -} -.toolbar-title strong { font-size: 16px; } -.toolbar-actions { - display: flex; - align-items: center; - gap: 10px; -} -.toolbar button { - border: 0; - border-radius: 999px; - padding: 10px 14px; - background: #d7e7ff; - color: #102040; - font-weight: 700; - cursor: pointer; -} -.toolbar button.secondary { - background: rgba(255, 255, 255, 0.08); - color: #eef4ff; -} -.workspace-canvas { - flex: 1; - min-height: 0; - padding: 16px; -} -.split-container { - width: 100%; - height: 100%; - display: flex; - gap: 12px; - min-width: 0; - min-height: 0; -} -.split-child { - min-width: 0; - min-height: 0; -} -.pane { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - border-radius: 20px; - background: rgba(10, 16, 32, 0.92); - border: 1px solid rgba(163, 191, 255, 0.08); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); - overflow: hidden; -} -.pane-active { - border-color: rgba(130, 187, 255, 0.55); - box-shadow: - inset 0 1px 0 rgba(255,255,255,0.05), - 0 0 0 1px rgba(61, 151, 255, 0.25); -} -.pane-header { - height: 38px; - min-height: 38px; - padding: 0 14px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid rgba(163, 191, 255, 0.08); - background: linear-gradient(180deg, rgba(22, 33, 63, 0.92), rgba(14, 22, 42, 0.92)); -} -.pane-title { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} -.pane-title strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.kind-badge { - border-radius: 999px; - padding: 4px 8px; - background: rgba(143, 183, 255, 0.12); - color: #a9c7ff; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.pane-body { - flex: 1; - min-height: 0; - position: relative; - overflow: hidden; -} -.surface-placeholder { - width: 100%; - height: 100%; - padding: 20px; - display: flex; - flex-direction: column; - gap: 10px; -} -.surface-placeholder.browser { - background: - linear-gradient(135deg, rgba(30, 61, 120, 0.18), rgba(13, 20, 39, 0.05)), - radial-gradient(circle at bottom right, rgba(87, 197, 255, 0.12), transparent 30%); +fn app_css() -> String { + theme::generate_css(&theme::default_dark()) } -.surface-placeholder.terminal { - background: - linear-gradient(180deg, rgba(9, 12, 21, 0.9), rgba(12, 18, 34, 0.96)); -} -.surface-backdrop { - width: 100%; - height: 100%; - border-radius: 0 0 20px 20px; - border: 1px dashed rgba(144, 184, 255, 0.12); -} -.surface-backdrop.browser { - background: - linear-gradient(180deg, rgba(11, 16, 29, 0.15), rgba(11, 16, 29, 0.02)); -} -.surface-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; -} -.surface-chip { - border-radius: 999px; - padding: 6px 10px; - font-size: 12px; - background: rgba(255, 255, 255, 0.06); - color: #d9e8ff; -} -@media (max-width: 900px) { - .sidebar { display: none; } - .workspace-canvas { padding: 12px; } - .toolbar { padding: 12px; } -} -"#; pub fn app() -> Element { let core = consume_context::(); @@ -286,62 +29,81 @@ pub fn app() -> Element { let _ = revision(); let snapshot = core.snapshot(); - - let focus = { + let stylesheet = app_css(); + let focus_active = { let core = core.clone(); - Arc::new(move |pane_id: PaneId| core.focus_pane(pane_id)) + let active_pane = snapshot.active_pane; + move |_| core.dispatch_shell_action(ShellAction::FocusPane { + pane_id: active_pane, + }) }; - let split_terminal = { let core = core.clone(); - move |_| core.split_with_terminal() + move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }) }; - let split_browser = { let core = core.clone(); - move |_| core.split_with_browser() + move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }) }; rsx! { - style { "{APP_CSS}" } + style { "{stylesheet}" } div { class: "app-shell", - aside { class: "sidebar", - div { class: "brand", - span { class: "eyebrow", "Greenfield rewrite" } + aside { class: "workspace-sidebar", + div { class: "sidebar-brand", + div { class: "sidebar-heading", "Taskers shell" } h1 { "Taskers" } } - div { class: "workspace-pill", - strong { "{snapshot.workspace_title}" } - span { "{snapshot.workspace_count} workspace · revision {snapshot.revision}" } + div { class: "workspace-list", + button { class: "workspace-button", + div { class: "workspace-item workspace-item-active", + div { + div { class: "workspace-label", "{snapshot.workspace_title}" } + div { class: "workspace-preview", "Unified Dioxus shell over native platform hosts." } + div { class: "workspace-meta", "{snapshot.workspace_count} workspace · revision {snapshot.revision}" } + } + div { class: "workspace-status-badge", "{snapshot.portal.panes.len()}" } + } + } } - div { class: "sidebar-card", - div { class: "eyebrow", "Portal runtime" } - p { "Browser panes are mounted through the Linux GTK portal layer. Terminal startup is bootstrapped too, but native Ghostty mounting is still blocked by the GTK3/GTK4 split." } + div { class: "runtime-card", + div { class: "sidebar-heading", "Shell notes" } + div { class: "status-copy", + "The shell chrome is shared Dioxus. Browser and terminal pane bodies are mounted by the platform host." + } } - div { class: "sidebar-card", - div { class: "eyebrow", "Runtime status" } + div { class: "runtime-card", + div { class: "sidebar-heading", "Runtime status" } {render_runtime_capability("Ghostty runtime", &snapshot.runtime_status.ghostty_runtime)} {render_runtime_capability("Shell integration", &snapshot.runtime_status.shell_integration)} {render_runtime_capability("Terminal host", &snapshot.runtime_status.terminal_host)} } } - main { class: "main-column", - header { class: "toolbar", - div { class: "toolbar-title", - strong { "Unified shell bootstrap" } - div { class: "toolbar-subtitle", - "Dioxus chrome + Linux GTK portal runtime" - } + main { class: "workspace-main", + header { class: "workspace-header", + button { + class: "workspace-header-title-btn", + onclick: focus_active, + span { class: "workspace-header-label", "{snapshot.workspace_title}" } + span { class: "workspace-header-meta", "Shared shell · native pane bodies" } } - div { class: "toolbar-actions", - button { class: "secondary", onclick: split_terminal, "Split Terminal" } - button { onclick: split_browser, "Split Browser" } + div { class: "workspace-header-actions", + button { + class: "workspace-header-action", + onclick: split_terminal, + "+ terminal" + } + button { + class: "workspace-header-action workspace-header-action-primary", + onclick: split_browser, + "+ browser" + } } } div { class: "workspace-canvas", - {render_layout(&snapshot.layout, focus.clone(), &snapshot.runtime_status)} + {render_layout(&snapshot.layout, core.clone(), &snapshot.runtime_status)} } } } @@ -350,19 +112,19 @@ pub fn app() -> Element { fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability) -> Element { let class = match capability { - RuntimeCapability::Ready => "status-badge ready", - RuntimeCapability::Fallback { .. } => "status-badge fallback", - RuntimeCapability::Unavailable { .. } => "status-badge unavailable", + RuntimeCapability::Ready => "status-pill status-pill-ready", + RuntimeCapability::Fallback { .. } => "status-pill status-pill-fallback", + RuntimeCapability::Unavailable { .. } => "status-pill status-pill-unavailable", }; rsx! { div { - div { class: "status-row", - span { class: "status-label", "{label}" } + div { class: "runtime-status-row", + span { class: "workspace-preview", "{label}" } span { class: "{class}", "{capability.label()}" } } if let Some(message) = capability.message() { - div { class: "status-note", "{message}" } + div { class: "status-copy", "{message}" } } } } @@ -370,7 +132,7 @@ fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability fn render_layout( node: &LayoutNodeSnapshot, - focus: Arc, + core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { match node { @@ -392,24 +154,24 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, focus.clone(), runtime_status)} + {render_layout(first, core.clone(), runtime_status)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, focus.clone(), runtime_status)} + {render_layout(second, core.clone(), runtime_status)} } } } } LayoutNodeSnapshot::Pane(pane) => { let pane_class = if pane.active { - "pane pane-active" + "pane-card pane-card-active" } else { - "pane" + "pane-card" }; let pane_id = pane.id; - let focus_this = focus.clone(); + let surface_id = pane.surface.id; let kind_label = pane.surface.kind.label(); - let placeholder = match pane.surface.kind { + let surface_copy = match pane.surface.kind { SurfaceKind::Browser => { let url = pane .surface @@ -417,15 +179,17 @@ fn render_layout( .clone() .unwrap_or_else(|| "about:blank".into()); rsx! { - div { class: "surface-placeholder browser", - div { class: "eyebrow", "Native browser surface" } - p { class: "placeholder-note", - "This pane is mounted through the Linux GTK portal overlay." + div { class: "surface-backdrop", + div { class: "surface-backdrop-copy", + div { class: "surface-backdrop-eyebrow", "Browser surface" } + div { class: "surface-backdrop-title", "{pane.surface.title}" } + div { class: "surface-backdrop-note", + "The platform host mounts a native browser view into this body region while the shared shell keeps the chrome and actions consistent." + } } div { class: "surface-meta", span { class: "surface-chip", "URL: {url}" } } - div { class: "surface-backdrop browser" } } } } @@ -435,33 +199,97 @@ fn render_layout( .message() .unwrap_or("Terminal hosting is ready."); rsx! { - div { class: "surface-placeholder terminal", - div { class: "eyebrow", "Terminal runtime" } - p { class: "placeholder-note", - "{host_message}" + div { class: "surface-backdrop", + div { class: "surface-backdrop-copy", + div { class: "surface-backdrop-eyebrow", "Terminal surface" } + div { class: "surface-backdrop-title", "{pane.surface.title}" } + div { class: "surface-backdrop-note", "{host_message}" } } if let Some(cwd) = &pane.surface.cwd { div { class: "surface-meta", span { class: "surface-chip", "cwd: {cwd}" } } } - div { class: "surface-backdrop" } } } } }; + let status_class = match pane.surface.kind { + SurfaceKind::Browser => "status-dot status-dot-busy", + SurfaceKind::Terminal => { + if matches!(runtime_status.terminal_host, RuntimeCapability::Ready) { + "status-dot status-dot-completed" + } else { + "status-dot status-dot-waiting" + } + } + }; + let subtitle = match &pane.surface.cwd { + Some(cwd) => format!("{kind_label} · {cwd}"), + None => format!("{kind_label} · {}", pane.id), + }; + let focus_pane = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::FocusPane { pane_id }) + }; + let split_browser = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { + pane_id: Some(pane_id), + }) + }; + let split_terminal = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { + pane_id: Some(pane_id), + }) + }; + let close_pane = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ClosePane { + pane_id, + surface_id, + }) + }; rsx! { - div { class: "{pane_class}", onclick: move |_| (focus_this)(pane_id), + section { + class: "{pane_class}", + onclick: focus_pane, div { class: "pane-header", - div { class: "pane-title", - span { class: "kind-badge", "{kind_label}" } - strong { "{pane.surface.title}" } + div { class: "pane-header-main", + span { class: "{status_class}", "●" } + div { class: "pane-title-stack", + div { class: "pane-title", "{pane.surface.title}" } + div { class: "pane-meta", "{subtitle}" } + } + } + div { class: "pane-action-cluster", + button { + class: "pane-action pane-window-action", + onclick: split_browser, + "+web" + } + button { + class: "pane-action pane-split-action", + onclick: split_terminal, + "+term" + } + button { + class: "pane-action pane-close-action", + onclick: close_pane, + "×" + } + } + } + div { class: "surface-tabs", + div { class: "surface-tab surface-tab-active", + span { class: "surface-tab-label", "{kind_label}" } + span { class: "pane-meta", "{pane.surface.id}" } } - span { class: "eyebrow", "{pane.id}" } } div { class: "pane-body", - {placeholder} + {surface_copy} } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs new file mode 100644 index 0000000..44b816a --- /dev/null +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -0,0 +1,554 @@ +use std::fmt::Write as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Color { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + pub fn to_hex(self) -> String { + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemePalette { + pub base: Color, + pub surface: Color, + pub elevated: Color, + pub overlay: Color, + pub text: Color, + pub text_bright: Color, + pub text_muted: Color, + pub text_subtle: Color, + pub text_dim: Color, + pub text_faint: Color, + pub border: Color, + pub accent: Color, + pub busy: Color, + pub completed: Color, + pub waiting: Color, + pub error: Color, + pub busy_text: Color, + pub completed_text: Color, + pub waiting_text: Color, + pub error_text: Color, + pub action_window: Color, + pub action_split: Color, + pub action_teal: Color, +} + +pub fn default_dark() -> ThemePalette { + ThemePalette { + base: Color::new(0x0f, 0x11, 0x17), + surface: Color::new(0x0d, 0x0f, 0x15), + elevated: Color::new(0x12, 0x14, 0x1c), + overlay: Color::new(0x1a, 0x1d, 0x28), + text: Color::new(0xe2, 0xe4, 0xea), + text_bright: Color::new(0xf0, 0xf2, 0xf8), + text_muted: Color::new(0x8b, 0x8f, 0xa3), + text_subtle: Color::new(0xb0, 0xb4, 0xc4), + text_dim: Color::new(0x5c, 0x61, 0x78), + text_faint: Color::new(0x3d, 0x42, 0x59), + border: Color::new(0xff, 0xff, 0xff), + accent: Color::new(0x7c, 0x8a, 0xff), + busy: Color::new(0x7c, 0x8a, 0xff), + completed: Color::new(0x34, 0xd3, 0x99), + waiting: Color::new(0x60, 0xa5, 0xfa), + error: Color::new(0xf8, 0x71, 0x71), + busy_text: Color::new(0xc7, 0xd2, 0xfe), + completed_text: Color::new(0xa7, 0xf3, 0xd0), + waiting_text: Color::new(0xdb, 0xea, 0xfe), + error_text: Color::new(0xfe, 0xca, 0xca), + action_window: Color::new(0x7d, 0xd3, 0xfc), + action_split: Color::new(0x5e, 0xea, 0xd4), + action_teal: Color::new(0x2d, 0xd4, 0xbf), + } +} + +fn rgba(color: Color, alpha: f32) -> String { + format!("rgba({},{},{},{alpha:.2})", color.r, color.g, color.b) +} + +pub fn generate_css(p: &ThemePalette) -> String { + let mut css = String::with_capacity(8192); + let _ = write!( + css, + r#" +html, body, #main {{ + margin: 0; + width: 100%; + height: 100%; + background: {base}; + color: {text}; + font-family: "IBM Plex Sans", "SF Pro Text", system-ui, sans-serif; +}} +* {{ box-sizing: border-box; }} +button {{ font: inherit; }} +.app-shell {{ + width: 100vw; + height: 100vh; + background: linear-gradient(180deg, {base} 0%, {surface} 100%); + display: flex; + overflow: hidden; +}} +.workspace-sidebar {{ + width: 248px; + flex: 0 0 248px; + background: {surface}; + border-right: 1px solid {border_04}; + padding: 10px 8px; + display: flex; + flex-direction: column; + gap: 12px; +}} +.sidebar-heading {{ + font-weight: 600; + font-size: 11px; + color: {text_dim}; + letter-spacing: 0.10em; + text-transform: uppercase; +}} +.sidebar-brand {{ + padding: 6px 8px 2px; + display: flex; + flex-direction: column; + gap: 4px; +}} +.sidebar-brand h1 {{ + margin: 0; + font-size: 26px; + line-height: 1; + color: {text_bright}; +}} +.workspace-list {{ + display: flex; + flex-direction: column; + gap: 6px; +}} +.workspace-button {{ + padding: 0; + border: 0; + background: transparent; + text-align: left; +}} +.workspace-item {{ + padding: 8px 9px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + transition: background 160ms ease-in-out, border-color 160ms ease-in-out; +}} +.workspace-button:hover .workspace-item {{ + background: {border_04}; + border-color: {border_10}; +}} +.workspace-item-active {{ + background: {border_05}; + border-color: {border_10}; +}} +.workspace-label {{ + font-weight: 600; + font-size: 13px; + color: {text_bright}; +}} +.workspace-preview {{ + color: {text_subtle}; + font-size: 12px; + line-height: 1.35; +}} +.workspace-meta {{ + color: {text_dim}; + font-size: 11px; +}} +.workspace-status-badge {{ + background: {accent_14}; + color: {busy_text}; + border-radius: 999px; + padding: 2px 6px; + min-width: 18px; + text-align: center; + font-size: 11px; + font-weight: 700; +}} +.runtime-card {{ + background: transparent; + border: 1px solid {border_06}; + border-radius: 8px; + padding: 9px 10px; + display: flex; + flex-direction: column; + gap: 6px; +}} +.runtime-status-row {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +}} +.status-pill {{ + border-radius: 999px; + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +}} +.status-pill-ready {{ + background: {completed_16}; + color: {completed_text}; +}} +.status-pill-fallback {{ + background: {waiting_18}; + color: {waiting_text}; +}} +.status-pill-unavailable {{ + background: {error_16}; + color: {error_text}; +}} +.status-copy {{ + color: {text_subtle}; + font-size: 12px; + line-height: 1.4; +}} +.workspace-main {{ + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; +}} +.workspace-header {{ + height: 52px; + min-height: 52px; + border-bottom: 1px solid {border_07}; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: {base}; +}} +.workspace-header-title-btn {{ + background: transparent; + border: 0; + border-radius: 6px; + color: {text_bright}; + padding: 6px 8px; + text-align: left; +}} +.workspace-header-title-btn:hover {{ + background: {border_06}; +}} +.workspace-header-label {{ + display: block; + font-weight: 600; + font-size: 14px; + color: {text_bright}; +}} +.workspace-header-meta {{ + display: block; + font-size: 12px; + color: {text_dim}; +}} +.workspace-header-actions {{ + display: flex; + align-items: center; + gap: 6px; +}} +.workspace-header-action {{ + background: transparent; + border: 0; + border-radius: 6px; + min-width: 28px; + min-height: 28px; + color: {text_faint}; + padding: 0 10px; +}} +.workspace-header-action:hover {{ + background: {border_06}; + color: {text_muted}; +}} +.workspace-header-action-primary {{ + background: {accent_14}; + color: {text_bright}; +}} +.workspace-header-action-primary:hover {{ + background: {accent_22}; +}} +.workspace-canvas {{ + flex: 1; + min-height: 0; + padding: 14px; +}} +.split-container {{ + width: 100%; + height: 100%; + display: flex; + gap: 12px; + min-width: 0; + min-height: 0; +}} +.split-child {{ + min-width: 0; + min-height: 0; +}} +.pane-card {{ + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: {elevated}; + border: 1px solid {border_07}; + border-radius: 8px; + overflow: hidden; +}} +.pane-card-active {{ + border-color: {accent_20}; +}} +.pane-header {{ + background: {border_02}; + border-bottom: 1px solid {border_05}; + padding: 5px 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + transition: background 160ms ease-in-out; +}} +.pane-card:hover .pane-header {{ + background: {border_04}; +}} +.pane-card-active .pane-header {{ + background: {accent_06}; + border-bottom-color: {accent_15}; +}} +.pane-header-main {{ + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +}} +.status-dot {{ + font-size: 12px; + line-height: 1; +}} +.status-dot-normal {{ color: {text_faint}; }} +.status-dot-busy {{ color: {busy}; }} +.status-dot-completed {{ color: {completed}; }} +.status-dot-waiting {{ color: {waiting}; }} +.status-dot-error {{ color: {error}; }} +.pane-title-stack {{ + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +}} +.pane-title {{ + font-weight: 500; + color: {text_muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +}} +.pane-card-active .pane-title {{ + color: {text}; +}} +.pane-meta {{ + color: {text_faint}; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +}} +.pane-action-cluster {{ + display: flex; + align-items: center; + gap: 4px; + background: {border_04}; + border: 1px solid {border_05}; + border-radius: 999px; + padding: 2px; +}} +.pane-card-active .pane-action-cluster {{ + background: {accent_06}; + border-color: {accent_12}; +}} +.pane-action {{ + background: transparent; + border: 1px solid transparent; + border-radius: 999px; + min-width: 24px; + min-height: 22px; + padding: 0 8px; + color: {text_faint}; +}} +.pane-action:hover {{ + background: {accent_12}; + border-color: {accent_15}; + color: {text}; +}} +.pane-window-action {{ + color: {action_window}; +}} +.pane-split-action {{ + color: {action_split}; +}} +.pane-close-action:hover {{ + background: {error_18}; + border-color: {error_18}; + color: {error_text}; +}} +.surface-tabs {{ + margin: 4px 8px 6px; + min-height: 24px; + display: flex; + align-items: center; + gap: 6px; +}} +.surface-tab {{ + background: {border_03}; + border: 1px solid {border_07}; + border-radius: 6px; + padding: 3px 8px; + display: inline-flex; + align-items: center; + gap: 7px; +}} +.surface-tab-active {{ + background: {accent_14}; + border-color: {accent_35}; +}} +.surface-tab-label {{ + color: {text_muted}; + font-size: 12px; +}} +.pane-body {{ + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; +}} +.surface-backdrop {{ + width: 100%; + height: 100%; + border-top: 1px solid {border_04}; + background: + linear-gradient(180deg, {overlay} 0%, {elevated} 100%); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 16px; +}} +.surface-backdrop-copy {{ + max-width: 520px; + display: flex; + flex-direction: column; + gap: 8px; +}} +.surface-backdrop-eyebrow {{ + font-weight: 600; + font-size: 11px; + letter-spacing: 0.10em; + text-transform: uppercase; + color: {text_dim}; +}} +.surface-backdrop-title {{ + font-size: 18px; + font-weight: 600; + color: {text_bright}; +}} +.surface-backdrop-note {{ + color: {text_subtle}; + font-size: 13px; + line-height: 1.45; +}} +.surface-meta {{ + display: flex; + flex-wrap: wrap; + gap: 8px; +}} +.surface-chip {{ + border-radius: 999px; + padding: 6px 10px; + background: {border_04}; + border: 1px solid {border_06}; + color: {text_subtle}; + font-size: 12px; +}} +@media (max-width: 960px) {{ + .workspace-sidebar {{ + display: none; + }} + .workspace-canvas {{ + padding: 10px; + }} +}} +"#, + base = p.base.to_hex(), + surface = p.surface.to_hex(), + elevated = p.elevated.to_hex(), + overlay = p.overlay.to_hex(), + text = p.text.to_hex(), + text_bright = p.text_bright.to_hex(), + text_muted = p.text_muted.to_hex(), + text_subtle = p.text_subtle.to_hex(), + text_dim = p.text_dim.to_hex(), + text_faint = p.text_faint.to_hex(), + busy = p.busy.to_hex(), + completed = p.completed.to_hex(), + waiting = p.waiting.to_hex(), + error = p.error.to_hex(), + busy_text = p.busy_text.to_hex(), + completed_text = p.completed_text.to_hex(), + waiting_text = p.waiting_text.to_hex(), + error_text = p.error_text.to_hex(), + action_window = p.action_window.to_hex(), + action_split = p.action_split.to_hex(), + border_02 = rgba(p.border, 0.02), + border_03 = rgba(p.border, 0.03), + border_04 = rgba(p.border, 0.04), + border_05 = rgba(p.border, 0.05), + border_06 = rgba(p.border, 0.06), + border_07 = rgba(p.border, 0.07), + border_10 = rgba(p.border, 0.10), + accent_06 = rgba(p.accent, 0.06), + accent_12 = rgba(p.accent, 0.12), + accent_14 = rgba(p.accent, 0.14), + accent_15 = rgba(p.accent, 0.15), + accent_20 = rgba(p.accent, 0.20), + accent_22 = rgba(p.accent, 0.22), + accent_35 = rgba(p.accent, 0.35), + completed_16 = rgba(p.completed, 0.16), + waiting_18 = rgba(p.waiting, 0.18), + error_16 = rgba(p.error, 0.16), + error_18 = rgba(p.error, 0.18), + ); + css +} + +#[cfg(test)] +mod tests { + use super::{default_dark, generate_css}; + + #[test] + fn generated_css_contains_legacy_shell_landmarks() { + let css = generate_css(&default_dark()); + assert!(css.contains(".workspace-sidebar")); + assert!(css.contains(".workspace-header")); + assert!(css.contains(".pane-card")); + assert!(css.contains(".surface-tabs")); + } +} From 15566942a9e9c2356902e0ba1dd4e74d8068f109 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 11:20:56 +0100 Subject: [PATCH 16/63] feat: replace greenfield gtk3 desktop bootstrap with gtk4 liveview host --- greenfield/Cargo.lock | 3091 ++++---------------- greenfield/Cargo.toml | 13 +- greenfield/README.md | 35 +- greenfield/crates/taskers-host/Cargo.toml | 4 +- greenfield/crates/taskers-host/src/lib.rs | 906 +++--- greenfield/crates/taskers-shell/src/lib.rs | 5 +- greenfield/crates/taskers/Cargo.toml | 7 +- greenfield/crates/taskers/src/main.rs | 506 ++-- greenfield/scripts/headless-smoke.sh | 13 + 9 files changed, 1571 insertions(+), 3009 deletions(-) create mode 100755 greenfield/scripts/headless-smoke.sh diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index dff3c5d..4e4e147 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "1.0.0" @@ -81,37 +72,75 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "atk" -version = "0.18.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "atk-sys" -version = "0.18.2" +name = "axum" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] [[package]] name = "base64" @@ -134,12 +163,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -149,27 +172,12 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -181,23 +189,21 @@ dependencies = [ [[package]] name = "cairo-rs" -version = "0.18.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", - "once_cell", - "thiserror 1.0.69", ] [[package]] name = "cairo-sys-rs" -version = "0.18.2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", @@ -214,28 +220,11 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - [[package]] name = "cfg-expr" -version = "0.15.8" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -281,10 +270,10 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -293,93 +282,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cocoa" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" -dependencies = [ - "bitflags 2.11.0", - "block", - "cocoa-foundation", - "core-foundation", - "core-graphics 0.24.0", - "foreign-types 0.5.0", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" -dependencies = [ - "bitflags 2.11.0", - "block", - "core-foundation", - "core-graphics-types", - "objc", -] - [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "const-serialize" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" -dependencies = [ - "const-serialize-macro 0.7.2", -] - -[[package]] -name = "const-serialize" -version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" -dependencies = [ - "const-serialize 0.7.2", - "const-serialize-macro 0.8.0-alpha.0", - "serde", -] - -[[package]] -name = "const-serialize-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "const-serialize-macro" -version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "const_format" version = "0.2.35" @@ -400,12 +308,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.8.0" @@ -415,69 +317,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "libc", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -496,21 +335,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crypto-common" version = "0.1.7" @@ -521,33 +345,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "darling" version = "0.21.3" @@ -568,7 +365,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -579,7 +376,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -598,19 +395,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "digest" version = "0.10.7" @@ -627,43 +411,14 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" dependencies = [ - "dioxus-asset-resolver", - "dioxus-cli-config", - "dioxus-config-macro", "dioxus-config-macros", "dioxus-core", "dioxus-core-macro", - "dioxus-desktop", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", "dioxus-hooks", "dioxus-html", - "dioxus-logger", "dioxus-signals", "dioxus-stores", - "dioxus-web", - "manganis", "subsecond", - "warnings", -] - -[[package]] -name = "dioxus-asset-resolver" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" -dependencies = [ - "dioxus-cli-config", - "http", - "infer", - "jni 0.21.1", - "ndk", - "ndk-context", - "ndk-sys", - "percent-encoding", - "thiserror 2.0.18", - "tokio", ] [[package]] @@ -671,19 +426,6 @@ name = "dioxus-cli-config" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "dioxus-config-macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" -dependencies = [ - "proc-macro2", - "quote", -] [[package]] name = "dioxus-config-macros" @@ -719,11 +461,11 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" dependencies = [ - "convert_case 0.8.0", + "convert_case", "dioxus-rsx", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -732,61 +474,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" -[[package]] -name = "dioxus-desktop" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" -dependencies = [ - "async-trait", - "base64", - "bytes", - "cocoa", - "core-foundation", - "dioxus-asset-resolver", - "dioxus-cli-config", - "dioxus-core", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-hooks", - "dioxus-html", - "dioxus-interpreter-js", - "dioxus-signals", - "dunce", - "futures-channel", - "futures-util", - "generational-box", - "global-hotkey", - "infer", - "jni 0.21.1", - "lazy-js-bundle", - "libc", - "muda", - "ndk", - "ndk-context", - "ndk-sys", - "objc", - "objc_id", - "percent-encoding", - "rand 0.9.2", - "rfd", - "rustc-hash 2.1.1", - "serde", - "serde_json", - "signal-hook", - "slab", - "subtle", - "tao", - "thiserror 2.0.18", - "tokio", - "tracing", - "tray-icon", - "tungstenite", - "webbrowser", - "wry", -] - [[package]] name = "dioxus-devtools" version = "0.7.3" @@ -802,7 +489,7 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite", + "tungstenite 0.27.0", ] [[package]] @@ -894,10 +581,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" dependencies = [ - "convert_case 0.8.0", + "convert_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -909,27 +596,38 @@ dependencies = [ "dioxus-core", "dioxus-core-types", "dioxus-html", - "js-sys", "lazy-js-bundle", "rustc-hash 2.1.1", - "serde", "sledgehammer_bindgen", "sledgehammer_utils", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] -name = "dioxus-logger" +name = "dioxus-liveview" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" dependencies = [ + "axum", "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "futures-channel", + "futures-util", + "generational-box", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "slab", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", "tracing", - "tracing-subscriber", - "tracing-wasm", ] [[package]] @@ -942,7 +640,7 @@ dependencies = [ "proc-macro2-diagnostics", "quote", "rustversion", - "syn 2.0.117", + "syn", ] [[package]] @@ -979,76 +677,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" dependencies = [ - "convert_case 0.8.0", + "convert_case", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "dioxus-web" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" -dependencies = [ - "dioxus-cli-config", - "dioxus-core", - "dioxus-core-types", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-html", - "dioxus-interpreter-js", - "dioxus-signals", - "futures-channel", - "futures-util", - "generational-box", - "gloo-timers", - "js-sys", - "lazy-js-bundle", - "rustc-hash 2.1.1", - "send_wrapper", - "serde", - "serde-wasm-bindgen", - "serde_json", - "tracing", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", + "syn", ] [[package]] @@ -1059,30 +691,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1091,33 +700,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "enumset" version = "1.1.10" @@ -1136,7 +718,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1165,21 +747,6 @@ dependencies = [ "serde", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "field-offset" version = "0.3.6" @@ -1201,6 +768,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1229,48 +807,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1280,16 +816,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1330,7 +856,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1352,57 +878,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - [[package]] name = "gdk-pixbuf" -version = "0.18.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" dependencies = [ "gdk-pixbuf-sys", "gio", "glib", "libc", - "once_cell", ] [[package]] name = "gdk-pixbuf-sys" -version = "0.18.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" dependencies = [ "gio-sys", "glib-sys", @@ -1412,49 +911,37 @@ dependencies = [ ] [[package]] -name = "gdk-sys" -version = "0.18.2" +name = "gdk4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", "libc", - "pango-sys", - "pkg-config", - "system-deps", + "pango", ] [[package]] -name = "gdkwayland-sys" -version = "0.18.2" +name = "gdk4-sys" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" dependencies = [ - "gdk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", "glib-sys", "gobject-sys", "libc", + "pango-sys", "pkg-config", "system-deps", ] -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - [[package]] name = "generational-box" version = "0.7.3" @@ -1475,27 +962,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link 0.2.1", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1504,7 +970,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1534,9 +1000,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.18.4" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" dependencies = [ "futures-channel", "futures-core", @@ -1545,30 +1011,28 @@ dependencies = [ "gio-sys", "glib", "libc", - "once_cell", "pin-project-lite", "smallvec", - "thiserror 1.0.69", ] [[package]] name = "gio-sys" -version = "0.18.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" dependencies = [ "glib-sys", "gobject-sys", "libc", "system-deps", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "glib" -version = "0.18.5" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" dependencies = [ "bitflags 2.11.0", "futures-channel", @@ -1582,125 +1046,146 @@ dependencies = [ "gobject-sys", "libc", "memchr", - "once_cell", "smallvec", - "thiserror 1.0.69", ] [[package]] name = "glib-macros" -version = "0.18.5" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "glib-sys" -version = "0.18.1" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" dependencies = [ "libc", "system-deps", ] [[package]] -name = "global-hotkey" -version = "0.7.0" +name = "gobject-sys" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2", - "objc2-app-kit", - "once_cell", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", + "glib-sys", + "libc", + "system-deps", ] [[package]] -name = "gloo-timers" -version = "0.3.0" +name = "graphene-rs" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", + "glib", + "graphene-sys", + "libc", ] [[package]] -name = "gobject-sys" -version = "0.18.0" +name = "graphene-sys" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" dependencies = [ "glib-sys", "libc", + "pkg-config", "system-deps", ] [[package]] -name = "gtk" -version = "0.18.2" +name = "gsk4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" dependencies = [ - "atk", "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", + "gdk4", "glib", - "gtk-sys", - "gtk3-macros", + "graphene-rs", + "gsk4-sys", "libc", "pango", - "pkg-config", ] [[package]] -name = "gtk-sys" -version = "0.18.2" +name = "gsk4-sys" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" dependencies = [ - "atk-sys", "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", + "gdk4-sys", "glib-sys", "gobject-sys", + "graphene-sys", "libc", "pango-sys", "system-deps", ] [[package]] -name = "gtk3-macros" -version = "0.18.2" +name = "gtk4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] @@ -1720,36 +1205,41 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "heck" -version = "0.5.0" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] [[package]] -name = "html5ever" -version = "0.29.1" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", + "bytes", + "http", ] [[package]] -name = "http" -version = "1.4.0" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "itoa", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] @@ -1758,6 +1248,48 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1884,15 +1416,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1906,21 +1429,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] -name = "javascriptcore-rs" -version = "1.1.2" +name = "javascriptcore6" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" dependencies = [ - "bitflags 1.3.2", "glib", - "javascriptcore-rs-sys", + "javascriptcore6-sys", + "libc", ] [[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" +name = "javascriptcore6-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" dependencies = [ "glib-sys", "gobject-sys", @@ -1928,77 +1451,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.0", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link 0.2.1", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "js-sys" version = "0.3.91" @@ -2017,19 +1469,6 @@ checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ "bitflags 2.11.0", "serde", - "unicode-segmentation", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap", - "selectors", ] [[package]] @@ -2051,27 +1490,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libappindicator" -version = "0.9.0" +name = "libadwaita" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" dependencies = [ + "gdk4", + "gio", "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", + "gtk4", + "libadwaita-sys", + "libc", + "pango", ] [[package]] -name = "libappindicator-sys" -version = "0.9.0" +name = "libadwaita-sys" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] @@ -2080,16 +1526,6 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.9" @@ -2097,7 +1533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2106,26 +1542,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", -] - -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -2162,110 +1582,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" [[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "macro-string" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" +name = "lzma-sys" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" dependencies = [ + "cc", "libc", + "pkg-config", ] [[package]] -name = "manganis" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" -dependencies = [ - "const-serialize 0.7.2", - "const-serialize 0.8.0-alpha.0", - "manganis-core", - "manganis-macro", -] - -[[package]] -name = "manganis-core" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" -dependencies = [ - "const-serialize 0.7.2", - "const-serialize 0.8.0-alpha.0", - "dioxus-cli-config", - "dioxus-core-types", - "serde", - "winnow 0.7.15", -] - -[[package]] -name = "manganis-macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" -dependencies = [ - "dunce", - "macro-string", - "manganis-core", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matches" -version = "0.1.10" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -2300,6 +1631,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2311,102 +1648,33 @@ dependencies = [ ] [[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "libxdo", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "native-tls" -version = "0.2.18" +name = "mio" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "ndk" -version = "0.9.0" +name = "nix" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.11.0", - "jni-sys 0.3.0", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle 0.6.2", - "thiserror 1.0.69", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] -name = "ndk-context" -version = "0.1.1" +name = "num-conv" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.0", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -2417,143 +1685,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.0", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2566,74 +1697,23 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "pango" -version = "0.18.3" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" dependencies = [ "gio", "glib", "libc", - "once_cell", "pango-sys", ] [[package]] name = "pango-sys" -version = "0.18.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" dependencies = [ "glib-sys", "gobject-sys", @@ -2659,9 +1739,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2670,126 +1750,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - [[package]] name = "pin-project" version = "1.1.11" @@ -2807,7 +1767,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2817,29 +1777,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pin-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "png" -version = "0.17.16" +name = "pkg-config" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] -name = "pollster" -version = "0.4.0" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "portable-pty" @@ -2886,12 +1839,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "prettyplease" version = "0.2.37" @@ -2899,27 +1846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "syn", ] [[package]] @@ -2928,39 +1855,9 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.5+spec-1.1.0", + "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2978,7 +1875,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "version_check", ] @@ -3003,59 +1900,14 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -3065,25 +1917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -3095,36 +1929,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3135,55 +1939,12 @@ dependencies = [ ] [[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rfd" -version = "0.17.2" +name = "redox_syscall" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "block2", - "dispatch2", - "js-sys", - "libc", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "percent-encoding", - "pollster", - "raw-window-handle 0.6.2", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.61.2", + "bitflags 2.11.0", ] [[package]] @@ -3240,7 +2001,9 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3274,22 +2037,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "same-file" -version = "1.0.6" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -3297,62 +2048,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" -dependencies = [ - "futures-core", -] - [[package]] name = "serde" version = "1.0.228" @@ -3363,17 +2064,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -3391,7 +2081,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3408,75 +2098,68 @@ dependencies = [ ] [[package]] -name = "serde_repr" +name = "serde_path_to_error" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ + "itoa", "serde", + "serde_core", ] [[package]] -name = "serial2" -version = "0.2.34" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ - "cfg-if", - "libc", - "winapi", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "servo_arc" -version = "0.2.0" +name = "serde_spanned" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "nodrop", - "stable_deref_trait", + "serde_core", ] [[package]] -name = "sha1" -version = "0.10.6" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "serial2" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "libc", + "winapi", ] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "lazy_static", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -3501,60 +2184,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" @@ -3568,7 +2203,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" dependencies = [ "sledgehammer_bindgen_macro", - "wasm-bindgen", ] [[package]] @@ -3578,7 +2212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" dependencies = [ "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3606,11 +2240,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "soup3" -version = "0.5.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" dependencies = [ "futures-channel", "gio", @@ -3621,9 +2265,9 @@ dependencies = [ [[package]] name = "soup3-sys" -version = "0.5.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" dependencies = [ "gio-sys", "glib-sys", @@ -3638,31 +2282,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "strsim" version = "0.11.1" @@ -3677,7 +2296,7 @@ checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" dependencies = [ "js-sys", "libc", - "libloading 0.8.9", + "libloading", "memfd", "memmap2", "serde", @@ -3705,9 +2324,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3715,15 +2334,10 @@ dependencies = [ ] [[package]] -name = "syn" -version = "2.0.117" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "synstructure" @@ -3733,92 +2347,58 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "system-deps" -version = "6.2.2" +version = "7.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" dependencies = [ "cfg-expr", - "heck 0.5.0", + "heck", "pkg-config", "toml", "version-compare", ] [[package]] -name = "tao" -version = "0.34.6" +name = "tar" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" dependencies = [ - "bitflags 2.11.0", - "block2", - "core-foundation", - "core-graphics 0.25.0", - "crossbeam-channel", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni 0.21.1", + "filetime", "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "parking_lot", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "xattr", ] [[package]] name = "target-lexicon" -version = "0.12.16" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "taskers" version = "0.1.0-alpha.1" dependencies = [ + "anyhow", + "axum", "clap", "dioxus", - "dioxus-desktop", - "gtk", + "dioxus-liveview", + "gtk4", + "libadwaita", "taskers-core", + "taskers-ghostty", "taskers-host", "taskers-paths", "taskers-runtime", "taskers-shell", "tokio", + "webkit6", ] [[package]] @@ -3842,14 +2422,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "taskers-ghostty" +version = "0.3.0" +dependencies = [ + "gtk4", + "libloading", + "serde", + "tar", + "taskers-domain", + "taskers-paths", + "thiserror 2.0.18", + "ureq", + "xz2", +] + [[package]] name = "taskers-host" version = "0.1.0-alpha.1" dependencies = [ "anyhow", - "dioxus-desktop", - "gtk", + "gtk4", "taskers-core", + "taskers-domain", + "taskers-ghostty", + "webkit6", ] [[package]] @@ -3876,30 +2473,6 @@ dependencies = [ "taskers-core", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -3926,7 +2499,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3937,16 +2510,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", + "syn", ] [[package]] @@ -3997,8 +2561,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -4009,61 +2577,77 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "toml" -version = "0.8.2" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] -name = "toml_datetime" -version = "0.6.3" +name = "tokio-tungstenite" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ - "serde", + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", ] [[package]] -name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +name = "tokio-util" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ - "serde_core", + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", ] [[package]] -name = "toml_edit" -version = "0.19.15" +name = "toml" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] -name = "toml_edit" -version = "0.20.2" +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", ] [[package]] @@ -4087,12 +2671,47 @@ dependencies = [ "winnow 1.0.0", ] +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4106,7 +2725,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4119,66 +2738,34 @@ dependencies = [ ] [[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "once_cell", - "regex-automata", - "sharded-slab", - "thread_local", - "tracing", - "tracing-core", -] - -[[package]] -name = "tracing-wasm" -version = "0.2.1" +name = "tungstenite" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" dependencies = [ - "tracing", - "tracing-subscriber", - "wasm-bindgen", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", "thiserror 2.0.18", - "windows-sys 0.60.2", + "utf-8", ] [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "native-tls", - "rand 0.9.2", - "rustls", + "rand", "sha1", "thiserror 2.0.18", "utf-8", @@ -4214,6 +2801,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4256,12 +2859,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -4274,16 +2871,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "warnings" version = "0.2.1" @@ -4303,15 +2890,9 @@ checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4382,7 +2963,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -4417,19 +2998,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -4453,99 +3021,54 @@ dependencies = [ ] [[package]] -name = "webbrowser" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" -dependencies = [ - "core-foundation", - "jni 0.22.4", - "log", - "ndk-context", - "objc2", - "objc2-foundation", - "url", - "web-sys", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.1" +name = "webkit6" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", + "gdk4", "gio", - "gio-sys", "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", + "gtk4", + "javascriptcore6", "libc", - "once_cell", "soup3", - "webkit2gtk-sys", + "webkit6-sys", ] [[package]] -name = "webkit2gtk-sys" -version = "2.0.1" +name = "webkit6-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", + "gdk4-sys", "gio-sys", "glib-sys", "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", + "gtk4-sys", + "javascriptcore6-sys", "libc", - "pkg-config", "soup3-sys", "system-deps", ] [[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core", - "windows-implement", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" +name = "webpki-roots" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "webpki-roots 1.0.6", ] [[package]] -name = "webview2-com-sys" -version = "0.38.2" +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-core", + "rustls-pki-types", ] [[package]] @@ -4564,163 +3087,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4729,22 +3114,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link", ] [[package]] @@ -4753,206 +3123,69 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -4988,7 +3221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -4999,10 +3232,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5018,7 +3251,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5067,92 +3300,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] -name = "wry" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" -dependencies = [ - "base64", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dpi", - "dunce", - "gtk", - "html5ever", - "http", - "javascriptcore-rs", - "jni 0.21.1", - "kuchikiki", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle 0.6.2", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows", - "windows-core", - "windows-version", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" +name = "xattr" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "once_cell", - "pkg-config", + "rustix", ] [[package]] -name = "x11rb" -version = "0.13.2" +name = "xz2" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" dependencies = [ - "gethostname", - "rustix", - "x11rb-protocol", + "lzma-sys", ] -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - [[package]] name = "yoke" version = "0.8.1" @@ -5172,7 +3337,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -5193,7 +3358,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5213,7 +3378,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -5253,7 +3418,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml index d1d8977..003b28c 100644 --- a/greenfield/Cargo.toml +++ b/greenfield/Cargo.toml @@ -14,15 +14,20 @@ repository = "https://github.com/OneNoted/taskers" version = "0.1.0-alpha.1" [workspace.dependencies] +adw = { package = "libadwaita", version = "0.9.1" } anyhow = "1" +axum = { version = "0.8.4", features = ["ws"] } clap = { version = "4", features = ["derive"] } -dioxus = { version = "0.7.3", features = ["desktop"] } -dioxus-desktop = "0.7.3" -gtk = "0.18" +dioxus = { version = "0.7.3", default-features = false, features = ["hooks", "html", "macro", "signals"] } +dioxus-liveview = { version = "0.7.3", features = ["axum"] } +gtk = { package = "gtk4", version = "0.11.0" } indexmap = "2" parking_lot = "0.12" -tokio = { version = "1.50.0", features = ["sync"] } +tokio = { version = "1.50.0", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +webkit6 = { version = "0.6.1", features = ["v2_50"] } taskers-core = { path = "crates/taskers-core" } +taskers-domain = { path = "../crates/taskers-domain" } +taskers-ghostty = { path = "../crates/taskers-ghostty" } taskers-host = { path = "crates/taskers-host" } taskers-paths = { path = "../crates/taskers-paths" } taskers-runtime = { path = "../crates/taskers-runtime" } diff --git a/greenfield/README.md b/greenfield/README.md index 8eeb2b6..071b33c 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -1,6 +1,6 @@ # Taskers Greenfield Rewrite -This nested workspace is the bootstrap implementation for the Dioxus-based rewrite. +This nested workspace is the bootstrap implementation for the shared Dioxus shell rewrite. It is intentionally isolated from the legacy GTK/AppKit workspace at the repo root so the new architecture can evolve without disturbing the existing product. @@ -8,10 +8,10 @@ new architecture can evolve without disturbing the existing product. Current scope: - shared Rust core for workspace/pane/surface state -- Dioxus desktop shell with CSS-driven chrome -- Linux GTK portal runtime that mounts browser panes into the Dioxus window -- startup/runtime bootstrap that scrubs inherited terminal env and installs shell integration -- explicit terminal host fallback until the Ghostty GTK4 bridge is reconciled with the GTK3 Dioxus host +- shared Dioxus shell rendered through LiveView inside a GTK4/libadwaita host +- Linux GTK4 portal runtime that mounts browser and Ghostty pane bodies into the shared shell +- legacy Taskers-inspired shell styling instead of the old greenfield prototype chrome +- startup/runtime bootstrap that scrubs inherited terminal env, installs shell integration, and probes Ghostty runtime availability Run it from this workspace: @@ -28,7 +28,6 @@ XDG_DATA_HOME="$TMPDIR/data" \ XDG_STATE_HOME="$TMPDIR/state" \ XDG_CACHE_HOME="$TMPDIR/cache" \ TASKERS_RUNTIME_DIR="$TMPDIR/runtime" \ -TASKERS_GHOSTTY_RUNTIME_DIR="$TMPDIR/ghostty" \ cargo run -p taskers -- --smoke-script baseline --diagnostic-log stderr --quit-after-ms 5000 ``` @@ -38,19 +37,33 @@ Diagnostics can also be written to a file: cargo run -p taskers -- --smoke-script baseline --diagnostic-log /tmp/taskers-greenfield.log ``` +For a CI-like launch check that avoids the current interactive Ghostty abort on this machine, use the headless helper: + +```bash +greenfield/scripts/headless-smoke.sh \ + ./greenfield/target/debug/taskers \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 +``` + Baseline comparison checklist: - Startup logs runtime capability states instead of silently falling back. -- Initial window attach and snapshot sync are recorded. -- Browser pane creation is logged through the GTK portal runtime. +- Initial GTK4 host attach and snapshot sync are recorded. +- Browser pane creation is logged through the GTK4 portal runtime. - Browser title metadata is observed from the native surface. -- Terminal split records the current explicit fallback state instead of pretending native hosting works. +- Terminal pane creation is attempted through the Ghostty host path. - Final smoke output records pane counts, active pane, and exit timing. Manual interactive checklist: - Launch `cargo run -p taskers`. - Split one browser pane and one terminal pane. -- Resize the window and confirm the browser surface stays aligned with the shell chrome. +- Resize the window and confirm the native surfaces stay aligned with the shell chrome. - Move focus between panes and confirm the active-pane highlight follows. -- Confirm the terminal pane still reports the GTK3/GTK4 Ghostty fallback honestly. + +Current blocker: + +- On this machine, direct interactive launch can still abort inside the Ghostty bridge during startup before the shared shell becomes usable. This matches the current comparator result and appears to be a Ghostty runtime issue rather than a GTK3/GTK4 host mismatch. +- Headless smoke with `dbus-run-session + xvfb-run + LIBGL_ALWAYS_SOFTWARE=1` is the current reliable validation path until that Ghostty startup abort is fixed. diff --git a/greenfield/crates/taskers-host/Cargo.toml b/greenfield/crates/taskers-host/Cargo.toml index 13bd87f..f1acbbb 100644 --- a/greenfield/crates/taskers-host/Cargo.toml +++ b/greenfield/crates/taskers-host/Cargo.toml @@ -7,6 +7,8 @@ version.workspace = true [dependencies] anyhow.workspace = true -dioxus-desktop.workspace = true gtk.workspace = true taskers-core.workspace = true +taskers-domain.workspace = true +taskers-ghostty.workspace = true +webkit6.workspace = true diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 1a0bb84..372a68c 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,13 +1,23 @@ -use anyhow::Result; -use dioxus_desktop::tao::window::Window; +use anyhow::{Result, anyhow, bail}; +use gtk::{ + EventControllerFocus, Fixed, GestureClick, Overlay, Widget, glib, + prelude::*, +}; use std::{ - cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; -use taskers_core::{HostEvent, PaneId, RuntimeCapability, ShellSnapshot, SurfaceId}; +use taskers_core::{ + BrowserMountSpec, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceMountSpec, + SurfacePortalPlan, TerminalMountSpec, +}; +use taskers_domain::PaneKind; +use taskers_ghostty::{GhosttyHost, SurfaceDescriptor}; +use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; -type HostEventSink = Arc; +pub type HostEventSink = Rc; pub type DiagnosticsSink = Arc; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,7 +37,7 @@ pub struct DiagnosticRecord { pub revision: Option, pub category: DiagnosticCategory, pub message: String, - pub pane_id: Option, + pub pane_id: Option, pub surface_id: Option, } @@ -47,7 +57,7 @@ impl DiagnosticRecord { } } - pub fn with_pane(mut self, pane_id: PaneId) -> Self { + pub fn with_pane(mut self, pane_id: taskers_core::PaneId) -> Self { self.pane_id = Some(pane_id); self } @@ -78,407 +88,629 @@ impl DiagnosticRecord { } } -thread_local! { - static HOST_RUNTIME: RefCell = RefCell::new(HostRuntimeState::default()); -} - -#[derive(Default)] -struct HostRuntimeState { - window: Option>, - event_sink: Option, - diagnostics: Option, - #[cfg(target_os = "linux")] - linux: linux::LinuxHostRuntime, -} - -pub fn attach_window( - window: Arc, +pub struct TaskersHost { + root: Overlay, + surface_layer: Fixed, event_sink: HostEventSink, diagnostics: Option, -) -> Result<()> { - HOST_RUNTIME.with(|slot| { - let mut state = slot.borrow_mut(); - state.window = Some(window); - state.event_sink = Some(event_sink); - state.diagnostics = diagnostics.clone(); - emit_diagnostic( - diagnostics.as_ref(), - DiagnosticRecord::new(DiagnosticCategory::Window, None, "host window attached"), - ); - Ok(()) - }) + ghostty_host: Option, + browser_surfaces: HashMap, + terminal_surfaces: HashMap, } -pub fn sync_snapshot(snapshot: &ShellSnapshot) -> Result<()> { - HOST_RUNTIME.with(|slot| { - let mut state = slot.borrow_mut(); - let Some(window) = state.window.clone() else { - return Ok(()); - }; +impl TaskersHost { + pub fn new( + shell_widget: &impl IsA, + ghostty_host: Option, + event_sink: HostEventSink, + diagnostics: Option, + ) -> Self { + let root = Overlay::new(); + root.set_hexpand(true); + root.set_vexpand(true); + root.set_child(Some(shell_widget)); - #[cfg(target_os = "linux")] - { - if let Some(event_sink) = state.event_sink.clone() { - let diagnostics = state.diagnostics.clone(); - return state - .linux - .sync_snapshot(&window, snapshot, &event_sink, diagnostics.as_ref()); - } - } + let surface_layer = Fixed::new(); + surface_layer.set_hexpand(true); + surface_layer.set_vexpand(true); + root.add_overlay(&surface_layer); - let _ = snapshot; - Ok(()) - }) -} + emit_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new(DiagnosticCategory::Window, None, "created GTK4 host overlay"), + ); -pub fn terminal_host_capability() -> RuntimeCapability { - #[cfg(target_os = "linux")] - { - RuntimeCapability::Fallback { - message: "Linux Ghostty embedding is still blocked here: the existing bridge exposes GTK4 widgets, but the Dioxus desktop host is GTK3.".into(), + Self { + root, + surface_layer, + event_sink, + diagnostics, + ghostty_host, + browser_surfaces: HashMap::new(), + terminal_surfaces: HashMap::new(), } } - #[cfg(not(target_os = "linux"))] - { - RuntimeCapability::Unavailable { - message: "This greenfield checkpoint only wires the Linux host runtime.".into(), - } + pub fn widget(&self) -> Overlay { + self.root.clone() } -} -fn emit_diagnostic(sink: Option<&DiagnosticsSink>, record: DiagnosticRecord) { - if let Some(sink) = sink { - sink(record); + pub fn sync_snapshot(&mut self, snapshot: &ShellSnapshot) -> Result<()> { + emit_diagnostic( + self.diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(snapshot.revision), + format!("host sync start panes={}", snapshot.portal.panes.len()), + ), + ); + self.sync_browser_surfaces(&snapshot.portal, snapshot.revision)?; + self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision)?; + Ok(()) } -} -fn current_timestamp_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -#[cfg(target_os = "linux")] -mod linux { - use super::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, HostEventSink, emit_diagnostic}; - use anyhow::{Context, Result}; - use dioxus_desktop::{ - tao::{ - dpi::{LogicalPosition, LogicalSize}, - platform::unix::WindowExtUnix, - window::Window, - }, - wry::{PageLoadEvent, Rect, WebView, WebViewBuilder, WebViewBuilderExtUnix, WebViewExtUnix}, - }; - use gtk::{Fixed, Overlay, Widget, glib::Propagation, prelude::*}; - use std::{collections::{HashMap, HashSet}, sync::Arc}; - use taskers_core::{BrowserMountSpec, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceMountSpec}; - - #[derive(Default)] - pub struct LinuxHostRuntime { - portal_overlay: Option, - portal_fixed: Option, - browser_surfaces: HashMap, + pub fn tick(&self) { + if let Some(host) = &self.ghostty_host { + let _ = host.tick(); + } } - impl LinuxHostRuntime { - pub fn sync_snapshot( - &mut self, - window: &Arc, - snapshot: &ShellSnapshot, - event_sink: &HostEventSink, - diagnostics: Option<&DiagnosticsSink>, - ) -> Result<()> { - emit_diagnostic( - diagnostics, - DiagnosticRecord::new( - DiagnosticCategory::Sync, - Some(snapshot.revision), - format!("host sync start panes={}", snapshot.portal.panes.len()), - ), - ); - - let Some(fixed) = self.ensure_portal_layer(window, diagnostics)? else { - return Ok(()); - }; - - let desired: Vec<_> = snapshot - .portal - .panes - .iter() - .filter(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(_))) - .cloned() - .collect(); - - let diff = browser_surface_diff( - self.browser_surfaces.keys().copied(), - desired.iter().map(|plan| plan.surface_id), - ); + fn sync_browser_surfaces( + &mut self, + portal: &SurfacePortalPlan, + revision: u64, + ) -> Result<()> { + let desired = browser_plans(portal); + let desired_ids = desired + .iter() + .map(|plan| plan.surface_id) + .collect::>(); + + let stale = self + .browser_surfaces + .keys() + .copied() + .filter(|surface_id| !desired_ids.contains(surface_id)) + .collect::>(); - for surface_id in diff.removed { - self.browser_surfaces.remove(&surface_id); + for surface_id in stale { + if let Some(surface) = self.browser_surfaces.remove(&surface_id) { + detach_from_fixed(&self.surface_layer, surface.webview.upcast_ref()); emit_diagnostic( - diagnostics, + self.diagnostics.as_ref(), DiagnosticRecord::new( DiagnosticCategory::SurfaceLifecycle, - Some(snapshot.revision), + Some(revision), "browser surface removed", ) .with_surface(surface_id), ); } + } - for plan in desired { - match self.browser_surfaces.get_mut(&plan.surface_id) { - Some(surface) => surface.sync(&plan, snapshot.revision, diagnostics)?, - None => { - let surface = - BrowserSurface::new(&fixed, &plan, snapshot.revision, event_sink, diagnostics)?; - self.browser_surfaces.insert(plan.surface_id, surface); - } + for plan in desired { + match self.browser_surfaces.get_mut(&plan.surface_id) { + Some(surface) => { + surface.sync(&self.surface_layer, &plan, revision, self.diagnostics.as_ref())? + } + None => { + let surface = BrowserSurface::new( + &self.surface_layer, + &plan, + revision, + self.event_sink.clone(), + self.diagnostics.clone(), + )?; + self.browser_surfaces.insert(plan.surface_id, surface); } } - - Ok(()) } - fn ensure_portal_layer( - &mut self, - window: &Arc, - diagnostics: Option<&DiagnosticsSink>, - ) -> Result> { - if let Some(fixed) = &self.portal_fixed { - return Ok(Some(fixed.clone())); + Ok(()) + } + + fn sync_terminal_surfaces( + &mut self, + portal: &SurfacePortalPlan, + revision: u64, + ) -> Result<()> { + let desired = terminal_plans(portal); + let desired_ids = desired + .iter() + .map(|plan| plan.surface_id) + .collect::>(); + + let stale = self + .terminal_surfaces + .keys() + .copied() + .filter(|surface_id| !desired_ids.contains(surface_id)) + .collect::>(); + + for surface_id in stale { + if let Some(surface) = self.terminal_surfaces.remove(&surface_id) { + detach_from_fixed(&self.surface_layer, &surface.widget); + emit_diagnostic( + self.diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "terminal surface removed", + ) + .with_surface(surface_id), + ); } + } - let Some(vbox) = window.default_vbox() else { - return Ok(None); - }; - let children = vbox.children(); - let Some(webview_child) = children.into_iter().next_back() else { - return Ok(None); - }; - - let overlay = Overlay::new(); - overlay.set_hexpand(true); - overlay.set_vexpand(true); - - let fixed = Fixed::new(); - fixed.set_hexpand(true); - fixed.set_vexpand(true); - - vbox.remove(&webview_child); - overlay.add(&webview_child); - overlay.add_overlay(&fixed); - vbox.pack_start(&overlay, true, true, 0); - overlay.show_all(); - - self.portal_overlay = Some(overlay); - self.portal_fixed = Some(fixed.clone()); - emit_diagnostic( - diagnostics, - DiagnosticRecord::new( - DiagnosticCategory::Window, - None, - "created linux portal overlay layer", + let Some(host) = self.ghostty_host.as_ref() else { + return Ok(()); + }; + + for plan in desired { + match self.terminal_surfaces.get_mut(&plan.surface_id) { + Some(surface) => surface.sync( + &self.surface_layer, + plan.frame, + plan.active, + revision, + host, + self.diagnostics.as_ref(), ), - ); - Ok(Some(fixed)) + None => { + let surface = TerminalSurface::new( + &self.surface_layer, + &plan, + revision, + self.event_sink.clone(), + self.diagnostics.clone(), + host, + )?; + self.terminal_surfaces.insert(plan.surface_id, surface); + } + } } - } - struct BrowserSurface { - webview: WebView, - url: String, + Ok(()) } +} + +struct BrowserSurface { + webview: WebView, + url: String, +} - impl BrowserSurface { - fn new( - fixed: &Fixed, - plan: &PortalSurfacePlan, - revision: u64, - event_sink: &HostEventSink, - diagnostics: Option<&DiagnosticsSink>, - ) -> Result { - let BrowserMountSpec { url } = browser_spec(plan)?.clone(); - let surface_id = plan.surface_id; - let pane_id = plan.pane_id; - - let title_sink = event_sink.clone(); - let title_diag = diagnostics.cloned(); - let url_sink = event_sink.clone(); - let url_diag = diagnostics.cloned(); - let webview = WebViewBuilder::new() - .with_url(&url) - .with_bounds(rect_from_frame(plan.frame)) - .with_document_title_changed_handler(move |title| { - emit_diagnostic( - title_diag.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::BrowserMetadata, - None, - format!("browser title observed: {title}"), - ) - .with_pane(pane_id) - .with_surface(surface_id), - ); - (title_sink)(HostEvent::SurfaceTitleChanged { surface_id, title }); - }) - .with_on_page_load_handler(move |event, url| { - if matches!(event, PageLoadEvent::Finished) { - emit_diagnostic( - url_diag.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::BrowserMetadata, - None, - format!("browser url observed: {url}"), - ) - .with_pane(pane_id) - .with_surface(surface_id), - ); - (url_sink)(HostEvent::SurfaceUrlChanged { surface_id, url }); - } - }) - .build_gtk(fixed) - .with_context(|| format!("failed to create browser surface {}", plan.surface_id.0))?; - - let widget = webview.webview(); - let focus_sink = event_sink.clone(); - let focus_diag = diagnostics.cloned(); - widget.connect_focus_in_event(move |_, _| { +impl BrowserSurface { + fn new( + fixed: &Fixed, + plan: &PortalSurfacePlan, + revision: u64, + event_sink: HostEventSink, + diagnostics: Option, + ) -> Result { + let BrowserMountSpec { url } = browser_spec(plan)?.clone(); + + let settings = WebKitSettings::builder() + .enable_back_forward_navigation_gestures(true) + .enable_developer_extras(true) + .build(); + let webview = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .settings(&settings) + .build(); + webview.load_uri(&url); + position_widget(fixed, webview.upcast_ref(), plan.frame); + + let pane_id = plan.pane_id; + let surface_id = plan.surface_id; + let focus_sink = event_sink.clone(); + let focus_diagnostics = diagnostics.clone(); + let click = GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + emit_diagnostic( + focus_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "browser click focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (focus_sink)(HostEvent::PaneFocused { pane_id }); + }); + webview.add_controller(click); + + let pane_id = plan.pane_id; + let surface_id = plan.surface_id; + let focus_sink = event_sink.clone(); + let focus_diagnostics = diagnostics.clone(); + let focus = EventControllerFocus::new(); + focus.connect_enter(move |_| { + emit_diagnostic( + focus_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "browser focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (focus_sink)(HostEvent::PaneFocused { pane_id }); + }); + webview.add_controller(focus); + + let surface_id = plan.surface_id; + let title_sink = event_sink.clone(); + let title_diagnostics = diagnostics.clone(); + webview.connect_title_notify(move |web_view| { + if let Some(title) = web_view.title() { emit_diagnostic( - focus_diag.as_ref(), + title_diagnostics.as_ref(), DiagnosticRecord::new( - DiagnosticCategory::HostEvent, + DiagnosticCategory::BrowserMetadata, None, - "browser focus event received", + format!("browser title observed: {title}"), ) - .with_pane(pane_id) .with_surface(surface_id), ); - (focus_sink)(HostEvent::PaneFocused { pane_id }); - Propagation::Proceed - }); + (title_sink)(HostEvent::SurfaceTitleChanged { + surface_id, + title: title.to_string(), + }); + } + }); - if plan.active { - let _ = webview.focus(); + let surface_id = plan.surface_id; + let url_sink = event_sink; + let url_diagnostics = diagnostics.clone(); + webview.connect_uri_notify(move |web_view| { + if let Some(url) = web_view.uri() { + emit_diagnostic( + url_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::BrowserMetadata, + None, + format!("browser url observed: {url}"), + ) + .with_surface(surface_id), + ); + (url_sink)(HostEvent::SurfaceUrlChanged { + surface_id, + url: url.to_string(), + }); } + }); - emit_diagnostic( - diagnostics, - DiagnosticRecord::new( - DiagnosticCategory::SurfaceLifecycle, - Some(revision), - "browser surface created", - ) - .with_pane(plan.pane_id) - .with_surface(plan.surface_id), - ); + if plan.active { + webview.grab_focus(); + } + + emit_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "browser surface created", + ) + .with_pane(plan.pane_id) + .with_surface(plan.surface_id), + ); - Ok(Self { webview, url }) + Ok(Self { webview, url }) + } + + fn sync( + &mut self, + fixed: &Fixed, + plan: &PortalSurfacePlan, + revision: u64, + diagnostics: Option<&DiagnosticsSink>, + ) -> Result<()> { + position_widget(fixed, self.webview.upcast_ref(), plan.frame); + + let BrowserMountSpec { url } = browser_spec(plan)?; + if self.url != *url { + self.webview.load_uri(url); + self.url = url.clone(); + } + if plan.active { + self.webview.grab_focus(); } - fn sync( - &mut self, - plan: &PortalSurfacePlan, - revision: u64, - diagnostics: Option<&DiagnosticsSink>, - ) -> Result<()> { - self.webview - .set_bounds(rect_from_frame(plan.frame)) - .context("failed to update browser bounds")?; - - let BrowserMountSpec { url } = browser_spec(plan)?; - if self.url != *url { - self.webview - .load_url(url) - .with_context(|| format!("failed to navigate browser surface {}", plan.surface_id.0))?; - self.url = url.clone(); - } + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "browser surface updated", + ) + .with_pane(plan.pane_id) + .with_surface(plan.surface_id), + ); - if plan.active { - let _ = self.webview.focus(); - } + Ok(()) + } +} + +struct TerminalSurface { + widget: Widget, +} +impl TerminalSurface { + fn new( + fixed: &Fixed, + plan: &PortalSurfacePlan, + revision: u64, + event_sink: HostEventSink, + diagnostics: Option, + host: &GhosttyHost, + ) -> Result { + let spec = terminal_spec(plan)?.clone(); + let descriptor = surface_descriptor_from(&spec); + let widget = host + .create_surface(&descriptor) + .map_err(|error| anyhow!(error.to_string()))?; + widget.set_hexpand(true); + widget.set_vexpand(true); + widget.set_focusable(true); + position_widget(fixed, &widget, plan.frame); + + connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); + + if plan.active { + let _ = host.focus_surface(&widget); + } + + emit_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "terminal surface created", + ) + .with_pane(plan.pane_id) + .with_surface(plan.surface_id), + ); + + Ok(Self { widget }) + } + + fn sync( + &mut self, + fixed: &Fixed, + frame: taskers_core::Frame, + active: bool, + revision: u64, + host: &GhosttyHost, + diagnostics: Option<&DiagnosticsSink>, + ) { + position_widget(fixed, &self.widget, frame); + if active { + let _ = host.focus_surface(&self.widget); + } + + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::SurfaceLifecycle, + Some(revision), + "terminal surface updated", + ), + ); + } +} + +fn connect_ghostty_widget( + host: &GhosttyHost, + widget: &Widget, + plan: &PortalSurfacePlan, + event_sink: HostEventSink, + diagnostics: Option, +) { + let _ = host; + + let pane_id = plan.pane_id; + let surface_id = plan.surface_id; + let focus_sink = event_sink.clone(); + let focus_diagnostics = diagnostics.clone(); + let click = GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + emit_diagnostic( + focus_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "terminal click focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (focus_sink)(HostEvent::PaneFocused { pane_id }); + }); + widget.add_controller(click); + + let pane_id = plan.pane_id; + let surface_id = plan.surface_id; + let focus_sink = event_sink.clone(); + let focus_diagnostics = diagnostics.clone(); + let focus = EventControllerFocus::new(); + focus.connect_enter(move |_| { + emit_diagnostic( + focus_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "terminal focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (focus_sink)(HostEvent::PaneFocused { pane_id }); + }); + widget.add_controller(focus); + + let surface_id = plan.surface_id; + let title_sink = event_sink.clone(); + let title_diagnostics = diagnostics.clone(); + widget.connect_notify_local(Some("title"), move |widget, _| { + if let Some(title) = widget.property::>("title") { emit_diagnostic( - diagnostics, + title_diagnostics.as_ref(), DiagnosticRecord::new( - DiagnosticCategory::SurfaceLifecycle, - Some(revision), - "browser surface updated", + DiagnosticCategory::HostEvent, + None, + format!("terminal title observed: {title}"), ) - .with_pane(plan.pane_id) - .with_surface(plan.surface_id), + .with_surface(surface_id), ); + (title_sink)(HostEvent::SurfaceTitleChanged { + surface_id, + title: title.to_string(), + }); + } + }); - Ok(()) + let surface_id = plan.surface_id; + let cwd_sink = event_sink.clone(); + let cwd_diagnostics = diagnostics.clone(); + widget.connect_notify_local(Some("pwd"), move |widget, _| { + if let Some(cwd) = widget.property::>("pwd") { + emit_diagnostic( + cwd_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + format!("terminal cwd observed: {cwd}"), + ) + .with_surface(surface_id), + ); + (cwd_sink)(HostEvent::SurfaceCwdChanged { + surface_id, + cwd: cwd.to_string(), + }); } - } + }); + + let pane_id = plan.pane_id; + let surface_id = plan.surface_id; + let exit_sink = event_sink; + let exit_diagnostics = diagnostics; + widget.connect_notify_local(Some("child-exited"), move |widget, _| { + if widget.property::("child-exited") { + emit_diagnostic( + exit_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "terminal child exited", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (exit_sink)(HostEvent::SurfaceClosed { + pane_id, + surface_id, + }); + } + }); +} - #[derive(Debug, PartialEq, Eq)] - struct BrowserSurfaceDiff { - removed: Vec, +fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { + SurfaceDescriptor { + cols: spec.cols, + rows: spec.rows, + kind: PaneKind::Terminal, + cwd: spec.cwd.clone(), + title: None, + url: None, + // The current Ghostty bridge is more stable when it controls shell + // selection itself, so keep command overrides empty until that path is + // proven across hosts. + command_argv: Vec::new(), + env: spec.env.clone(), } +} - fn browser_surface_diff( - existing: impl IntoIterator, - desired: impl IntoIterator, - ) -> BrowserSurfaceDiff { - let desired = desired.into_iter().collect::>(); - let removed = existing - .into_iter() - .filter(|surface_id| !desired.contains(surface_id)) - .collect::>(); +fn browser_spec(plan: &PortalSurfacePlan) -> Result<&BrowserMountSpec> { + match &plan.mount { + SurfaceMountSpec::Browser(spec) => Ok(spec), + SurfaceMountSpec::Terminal(_) => bail!("surface {} is not a browser", plan.surface_id), + } +} - BrowserSurfaceDiff { removed } +fn terminal_spec(plan: &PortalSurfacePlan) -> Result<&TerminalMountSpec> { + match &plan.mount { + SurfaceMountSpec::Terminal(spec) => Ok(spec), + SurfaceMountSpec::Browser(_) => bail!("surface {} is not a terminal", plan.surface_id), } +} - fn browser_spec(plan: &PortalSurfacePlan) -> Result<&BrowserMountSpec> { - match &plan.mount { - SurfaceMountSpec::Browser(spec) => Ok(spec), - SurfaceMountSpec::Terminal(_) => anyhow::bail!( - "surface {} is not a browser mount", - plan.surface_id.0 - ), - } +fn position_widget(fixed: &Fixed, widget: &Widget, frame: taskers_core::Frame) { + widget.set_size_request(frame.width.max(1), frame.height.max(1)); + if widget.parent().is_some() { + fixed.move_(widget, f64::from(frame.x), f64::from(frame.y)); + } else { + fixed.put(widget, f64::from(frame.x), f64::from(frame.y)); } +} - fn rect_from_frame(frame: taskers_core::Frame) -> Rect { - Rect { - position: LogicalPosition::new(frame.x, frame.y).into(), - size: LogicalSize::new(frame.width.max(1), frame.height.max(1)).into(), - } +fn detach_from_fixed(fixed: &Fixed, widget: &Widget) { + if widget.parent().is_some() { + fixed.remove(widget); } +} + +pub fn browser_plans(portal: &SurfacePortalPlan) -> Vec { + portal + .panes + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) + .cloned() + .collect() +} - #[allow(dead_code)] - fn _widget_debug_name(widget: &Widget) -> &'static str { - widget.type_().name() +pub fn terminal_plans(portal: &SurfacePortalPlan) -> Vec { + portal + .panes + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) + .cloned() + .collect() +} + +fn emit_diagnostic(sink: Option<&DiagnosticsSink>, record: DiagnosticRecord) { + if let Some(sink) = sink { + sink(record); } +} - #[cfg(test)] - mod tests { - use super::{BrowserSurfaceDiff, browser_surface_diff}; - use taskers_core::SurfaceId; +fn current_timestamp_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} - #[test] - fn browser_surface_diff_returns_removed_ids() { - let diff = - browser_surface_diff([SurfaceId(1), SurfaceId(2), SurfaceId(3)], [SurfaceId(2)]); +#[cfg(test)] +mod tests { + use super::{browser_plans, terminal_plans}; + use taskers_core::{BootstrapModel, SharedCore, SurfaceMountSpec}; - assert_eq!( - diff, - BrowserSurfaceDiff { - removed: vec![SurfaceId(1), SurfaceId(3)], - } - ); - } + #[test] + fn partitions_portal_plans_by_surface_kind() { + let core = SharedCore::bootstrap(BootstrapModel::default()); + core.split_with_browser(); + let snapshot = core.snapshot(); - #[test] - fn browser_surface_diff_keeps_matching_ids() { - let diff = browser_surface_diff([SurfaceId(7)], [SurfaceId(7)]); - assert_eq!(diff, BrowserSurfaceDiff { removed: vec![] }); - } + let browsers = browser_plans(&snapshot.portal); + let terminals = terminal_plans(&snapshot.portal); + + assert_eq!(browsers.len(), 1); + assert_eq!(terminals.len(), 1); + assert!(matches!(browsers[0].mount, SurfaceMountSpec::Browser(_))); + assert!(matches!(terminals[0].mount, SurfaceMountSpec::Terminal(_))); } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index c130779..45061ba 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -10,7 +10,10 @@ fn app_css() -> String { theme::generate_css(&theme::default_dark()) } -pub fn app() -> Element { +#[component] +pub fn TaskersShell(core: SharedCore) -> Element { + use_context_provider(move || core.clone()); + let core = consume_context::(); let revision = use_signal(|| core.revision()); diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index 31afd16..945332a 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -10,13 +10,18 @@ name = "taskers" path = "src/main.rs" [dependencies] +adw.workspace = true +anyhow.workspace = true +axum.workspace = true clap.workspace = true dioxus.workspace = true -dioxus-desktop.workspace = true +dioxus-liveview.workspace = true gtk.workspace = true taskers-core.workspace = true +taskers-ghostty.workspace = true taskers-host.workspace = true taskers-paths.workspace = true taskers-runtime.workspace = true taskers-shell.workspace = true tokio.workspace = true +webkit6.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 5ed408f..0719f36 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -1,18 +1,16 @@ +use adw::prelude::*; +use anyhow::{Context, Result}; +use axum::{Router, extract::ws::WebSocketUpgrade, response::Html, routing::get}; use clap::{Parser, ValueEnum}; -use dioxus::LaunchBuilder; -use dioxus_desktop::{ - Config, WindowBuilder, - tao::{ - dpi::LogicalSize, - event::{Event, WindowEvent}, - }, -}; use gtk::glib; use std::{ + cell::{Cell, RefCell}, collections::BTreeMap, fs::File, io::{self, Write}, + net::TcpListener, path::PathBuf, + rc::Rc, sync::{Arc, Mutex}, thread, time::{Duration, Instant}, @@ -21,13 +19,16 @@ use taskers_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, SurfaceKind, TerminalDefaults, }; -use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink}; -use taskers_paths::default_ghostty_runtime_dir; +use taskers_ghostty::{GhosttyHost, ensure_runtime_installed}; +use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; +use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; + +const APP_ID: &str = "dev.onenoted.Taskers.Greenfield"; #[derive(Debug, Clone, Parser)] #[command(name = "taskers")] -#[command(about = "Greenfield Taskers desktop baseline")] +#[command(about = "Greenfield Taskers unified shell")] struct Cli { #[arg(long, value_enum)] smoke_script: Option, @@ -42,123 +43,160 @@ enum SmokeScript { Baseline, } -fn main() { +struct BootstrapContext { + core: SharedCore, + ghostty_host: Option, + startup_notes: Vec, +} + +fn main() -> glib::ExitCode { let cli = Cli::parse(); - scrub_inherited_terminal_env(); + let app = adw::Application::builder().application_id(APP_ID).build(); + let hold_guard = Rc::new(RefCell::new(None)); + let hold_guard_for_startup = hold_guard.clone(); + let cli_for_startup = cli.clone(); + app.connect_startup(move |app| { + *hold_guard_for_startup.borrow_mut() = Some(app.hold()); + build_ui(app, hold_guard_for_startup.clone(), cli_for_startup.clone()); + }); + app.connect_activate(|app| { + if let Some(window) = app.active_window() { + window.present(); + } + }); + app.run_with_args::<&str>(&[]) +} +fn build_ui( + app: &adw::Application, + hold_guard: Rc>>, + cli: Cli, +) { + if let Err(error) = build_ui_result(app, hold_guard, cli) { + eprintln!("failed to launch greenfield Taskers host: {error:?}"); + } +} + +fn build_ui_result( + app: &adw::Application, + hold_guard: Rc>>, + cli: Cli, +) -> Result<()> { let diagnostics = DiagnosticsWriter::from_cli(&cli); + let bootstrap = bootstrap_runtime(diagnostics.as_ref()); + log_runtime_status(diagnostics.as_ref(), &bootstrap.core.snapshot().runtime_status); + + let shell_url = launch_liveview_server(bootstrap.core.clone())?; + let settings = WebKitSettings::builder() + .enable_developer_extras(true) + .build(); + let shell_view = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .settings(&settings) + .build(); + shell_view.load_uri(&shell_url); + + let core = bootstrap.core.clone(); + let event_sink = Rc::new({ + let core = core.clone(); + move |event| core.apply_host_event(event) + }); + let diagnostics_sink = diagnostics.as_ref().map(DiagnosticsWriter::sink); + let host = Rc::new(RefCell::new(TaskersHost::new( + &shell_view, + bootstrap.ghostty_host, + event_sink, + diagnostics_sink, + ))); + + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Taskers") + .default_width(1440) + .default_height(900) + .build(); + window.connect_close_request(move |_| { + drop(hold_guard.borrow_mut().take()); + glib::Propagation::Proceed + }); + let host_widget = host.borrow().widget(); + window.set_content(Some(&host_widget)); + + for note in bootstrap.startup_notes { + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new(DiagnosticCategory::Startup, None, note.clone()), + ); + eprintln!("{note}"); + } + let smoke_script = cli.smoke_script; - let smoke_quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); + let quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); + let last_revision = Rc::new(Cell::new(0_u64)); + let last_size = Rc::new(Cell::new((0_i32, 0_i32))); + let tick_window = window.clone(); + let tick_core = core.clone(); + let tick_host = host.clone(); + let tick_revision = last_revision.clone(); + let tick_size = last_size.clone(); + let tick_diagnostics = diagnostics.clone(); + glib::timeout_add_local(Duration::from_millis(16), move || { + sync_window( + &tick_window, + &tick_core, + &tick_host, + &tick_revision, + &tick_size, + tick_diagnostics.as_ref(), + ); + glib::ControlFlow::Continue + }); - let (terminal_defaults, runtime_status) = bootstrap_runtime(); - log_runtime_status(diagnostics.as_ref(), &runtime_status); - let core = SharedCore::bootstrap(BootstrapModel { - runtime_status, - terminal_defaults, + window.present(); + + let initial_window = window.clone(); + let initial_core = core.clone(); + let initial_host = host.clone(); + let initial_revision = last_revision.clone(); + let initial_size = last_size.clone(); + let initial_diagnostics = diagnostics.clone(); + glib::timeout_add_local_once(Duration::from_millis(80), move || { + sync_window( + &initial_window, + &initial_core, + &initial_host, + &initial_revision, + &initial_size, + initial_diagnostics.as_ref(), + ); }); - let core_for_window = core.clone(); - let core_for_events = core.clone(); - let diagnostics_for_window = diagnostics.clone(); - let diagnostics_for_events = diagnostics.clone(); - spawn_revision_sync_relay(core.clone(), diagnostics.clone()); - - LaunchBuilder::desktop() - .with_context(core.clone()) - .with_cfg( - Config::new() - .with_window( - WindowBuilder::new() - .with_title("Taskers") - .with_inner_size(LogicalSize::new(1440.0, 900.0)), - ) - .with_on_window(move |window, _dom| { - let size = window.inner_size(); - core_for_window - .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); - - let event_sink = Arc::new({ - let core = core_for_window.clone(); - move |event| { - core.apply_host_event(event); - } - }); - let diagnostics_sink = diagnostics_for_window.as_ref().map(DiagnosticsWriter::sink); - - if let Err(error) = - taskers_host::attach_window(window.clone(), event_sink, diagnostics_sink) - { - log_diagnostic( - diagnostics_for_window.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Window, - None, - format!("host attach failed: {error}"), - ), - ); - eprintln!("taskers host attach failed: {error}"); - } - - let snapshot = core_for_window.snapshot(); - log_diagnostic( - diagnostics_for_window.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Window, - Some(snapshot.revision), - format!( - "initial snapshot panes={} active={}", - snapshot.portal.panes.len(), - snapshot.active_pane - ), - ), - ); - if let Err(error) = taskers_host::sync_snapshot(&snapshot) { - log_diagnostic( - diagnostics_for_window.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Sync, - Some(snapshot.revision), - format!("initial sync failed: {error}"), - ), - ); - eprintln!("taskers host initial sync failed: {error}"); - } - - if let Some(script) = smoke_script { - spawn_smoke_script( - script, - core_for_window.clone(), - diagnostics_for_window.clone(), - smoke_quit_after_ms, - ); - } - }) - .with_custom_event_handler(move |event, _target| { - if let Event::WindowEvent { - event: WindowEvent::Resized(size), - .. - } = event - { - core_for_events - .set_window_size(PixelSize::new(size.width as i32, size.height as i32)); - log_diagnostic( - diagnostics_for_events.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Window, - Some(core_for_events.revision()), - format!( - "window resized width={} height={}", - size.width, size.height - ), - ), - ); - } - }), - ) - .launch(taskers_shell::app); + + if let Some(script) = smoke_script { + spawn_smoke_script(script, core, diagnostics, quit_after_ms); + } + + Ok(()) } -fn bootstrap_runtime() -> (TerminalDefaults, RuntimeStatus) { - let ghostty_runtime = probe_ghostty_runtime(); +fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContext { + scrub_inherited_terminal_env(); + + let mut startup_notes = Vec::new(); + let ghostty_runtime = match ensure_runtime_installed() { + Ok(Some(runtime)) => { + startup_notes.push(format!( + "Installed Ghostty runtime assets to {}", + runtime.runtime_dir.display() + )); + RuntimeCapability::Ready + } + Ok(None) => RuntimeCapability::Ready, + Err(error) => RuntimeCapability::Fallback { + message: format!("Ghostty runtime bootstrap unavailable: {error}"), + }, + }; let (shell_launch, shell_integration) = match install_shell_integration(None) { Ok(integration) => (integration.launch_spec(), RuntimeCapability::Ready), @@ -170,36 +208,48 @@ fn bootstrap_runtime() -> (TerminalDefaults, RuntimeStatus) { ), }; - ( - terminal_defaults_from(shell_launch), - RuntimeStatus { - ghostty_runtime, - shell_integration, - terminal_host: taskers_host::terminal_host_capability(), - }, - ) -} + let (ghostty_host, terminal_host, terminal_note) = match GhosttyHost::new() { + Ok(host) => { + let _ = host.tick(); + (Some(host), RuntimeCapability::Ready, None) + } + Err(error) => ( + None, + RuntimeCapability::Fallback { + message: format!("Ghostty host unavailable: {error}"), + }, + Some(format!("Ghostty host unavailable: {error}")), + ), + }; -fn probe_ghostty_runtime() -> RuntimeCapability { - let runtime_dir = default_ghostty_runtime_dir(); - let bridge = runtime_dir.join("lib").join("libtaskers_ghostty_bridge.so"); + if let Some(note) = terminal_note { + startup_notes.push(note); + } - if bridge.exists() { - RuntimeCapability::Ready - } else { - RuntimeCapability::Fallback { - message: format!( - "Ghostty runtime bootstrap is deferred in this checkpoint to avoid mixing GTK3 and GTK4 in one process. Expected runtime asset: {}", - bridge.display() - ), - } + let runtime_status = RuntimeStatus { + ghostty_runtime, + shell_integration, + terminal_host, + }; + let terminal_defaults = terminal_defaults_from(shell_launch); + let core = SharedCore::bootstrap(BootstrapModel { + runtime_status, + terminal_defaults, + }); + + log_runtime_status(diagnostics, &core.snapshot().runtime_status); + + BootstrapContext { + core, + ghostty_host, + startup_notes, } } fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { - let mut argv = Vec::with_capacity(shell_launch.args.len() + 1); - argv.push(shell_launch.program.display().to_string()); - argv.extend(shell_launch.args); + let mut command_argv = Vec::with_capacity(shell_launch.args.len() + 1); + command_argv.push(shell_launch.program.display().to_string()); + command_argv.extend(shell_launch.args); let mut env = BTreeMap::new(); env.extend(shell_launch.env); @@ -207,57 +257,128 @@ fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { TerminalDefaults { cols: 120, rows: 40, - command_argv: argv, + command_argv, env, } } -fn spawn_revision_sync_relay(core: SharedCore, diagnostics: Option) { - let mut revisions = core.subscribe_revision_events(); - thread::spawn(move || loop { - match revisions.blocking_recv() { - Ok(revision) => { - let snapshot = core.snapshot(); - let diagnostics = diagnostics.clone(); - glib::MainContext::default().invoke(move || { - log_diagnostic( - diagnostics.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Sync, - Some(revision), - format!( - "syncing snapshot panes={} active={}", - snapshot.portal.panes.len(), - snapshot.active_pane - ), - ), - ); - if let Err(error) = taskers_host::sync_snapshot(&snapshot) { - log_diagnostic( - diagnostics.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Sync, - Some(revision), - format!("snapshot sync failed: {error}"), - ), - ); - eprintln!("taskers host sync failed for revision {revision}: {error}"); - } - }); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - log_diagnostic( - diagnostics.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::Sync, - None, - format!("revision relay lagged; skipped {skipped} events"), - ), +fn sync_window( + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Cell, + last_size: &Cell<(i32, i32)>, + diagnostics: Option<&DiagnosticsWriter>, +) { + let size = PixelSize::new(window.width().max(1), window.height().max(1)); + if last_size.get() != (size.width, size.height) { + core.set_window_size(size); + last_size.set((size.width, size.height)); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Window, + Some(core.revision()), + format!("window resized width={} height={}", size.width, size.height), + ), + ); + } + + let revision = core.revision(); + if last_revision.get() != revision { + let snapshot = core.snapshot(); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!( + "syncing snapshot panes={} active={}", + snapshot.portal.panes.len(), + snapshot.active_pane + ), + ), + ); + if let Err(error) = host.borrow_mut().sync_snapshot(&snapshot) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!("snapshot sync failed: {error}"), + ), + ); + eprintln!("taskers host sync failed for revision {revision}: {error}"); + } + last_revision.set(revision); + } + + host.borrow().tick(); +} + +fn launch_liveview_server(core: SharedCore) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").context("failed to bind loopback port")?; + listener + .set_nonblocking(true) + .context("failed to set loopback listener nonblocking")?; + let addr = listener.local_addr().context("failed to read loopback addr")?; + let url = format!("http://{addr}/"); + + thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).expect("tokio listener"); + let view = dioxus_liveview::LiveViewPool::new(); + let router = Router::new() + .route( + "/", + get(|| async move { + Html(format!( + r#" + + + + + Taskers + + +
+ + {} +"#, + dioxus_liveview::interpreter_glue("/ws") + )) + }), + ) + .route( + "/ws", + get(move |ws: WebSocketUpgrade| { + let view = view.clone(); + let core = core.clone(); + async move { + ws.on_upgrade(move |socket| async move { + let _ = view + .launch_with_props( + dioxus_liveview::axum_socket(socket), + taskers_shell::TaskersShell, + taskers_shell::TaskersShellProps { core }, + ) + .await; + }) + } + }), ); + + if let Err(error) = axum::serve(listener, router.into_make_service()).await { + eprintln!("liveview server failed: {error}"); } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } + }); }); + + Ok(url) } fn spawn_smoke_script( @@ -293,7 +414,11 @@ fn spawn_smoke_script( fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) { log_diagnostic( diagnostics, - DiagnosticRecord::new(DiagnosticCategory::Smoke, Some(core.revision()), "baseline smoke started"), + DiagnosticRecord::new( + DiagnosticCategory::Smoke, + Some(core.revision()), + "baseline smoke started", + ), ); thread::sleep(Duration::from_millis(300)); @@ -368,8 +493,7 @@ fn wait_for_browser_title(core: &SharedCore, timeout: Duration) -> Option [args...]" >&2 + exit 2 +fi + +timeout "${TIMEOUT_SECONDS:-8}" \ + dbus-run-session -- \ + env LIBGL_ALWAYS_SOFTWARE=1 \ + xvfb-run -a \ + "$@" From a2b5dbf81a33998d1ae06669e709532aca542632 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 11:32:30 +0100 Subject: [PATCH 17/63] refactor: add ghostty host options and launch context --- Cargo.lock | 1 + crates/taskers-app/src/main.rs | 8 +- crates/taskers-core/src/app_state.rs | 47 ++++++++-- crates/taskers-ghostty/Cargo.toml | 1 + crates/taskers-ghostty/src/backend.rs | 45 ++++++++- crates/taskers-ghostty/src/bridge.rs | 84 ++++++++++++----- crates/taskers-ghostty/src/lib.rs | 2 +- greenfield/Cargo.lock | 1 + greenfield/crates/taskers/src/main.rs | 5 +- .../ghostty/include/taskers_ghostty_bridge.h | 12 ++- vendor/ghostty/src/taskers_bridge.zig | 91 ++++++++++++++----- 11 files changed, 232 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d6690e..631e1c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,6 +1634,7 @@ dependencies = [ "tar", "taskers-domain", "taskers-paths", + "taskers-runtime", "tempfile", "thiserror 2.0.18", "ureq", diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index a89280d..251d873 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -40,7 +40,7 @@ use taskers_domain::{ WorkspaceViewport, WorkspaceWindowId, }; use taskers_ghostty::{ - BackendChoice, BackendProbe, DefaultBackend, GhosttyHost, TerminalBackend, + BackendChoice, BackendProbe, DefaultBackend, GhosttyHost, GhosttyHostOptions, TerminalBackend, ensure_runtime_installed, }; use taskers_runtime::{ @@ -2096,7 +2096,7 @@ fn main() -> gtk::glib::ExitCode { .env .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); let (backend_choice, _backend_note, ghostty_host, backend_toast) = - initialize_terminal_backend(&probe); + initialize_terminal_backend(&probe, &shell_launch); let runtime_toast = merge_startup_toasts( merge_startup_toasts( merge_startup_toasts(ghostty_runtime_toast, shell_integration_toast), @@ -2239,6 +2239,7 @@ fn build_ui( fn initialize_terminal_backend( probe: &BackendProbe, + shell_launch: &ShellLaunchSpec, ) -> (BackendChoice, String, Option, Option) { if probe.selected != BackendChoice::Ghostty { return (BackendChoice::Mock, probe.notes.clone(), None, None); @@ -2253,7 +2254,8 @@ fn initialize_terminal_backend( return (BackendChoice::Mock, note, None, Some(toast)); } - match GhosttyHost::new() { + let host_options = GhosttyHostOptions::from_shell_launch(shell_launch); + match GhosttyHost::new_with_options(&host_options) { Ok(host) => ( BackendChoice::Ghostty, probe.notes.clone(), diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index 1a3f296..c36f7c0 100644 --- a/crates/taskers-core/src/app_state.rs +++ b/crates/taskers-core/src/app_state.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, path::PathBuf}; use anyhow::{Context, Result, anyhow}; use taskers_control::{ControlCommand, ControlResponse, InMemoryController}; use taskers_domain::{AppModel, PaneId, PaneKind, WorkspaceId}; -use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; +use taskers_ghostty::{BackendChoice, GhosttyHostOptions, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; use crate::{pane_runtime::RuntimeManager, session_store}; @@ -62,6 +62,10 @@ impl AppState { &self.shell_launch } + pub fn ghostty_host_options(&self) -> GhosttyHostOptions { + GhosttyHostOptions::from_shell_launch(&self.shell_launch) + } + pub fn snapshot_model(&self) -> AppModel { self.controller.snapshot().model } @@ -112,15 +116,15 @@ impl AppState { .active_surface() .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?; - let (command_argv, env) = match surface.kind { + let env = match surface.kind { PaneKind::Terminal => { let mut env = self.shell_launch.env.clone(); env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); - (self.shell_launch.program_and_args(), env) + env } - PaneKind::Browser => (Vec::new(), BTreeMap::new()), + PaneKind::Browser => BTreeMap::new(), }; Ok(SurfaceDescriptor { @@ -130,7 +134,7 @@ impl AppState { cwd: surface.metadata.cwd.clone(), title: surface.metadata.title.clone(), url: surface.metadata.url.clone(), - command_argv, + command_argv: Vec::new(), env, }) } @@ -142,13 +146,13 @@ mod tests { use taskers_control::{ControlCommand, ControlQuery}; use taskers_domain::{AppModel, PaneKind, PaneMetadataPatch}; - use taskers_ghostty::BackendChoice; + use taskers_ghostty::{BackendChoice, GhosttyHostOptions}; use taskers_runtime::ShellLaunchSpec; use super::AppState; #[test] - fn surface_descriptor_includes_shell_launch_and_surface_metadata() { + fn surface_descriptor_keeps_surface_metadata_but_omits_embedded_command_override() { let model = AppModel::new("Main"); let workspace = model.active_workspace_id().expect("workspace"); let pane = model.active_workspace().expect("workspace").active_pane; @@ -174,7 +178,7 @@ mod tests { assert_eq!(descriptor.kind, PaneKind::Terminal); assert_eq!(descriptor.url, None); - assert_eq!(descriptor.command_argv, vec!["/bin/zsh", "-i"]); + assert!(descriptor.command_argv.is_empty()); assert_eq!( descriptor.env.get("TASKERS_WORKSPACE_ID"), Some(&workspace.to_string()) @@ -186,6 +190,33 @@ mod tests { assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID")); } + #[test] + fn ghostty_host_options_follow_shell_launch() { + let model = AppModel::new("Main"); + + let mut shell_launch = ShellLaunchSpec::fallback(); + shell_launch.program = PathBuf::from("/bin/zsh"); + shell_launch.args = vec!["-i".into()]; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); + + let app_state = AppState::new( + model, + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::Mock, + shell_launch, + ) + .expect("app state"); + + let options: GhosttyHostOptions = app_state.ghostty_host_options(); + assert_eq!(options.command_argv, vec!["/bin/zsh", "-i"]); + assert_eq!( + options.env.get("TASKERS_SOCKET").map(String::as_str), + Some("/tmp/taskers.sock") + ); + } + #[test] fn browser_surface_descriptor_omits_shell_launch_and_keeps_url() { let mut model = AppModel::new("Main"); diff --git a/crates/taskers-ghostty/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 5484882..893ed67 100644 --- a/crates/taskers-ghostty/Cargo.toml +++ b/crates/taskers-ghostty/Cargo.toml @@ -15,6 +15,7 @@ serde.workspace = true tar = "0.4" taskers-domain = { version = "0.3.0", path = "../taskers-domain" } taskers-paths.workspace = true +taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } thiserror.workspace = true ureq = "2.12" xz2 = "0.1" diff --git a/crates/taskers-ghostty/src/backend.rs b/crates/taskers-ghostty/src/backend.rs index faf7305..6189840 100644 --- a/crates/taskers-ghostty/src/backend.rs +++ b/crates/taskers-ghostty/src/backend.rs @@ -2,6 +2,7 @@ use crate::runtime::{runtime_bridge_path, runtime_resources_dir}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use taskers_domain::PaneKind; +use taskers_runtime::ShellLaunchSpec; use thiserror::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -43,6 +44,25 @@ pub struct SurfaceDescriptor { pub env: BTreeMap, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GhosttyHostOptions { + #[serde(default)] + pub command_argv: Vec, + #[serde(default)] + pub env: BTreeMap, +} + +impl GhosttyHostOptions { + pub fn from_shell_launch(shell_launch: &ShellLaunchSpec) -> Self { + let mut env = BTreeMap::new(); + env.extend(shell_launch.env.clone()); + Self { + command_argv: shell_launch.program_and_args(), + env, + } + } +} + #[derive(Debug, Error)] pub enum AdapterError { #[error("terminal backend is unavailable: {0}")] @@ -159,7 +179,11 @@ fn embedded_ghostty_notes() -> String { #[cfg(test)] mod tests { - use super::{BackendAvailability, BackendChoice, DefaultBackend, TerminalBackend}; + use super::{ + BackendAvailability, BackendChoice, DefaultBackend, GhosttyHostOptions, TerminalBackend, + }; + use std::{collections::BTreeMap, path::PathBuf}; + use taskers_runtime::ShellLaunchSpec; #[test] fn auto_probe_matches_runtime_availability() { @@ -191,4 +215,23 @@ mod tests { unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") }; assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded); } + + #[test] + fn host_options_follow_shell_launch_contract() { + let mut env = BTreeMap::new(); + env.insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); + let shell_launch = ShellLaunchSpec { + program: PathBuf::from("/bin/zsh"), + args: vec!["-i".into()], + env, + }; + + let options = GhosttyHostOptions::from_shell_launch(&shell_launch); + + assert_eq!(options.command_argv, vec!["/bin/zsh", "-i"]); + assert_eq!( + options.env.get("TASKERS_SOCKET").map(String::as_str), + Some("/tmp/taskers.sock") + ); + } } diff --git a/crates/taskers-ghostty/src/bridge.rs b/crates/taskers-ghostty/src/bridge.rs index b8ad2b3..40cb817 100644 --- a/crates/taskers-ghostty/src/bridge.rs +++ b/crates/taskers-ghostty/src/bridge.rs @@ -18,7 +18,7 @@ use gtk::prelude::ObjectType; use libloading::Library; use thiserror::Error; -use crate::backend::SurfaceDescriptor; +use crate::backend::{GhosttyHostOptions, SurfaceDescriptor}; use crate::runtime::{configure_runtime_environment, runtime_bridge_path}; #[derive(Debug, Error)] @@ -51,7 +51,8 @@ pub struct GhosttyHost; #[cfg(taskers_ghostty_bridge)] struct GhosttyBridgeLibrary { _library: Library, - host_new: unsafe extern "C" fn() -> *mut taskers_ghostty_host_t, + host_new: + unsafe extern "C" fn(*const taskers_ghostty_host_options_s) -> *mut taskers_ghostty_host_t, host_free: unsafe extern "C" fn(*mut taskers_ghostty_host_t), host_tick: unsafe extern "C" fn(*mut taskers_ghostty_host_t) -> c_int, surface_new: unsafe extern "C" fn( @@ -63,18 +64,62 @@ struct GhosttyBridgeLibrary { impl GhosttyHost { pub fn new() -> Result { + Self::new_with_options(&GhosttyHostOptions::default()) + } + + pub fn new_with_options(options: &GhosttyHostOptions) -> Result { configure_runtime_environment(); #[cfg(taskers_ghostty_bridge)] unsafe { let bridge = load_bridge_library()?; - let raw = (bridge.host_new)(); + let command_argv = options + .command_argv + .iter() + .map(|value| { + CString::new(value.as_str()) + .map_err(|_| GhosttyError::InvalidString("command_argv")) + }) + .collect::, _>>()?; + let command_argv_ptrs = command_argv + .iter() + .map(|value| value.as_ptr()) + .collect::>(); + let env_entries = options + .env + .iter() + .map(|(key, value)| { + CString::new(format!("{key}={value}")) + .map_err(|_| GhosttyError::InvalidString("env")) + }) + .collect::, _>>()?; + let env_entry_ptrs = env_entries + .iter() + .map(|value| value.as_ptr()) + .collect::>(); + let host_options = taskers_ghostty_host_options_s { + command_argv: if command_argv_ptrs.is_empty() { + std::ptr::null() + } else { + command_argv_ptrs.as_ptr() + }, + command_argc: command_argv_ptrs.len(), + env_entries: if env_entry_ptrs.is_empty() { + std::ptr::null() + } else { + env_entry_ptrs.as_ptr() + }, + env_count: env_entry_ptrs.len(), + }; + + let raw = (bridge.host_new)(&host_options); let raw = NonNull::new(raw).ok_or(GhosttyError::HostInit)?; Ok(Self { bridge, raw }) } #[cfg(not(taskers_ghostty_bridge))] { + let _ = options; Err(GhosttyError::Unavailable) } } @@ -109,18 +154,6 @@ impl GhosttyHost { .as_deref() .map(|value| CString::new(value).map_err(|_| GhosttyError::InvalidString("title"))) .transpose()?; - let command_argv = descriptor - .command_argv - .iter() - .map(|value| { - CString::new(value.as_str()) - .map_err(|_| GhosttyError::InvalidString("command_argv")) - }) - .collect::, _>>()?; - let command_argv_ptrs = command_argv - .iter() - .map(|value| value.as_ptr()) - .collect::>(); let env_entries = descriptor .env .iter() @@ -141,12 +174,6 @@ impl GhosttyHost { title: title .as_ref() .map_or(std::ptr::null(), |value| value.as_ptr()), - command_argv: if command_argv_ptrs.is_empty() { - std::ptr::null() - } else { - command_argv_ptrs.as_ptr() - }, - command_argc: command_argv_ptrs.len(), env_entries: if env_entry_ptrs.is_empty() { std::ptr::null() } else { @@ -210,7 +237,9 @@ fn load_bridge_library() -> Result { unsafe { let host_new = *library - .get:: *mut taskers_ghostty_host_t>( + .get:: *mut taskers_ghostty_host_t>( b"taskers_ghostty_host_new\0", ) .map_err(|error| GhosttyError::LibraryLoad { @@ -268,13 +297,20 @@ struct taskers_ghostty_host_t { _private: [u8; 0], } +#[cfg(taskers_ghostty_bridge)] +#[repr(C)] +struct taskers_ghostty_host_options_s { + command_argv: *const *const c_char, + command_argc: usize, + env_entries: *const *const c_char, + env_count: usize, +} + #[cfg(taskers_ghostty_bridge)] #[repr(C)] struct taskers_ghostty_surface_options_s { working_directory: *const c_char, title: *const c_char, - command_argv: *const *const c_char, - command_argc: usize, env_entries: *const *const c_char, env_count: usize, } diff --git a/crates/taskers-ghostty/src/lib.rs b/crates/taskers-ghostty/src/lib.rs index c9e2cad..fb73c92 100644 --- a/crates/taskers-ghostty/src/lib.rs +++ b/crates/taskers-ghostty/src/lib.rs @@ -5,7 +5,7 @@ pub mod runtime; pub use backend::{ AdapterError, BackendAvailability, BackendChoice, BackendProbe, DefaultBackend, - SurfaceDescriptor, TerminalBackend, + GhosttyHostOptions, SurfaceDescriptor, TerminalBackend, }; #[cfg(target_os = "linux")] pub use bridge::{GhosttyError, GhosttyHost}; diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index 4e4e147..15318b1 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -2432,6 +2432,7 @@ dependencies = [ "tar", "taskers-domain", "taskers-paths", + "taskers-runtime", "thiserror 2.0.18", "ureq", "xz2", diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 0719f36..3cb0637 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -19,7 +19,7 @@ use taskers_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, SurfaceKind, TerminalDefaults, }; -use taskers_ghostty::{GhosttyHost, ensure_runtime_installed}; +use taskers_ghostty::{GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; @@ -208,7 +208,8 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContex ), }; - let (ghostty_host, terminal_host, terminal_note) = match GhosttyHost::new() { + let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); + let (ghostty_host, terminal_host, terminal_note) = match GhosttyHost::new_with_options(&host_options) { Ok(host) => { let _ = host.tick(); (Some(host), RuntimeCapability::Ready, None) diff --git a/vendor/ghostty/include/taskers_ghostty_bridge.h b/vendor/ghostty/include/taskers_ghostty_bridge.h index 7913141..f3296b7 100644 --- a/vendor/ghostty/include/taskers_ghostty_bridge.h +++ b/vendor/ghostty/include/taskers_ghostty_bridge.h @@ -7,12 +7,22 @@ extern "C" { typedef struct taskers_ghostty_host taskers_ghostty_host_t; +typedef struct { + const char *const *command_argv; + size_t command_argc; + const char *const *env_entries; + size_t env_count; +} taskers_ghostty_host_options_s; + typedef struct { const char *working_directory; const char *title; + const char *const *env_entries; + size_t env_count; } taskers_ghostty_surface_options_s; -taskers_ghostty_host_t *taskers_ghostty_host_new(void); +taskers_ghostty_host_t *taskers_ghostty_host_new( + const taskers_ghostty_host_options_s *); void taskers_ghostty_host_free(taskers_ghostty_host_t *); int taskers_ghostty_host_tick(taskers_ghostty_host_t *); void *taskers_ghostty_surface_new( diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 0da9aa0..37f2e07 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -15,13 +15,20 @@ var initialized = false; pub const Host = struct { core_app: *CoreApp, rt_app: GtkRuntimeApp, + command_argv: []const [:0]u8, + env_entries: []const [:0]u8, +}; + +pub const HostOptions = extern struct { + command_argv: ?[*]const [*:0]const u8 = null, + command_argc: usize = 0, + env_entries: ?[*]const [*:0]const u8 = null, + env_count: usize = 0, }; pub const SurfaceOptions = extern struct { working_directory: ?[*:0]const u8 = null, title: ?[*:0]const u8 = null, - command_argv: ?[*]const [*:0]const u8 = null, - command_argc: usize = 0, env_entries: ?[*]const [*:0]const u8 = null, env_count: usize = 0, }; @@ -32,7 +39,7 @@ fn ensureInitialized() !void { initialized = true; } -pub export fn taskers_ghostty_host_new() ?*Host { +pub export fn taskers_ghostty_host_new(options: ?*const HostOptions) ?*Host { ensureInitialized() catch |err| { std.log.err("failed to initialize Ghostty state err={}", .{err}); return null; @@ -51,13 +58,35 @@ pub export fn taskers_ghostty_host_new() ?*Host { }; errdefer alloc.destroy(host); + const opts = options orelse &HostOptions{}; + const command_argv = duplicateStringList(alloc, opts.command_argv, opts.command_argc) catch |err| { + std.log.err("failed to copy Ghostty host command args err={}", .{err}); + core_app.destroy(); + alloc.destroy(host); + return null; + }; + errdefer freeStringList(alloc, command_argv); + + const env_entries = duplicateStringList(alloc, opts.env_entries, opts.env_count) catch |err| { + std.log.err("failed to copy Ghostty host env entries err={}", .{err}); + freeStringList(alloc, command_argv); + core_app.destroy(); + alloc.destroy(host); + return null; + }; + errdefer freeStringList(alloc, env_entries); + host.* = .{ .core_app = core_app, .rt_app = undefined, + .command_argv = command_argv, + .env_entries = env_entries, }; host.rt_app.init(core_app, .{}) catch |err| { std.log.err("failed to initialize Ghostty GTK runtime err={}", .{err}); + freeStringList(alloc, env_entries); + freeStringList(alloc, command_argv); core_app.destroy(); alloc.destroy(host); return null; @@ -69,6 +98,8 @@ pub export fn taskers_ghostty_host_free(host: ?*Host) void { const ptr = host orelse return; const alloc = state.alloc; ptr.rt_app.terminate(); + freeStringList(alloc, ptr.env_entries); + freeStringList(alloc, ptr.command_argv); ptr.core_app.destroy(); alloc.destroy(ptr); } @@ -88,26 +119,9 @@ pub export fn taskers_ghostty_surface_new( ) ?*gtk.Widget { const ptr = host orelse return null; const opts = options orelse &SurfaceOptions{}; - var arena = std.heap.ArenaAllocator.init(state.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - const command = command: { - const argv = opts.command_argv orelse break :command null; - if (opts.command_argc == 0) break :command null; - - const args = arena_alloc.alloc([:0]const u8, opts.command_argc) catch |err| { - std.log.err("failed to allocate Ghostty command args err={}", .{err}); - return null; - }; - for (0..opts.command_argc) |index| { - args[index] = arena_alloc.dupeZ(u8, std.mem.span(argv[index])) catch |err| { - std.log.err("failed to copy Ghostty command arg err={}", .{err}); - return null; - }; - } - - break :command configpkg.Command{ .direct = args }; + if (ptr.command_argv.len == 0) break :command null; + break :command configpkg.Command{ .direct = ptr.command_argv }; }; const surface = Surface.newForApp(ptr.rt_app.app, .{ @@ -115,7 +129,7 @@ pub export fn taskers_ghostty_surface_new( .working_directory = if (opts.working_directory) |value| std.mem.span(value) else null, .title = if (opts.title) |value| std.mem.span(value) else null, }); - const config = taskersSurfaceConfig(ptr.rt_app.app, opts) catch |err| { + const config = taskersSurfaceConfig(ptr.rt_app.app, ptr, opts) catch |err| { std.log.err("failed to configure Taskers Ghostty surface err={}", .{err}); return null; }; @@ -133,7 +147,7 @@ pub export fn taskers_ghostty_surface_grab_focus(widget: ?*gtk.Widget) c_int { return 1; } -fn taskersSurfaceConfig(app: anytype, opts: *const SurfaceOptions) !*Config { +fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOptions) !*Config { const alloc = state.alloc; const base = app.getConfig(); defer base.unref(); @@ -141,11 +155,13 @@ fn taskersSurfaceConfig(app: anytype, opts: *const SurfaceOptions) !*Config { var cloned = try base.get().clone(alloc); defer cloned.deinit(); - // Taskers should not inherit the user's standalone Ghostty shell command. cloned.command = null; cloned.@"shell-integration" = .none; cloned.@"shell-integration-features" = .{}; cloned.@"linux-cgroup" = .never; + for (ptr.env_entries) |entry| { + try cloned.env.parseCLI(alloc, entry); + } if (opts.env_entries) |entries| { for (0..opts.env_count) |index| { try cloned.env.parseCLI(alloc, std.mem.span(entries[index])); @@ -154,3 +170,28 @@ fn taskersSurfaceConfig(app: anytype, opts: *const SurfaceOptions) !*Config { return try Config.new(alloc, &cloned); } + +fn duplicateStringList( + alloc: std.mem.Allocator, + entries_ptr: ?[*]const [*:0]const u8, + count: usize, +) ![]const [:0]u8 { + var entries = try alloc.alloc([:0]u8, count); + errdefer { + for (entries) |entry| alloc.free(entry); + alloc.free(entries); + } + + if (entries_ptr) |source| { + for (0..count) |index| { + entries[index] = try alloc.dupeZ(u8, std.mem.span(source[index])); + } + } + + return entries; +} + +fn freeStringList(alloc: std.mem.Allocator, entries: []const [:0]u8) void { + for (entries) |entry| alloc.free(entry); + alloc.free(entries); +} From 6927dcb28c469b99310dc5f736f3a7f15026f57f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 12:31:36 +0100 Subject: [PATCH 18/63] feat: gate greenfield ghostty startup on surface probe --- crates/taskers-app/src/main.rs | 11 +- crates/taskers-ghostty/src/backend.rs | 7 +- greenfield/crates/taskers/src/main.rs | 296 +++++++++++++++++++++++--- 3 files changed, 281 insertions(+), 33 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 251d873..6e995a1 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -2271,7 +2271,16 @@ fn initialize_terminal_backend( } fn run_internal_ghostty_probe() -> gtk::glib::ExitCode { - match GhosttyHost::new() { + let shell_launch = match install_shell_integration(None) { + Ok(integration) => integration.launch_spec(), + Err(error) => { + eprintln!("ghostty self-probe falling back to default shell launch: {error}"); + ShellLaunchSpec::fallback() + } + }; + let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); + + match GhosttyHost::new_with_options(&host_options) { Ok(host) => { let _ = host.tick(); thread::sleep(Duration::from_millis(250)); diff --git a/crates/taskers-ghostty/src/backend.rs b/crates/taskers-ghostty/src/backend.rs index 6189840..64704ce 100644 --- a/crates/taskers-ghostty/src/backend.rs +++ b/crates/taskers-ghostty/src/backend.rs @@ -182,11 +182,15 @@ mod tests { use super::{ BackendAvailability, BackendChoice, DefaultBackend, GhosttyHostOptions, TerminalBackend, }; - use std::{collections::BTreeMap, path::PathBuf}; + use std::{collections::BTreeMap, path::PathBuf, sync::Mutex}; use taskers_runtime::ShellLaunchSpec; + static BACKEND_ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn auto_probe_matches_runtime_availability() { + let _guard = BACKEND_ENV_LOCK.lock().expect("env lock"); + unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") }; let probe = DefaultBackend::probe(BackendChoice::Auto); match probe.availability { BackendAvailability::Ready => assert_eq!(probe.selected, BackendChoice::Ghostty), @@ -210,6 +214,7 @@ mod tests { #[test] fn env_override_accepts_hyphenated_embedded_backend() { + let _guard = BACKEND_ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("TASKERS_TERMINAL_BACKEND", "ghostty-embedded") }; let probe = DefaultBackend::probe(BackendChoice::Mock); unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") }; diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 3cb0637..ed83349 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -6,14 +6,15 @@ use gtk::glib; use std::{ cell::{Cell, RefCell}, collections::BTreeMap, - fs::File, + fs::{File, OpenOptions, remove_file}, io::{self, Write}, net::TcpListener, path::PathBuf, + process::{Command, Stdio}, rc::Rc, sync::{Arc, Mutex}, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use taskers_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, @@ -36,6 +37,8 @@ struct Cli { diagnostic_log: Option, #[arg(long)] quit_after_ms: Option, + #[arg(long, hide = true, value_enum)] + internal_ghostty_probe: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -43,14 +46,40 @@ enum SmokeScript { Baseline, } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum GhosttyProbeMode { + Host, + Surface, +} + +impl GhosttyProbeMode { + fn as_arg(self) -> &'static str { + match self { + Self::Host => "host", + Self::Surface => "surface", + } + } +} + struct BootstrapContext { core: SharedCore, ghostty_host: Option, startup_notes: Vec, } +struct RuntimeBootstrap { + ghostty_runtime: RuntimeCapability, + shell_integration: RuntimeCapability, + terminal_defaults: TerminalDefaults, + host_options: GhosttyHostOptions, + startup_notes: Vec, +} + fn main() -> glib::ExitCode { let cli = Cli::parse(); + if let Some(mode) = cli.internal_ghostty_probe { + return run_internal_ghostty_probe(mode); + } let app = adw::Application::builder().application_id(APP_ID).build(); let hold_guard = Rc::new(RefCell::new(None)); let hold_guard_for_startup = hold_guard.clone(); @@ -181,6 +210,57 @@ fn build_ui_result( } fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContext { + let runtime = resolve_runtime_bootstrap(); + let mut startup_notes = runtime.startup_notes; + + let (ghostty_host, terminal_host, terminal_note) = + match probe_ghostty_backend_process(GhosttyProbeMode::Surface) { + Ok(()) => match GhosttyHost::new_with_options(&runtime.host_options) { + Ok(host) => { + let _ = host.tick(); + (Some(host), RuntimeCapability::Ready, None) + } + Err(error) => ( + None, + RuntimeCapability::Fallback { + message: format!("Ghostty host unavailable: {error}"), + }, + Some(format!("Ghostty host unavailable after probe: {error}")), + ), + }, + Err(error) => ( + None, + RuntimeCapability::Fallback { + message: format!("Ghostty surface self-probe failed: {error}"), + }, + Some(format!("Ghostty surface self-probe failed: {error}")), + ), + }; + + if let Some(note) = terminal_note { + startup_notes.push(note); + } + + let runtime_status = RuntimeStatus { + ghostty_runtime: runtime.ghostty_runtime, + shell_integration: runtime.shell_integration, + terminal_host, + }; + let core = SharedCore::bootstrap(BootstrapModel { + runtime_status, + terminal_defaults: runtime.terminal_defaults, + }); + + log_runtime_status(diagnostics, &core.snapshot().runtime_status); + + BootstrapContext { + core, + ghostty_host, + startup_notes, + } +} + +fn resolve_runtime_bootstrap() -> RuntimeBootstrap { scrub_inherited_terminal_env(); let mut startup_notes = Vec::new(); @@ -208,43 +288,42 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContex ), }; + let terminal_defaults = terminal_defaults_from(shell_launch.clone()); let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); - let (ghostty_host, terminal_host, terminal_note) = match GhosttyHost::new_with_options(&host_options) { - Ok(host) => { - let _ = host.tick(); - (Some(host), RuntimeCapability::Ready, None) - } - Err(error) => ( - None, - RuntimeCapability::Fallback { - message: format!("Ghostty host unavailable: {error}"), - }, - Some(format!("Ghostty host unavailable: {error}")), - ), - }; - - if let Some(note) = terminal_note { - startup_notes.push(note); - } - let runtime_status = RuntimeStatus { + RuntimeBootstrap { ghostty_runtime, shell_integration, - terminal_host, - }; - let terminal_defaults = terminal_defaults_from(shell_launch); - let core = SharedCore::bootstrap(BootstrapModel { - runtime_status, terminal_defaults, - }); + host_options, + startup_notes, + } +} - log_runtime_status(diagnostics, &core.snapshot().runtime_status); +fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { + let runtime = resolve_runtime_bootstrap(); + if let Err(error) = gtk::init() { + eprintln!("ghostty {} self-probe failed during gtk init: {error}", mode.as_arg()); + return glib::ExitCode::FAILURE; + } - BootstrapContext { - core, - ghostty_host, - startup_notes, + let host = match GhosttyHost::new_with_options(&runtime.host_options) { + Ok(host) => { + let _ = host.tick(); + host + } + Err(error) => { + eprintln!("ghostty {} self-probe failed during host init: {error}", mode.as_arg()); + return glib::ExitCode::FAILURE; + } + }; + + if matches!(mode, GhosttyProbeMode::Surface) { + return run_internal_surface_probe(host, runtime.terminal_defaults, mode); } + + spin_probe_main_context(Duration::from_millis(350)); + glib::ExitCode::SUCCESS } fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { @@ -263,6 +342,161 @@ fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { } } +fn run_internal_surface_probe( + host: GhosttyHost, + terminal_defaults: TerminalDefaults, + mode: GhosttyProbeMode, +) -> glib::ExitCode { + let settings = WebKitSettings::builder() + .enable_developer_extras(true) + .build(); + let shell_view = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .settings(&settings) + .build(); + shell_view.load_html( + "", + Some("http://127.0.0.1/"), + ); + + let core = SharedCore::bootstrap(BootstrapModel { + runtime_status: RuntimeStatus { + ghostty_runtime: RuntimeCapability::Ready, + shell_integration: RuntimeCapability::Ready, + terminal_host: RuntimeCapability::Ready, + }, + terminal_defaults, + }); + core.set_window_size(PixelSize::new(1200, 800)); + + let event_sink = Rc::new(|_| {}); + let mut taskers_host = TaskersHost::new(&shell_view, Some(host), event_sink, None); + let host_widget = taskers_host.widget(); + let window = gtk::Window::builder() + .title("Taskers Ghostty Probe") + .default_width(1200) + .default_height(800) + .child(&host_widget) + .build(); + window.present(); + + spin_probe_main_context(Duration::from_millis(80)); + if let Err(error) = taskers_host.sync_snapshot(&core.snapshot()) { + eprintln!( + "ghostty {} self-probe failed during snapshot sync: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; + } + + let deadline = Instant::now() + Duration::from_millis(350); + let context = glib::MainContext::default(); + while Instant::now() < deadline { + taskers_host.tick(); + while context.pending() { + let _ = context.iteration(false); + } + thread::sleep(Duration::from_millis(16)); + } + + window.close(); + spin_probe_main_context(Duration::from_millis(100)); + glib::ExitCode::SUCCESS +} + +fn spin_probe_main_context(duration: Duration) { + let deadline = Instant::now() + duration; + let context = glib::MainContext::default(); + while Instant::now() < deadline { + while context.pending() { + let _ = context.iteration(false); + } + thread::sleep(Duration::from_millis(16)); + } +} + +fn probe_ghostty_backend_process(mode: GhosttyProbeMode) -> Result<()> { + let current_exe = std::env::current_exe().context("failed to resolve current executable")?; + let log_path = ghostty_probe_log_path(mode); + let stdout = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&log_path) + .with_context(|| format!("failed to open probe log {}", log_path.display()))?; + let stderr = stdout + .try_clone() + .with_context(|| format!("failed to clone probe log {}", log_path.display()))?; + + let mut child = Command::new(current_exe) + .arg("--internal-ghostty-probe") + .arg(mode.as_arg()) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .context("failed to launch Ghostty self-probe")?; + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => { + let _ = remove_file(&log_path); + return Ok(()); + } + Ok(Some(status)) => { + anyhow::bail!( + "{}; probe log: {}", + describe_exit_status(status), + log_path.display() + ); + } + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("Ghostty self-probe timed out; probe log: {}", log_path.display()); + } + Err(error) => { + anyhow::bail!( + "failed to wait for Ghostty self-probe: {error}; probe log: {}", + log_path.display() + ); + } + } + } +} + +fn ghostty_probe_log_path(mode: GhosttyProbeMode) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + std::env::temp_dir().join(format!( + "taskers-ghostty-probe-{}-{}-{timestamp}.log", + mode.as_arg(), + std::process::id() + )) +} + +fn describe_exit_status(status: std::process::ExitStatus) -> String { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + + if let Some(signal) = status.signal() { + return format!("Ghostty self-probe crashed with signal {signal}"); + } + } + + match status.code() { + Some(code) => format!("Ghostty self-probe exited with status {code}"), + None => "Ghostty self-probe exited unsuccessfully".into(), + } +} + fn sync_window( window: &adw::ApplicationWindow, core: &SharedCore, From 084b01b712e8ceeef0f89ba65d750dca0a2b57d5 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 12:42:52 +0100 Subject: [PATCH 19/63] feat: expand greenfield shell snapshot and legacy-inspired chrome --- greenfield/crates/taskers-core/src/lib.rs | 1392 ++++++++++++++++-- greenfield/crates/taskers-host/src/lib.rs | 1 - greenfield/crates/taskers-shell/src/lib.rs | 644 ++++++-- greenfield/crates/taskers-shell/src/theme.rs | 856 ++++++++--- greenfield/crates/taskers/src/main.rs | 31 +- 5 files changed, 2414 insertions(+), 510 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 694271c..3e905df 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -3,24 +3,33 @@ use parking_lot::Mutex; use std::{collections::BTreeMap, fmt, sync::Arc}; use tokio::sync::{broadcast, watch}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct WorkspaceId(pub u64); + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PaneId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SurfaceId(pub u64); -impl fmt::Display for PaneId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "pane-{}", self.0) - } -} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ActivityId(pub u64); -impl fmt::Display for SurfaceId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "surface-{}", self.0) - } +macro_rules! impl_display_id { + ($name:ident, $prefix:literal) => { + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, concat!($prefix, "-{}"), self.0) + } + } + }; } +impl_display_id!(WorkspaceId, "workspace"); +impl_display_id!(PaneId, "pane"); +impl_display_id!(SurfaceId, "surface"); +impl_display_id!(ActivityId, "activity"); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplitAxis { Horizontal, @@ -42,6 +51,95 @@ impl SurfaceKind { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttentionState { + Normal, + Busy, + Completed, + WaitingInput, + Error, +} + +impl AttentionState { + pub fn label(self) -> &'static str { + match self { + Self::Normal => "Idle", + Self::Busy => "Busy", + Self::Completed => "Completed", + Self::WaitingInput => "Waiting", + Self::Error => "Error", + } + } + + pub fn slug(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Busy => "busy", + Self::Completed => "completed", + Self::WaitingInput => "waiting", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellSection { + Workspace, + Settings, +} + +impl ShellSection { + pub fn label(self) -> &'static str { + match self { + Self::Workspace => "Workspace", + Self::Settings => "Settings", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutPreset { + Balanced, + PowerUser, +} + +impl ShortcutPreset { + pub const ALL: [Self; 2] = [Self::Balanced, Self::PowerUser]; + + pub fn id(self) -> &'static str { + match self { + Self::Balanced => "balanced", + Self::PowerUser => "power-user", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Balanced => "Balanced Defaults", + Self::PowerUser => "Power User Defaults", + } + } + + pub fn detail(self) -> &'static str { + match self { + Self::Balanced => { + "Keep common focus, overview, browser, and split actions bound." + } + Self::PowerUser => { + "Restore dense direction and resize bindings for full keyboard-driven control." + } + } + } + + pub fn parse(id: &str) -> Option { + match id { + "balanced" => Some(Self::Balanced), + "power-user" => Some(Self::PowerUser), + _ => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuntimeCapability { Ready, @@ -155,20 +253,24 @@ impl Frame { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LayoutMetrics { pub sidebar_width: i32, + pub activity_width: i32, pub toolbar_height: i32, pub workspace_padding: i32, pub split_gap: i32, pub pane_header_height: i32, + pub surface_tab_height: i32, } impl Default for LayoutMetrics { fn default() -> Self { Self { sidebar_width: 248, - toolbar_height: 64, + activity_width: 312, + toolbar_height: 56, workspace_padding: 16, split_gap: 12, pane_header_height: 38, + surface_tab_height: 34, } } } @@ -180,13 +282,16 @@ pub struct SurfaceSnapshot { pub title: String, pub url: Option, pub cwd: Option, + pub attention: AttentionState, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PaneSnapshot { pub id: PaneId, pub active: bool, - pub surface: SurfaceSnapshot, + pub attention: AttentionState, + pub active_surface: SurfaceId, + pub surfaces: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -246,16 +351,87 @@ pub struct SurfacePortalPlan { pub panes: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSummary { + pub id: WorkspaceId, + pub title: String, + pub preview: String, + pub active: bool, + pub pane_count: usize, + pub surface_count: usize, + pub unread_activity: usize, + pub attention: AttentionState, +} + #[derive(Debug, Clone, PartialEq)] -pub struct ShellSnapshot { - pub revision: u64, - pub workspace_title: String, - pub workspace_count: usize, +pub struct WorkspaceViewSnapshot { + pub id: WorkspaceId, + pub title: String, + pub attention: AttentionState, + pub pane_count: usize, + pub surface_count: usize, pub active_pane: PaneId, pub layout: LayoutNodeSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivityItemSnapshot { + pub id: ActivityId, + pub title: String, + pub preview: String, + pub meta: String, + pub attention: AttentionState, + pub workspace_id: WorkspaceId, + pub pane_id: Option, + pub surface_id: Option, + pub unread: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemeOptionSnapshot { + pub id: String, + pub label: String, + pub family: String, + pub active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutPresetSnapshot { + pub id: String, + pub label: String, + pub detail: String, + pub active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutBindingSnapshot { + pub id: String, + pub label: String, + pub detail: String, + pub category: String, + pub accelerators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsSnapshot { + pub selected_theme_id: String, + pub theme_options: Vec, + pub shortcut_presets: Vec, + pub shortcuts: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ShellSnapshot { + pub revision: u64, + pub section: ShellSection, + pub overview_mode: bool, + pub workspaces: Vec, + pub current_workspace: WorkspaceViewSnapshot, + pub activity: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, + pub settings: SettingsSnapshot, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -269,10 +445,20 @@ pub enum HostEvent { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ShellAction { + ShowSection { section: ShellSection }, + ToggleOverview, + FocusWorkspace { workspace_id: WorkspaceId }, + CreateWorkspace, SplitBrowser { pane_id: Option }, SplitTerminal { pane_id: Option }, + AddBrowserSurface { pane_id: Option }, + AddTerminalSurface { pane_id: Option }, FocusPane { pane_id: PaneId }, - ClosePane { pane_id: PaneId, surface_id: SurfaceId }, + FocusSurface { pane_id: PaneId, surface_id: SurfaceId }, + CloseSurface { pane_id: PaneId, surface_id: SurfaceId }, + DismissActivity { activity_id: ActivityId }, + SelectTheme { theme_id: String }, + SelectShortcutPreset { preset_id: String }, } #[derive(Debug, Clone)] @@ -282,12 +468,14 @@ struct SurfaceRecord { title: String, url: Option, cwd: Option, + attention: AttentionState, } #[derive(Debug, Clone)] struct PaneRecord { id: PaneId, - surface: SurfaceRecord, + active_surface: SurfaceId, + surfaces: IndexMap, } #[derive(Debug, Clone)] @@ -364,11 +552,36 @@ impl LayoutNode { } #[derive(Debug, Clone)] -struct AppModel { - workspace_title: String, +struct WorkspaceRecord { + id: WorkspaceId, + title: String, active_pane: PaneId, panes: IndexMap, layout: LayoutNode, +} + +#[derive(Debug, Clone)] +struct ActivityRecord { + id: ActivityId, + title: String, + preview: String, + meta: String, + attention: AttentionState, + workspace_id: WorkspaceId, + pane_id: Option, + surface_id: Option, + unread: bool, +} + +#[derive(Debug, Clone)] +struct AppModel { + section: ShellSection, + overview_mode: bool, + active_workspace: WorkspaceId, + workspaces: IndexMap, + activity: IndexMap, + selected_theme_id: String, + selected_shortcut_preset: ShortcutPreset, window_size: PixelSize, } @@ -390,20 +603,94 @@ impl TaskersCore { revision: 1, metrics, model: AppModel { - workspace_title: "Main".into(), - active_pane: PaneId(0), - panes: IndexMap::new(), - layout: LayoutNode::Leaf(PaneId(0)), + section: ShellSection::Workspace, + overview_mode: false, + active_workspace: WorkspaceId(0), + workspaces: IndexMap::new(), + activity: IndexMap::new(), + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::Balanced, window_size: PixelSize::new(1440, 900), }, runtime_status: bootstrap.runtime_status, terminal_defaults: bootstrap.terminal_defaults, }; - let pane = core.make_surface(SurfaceKind::Terminal, "Agent shell".into(), None, None); - core.model.active_pane = pane.id; - core.model.layout = LayoutNode::Leaf(pane.id); - core.model.panes.insert(pane.id, pane); + let main = core.seed_workspace( + "Main", + vec![ + SeedSurface::terminal("Agent shell", None, AttentionState::Busy).with_secondary( + SeedSurface::browser( + "Docs", + "https://taskers.app/docs", + AttentionState::Completed, + ), + ), + SeedPane::new(SeedSurface::browser( + "Preview", + "https://dioxuslabs.com/learn/0.7/", + AttentionState::Normal, + )), + ], + ); + let research = core.seed_workspace( + "Research", + vec![SeedPane::new(SeedSurface::browser( + "Taskers docs", + "https://taskers.invalid/docs", + AttentionState::WaitingInput, + ))], + ); + let release = core.seed_workspace( + "Release", + vec![SeedPane::new(SeedSurface::terminal( + "Release checks", + Some("/home/notes/Projects/taskers"), + AttentionState::Error, + ))], + ); + + core.model.active_workspace = main; + core.seed_activity( + "Embedded terminal host", + match &core.runtime_status.terminal_host { + RuntimeCapability::Ready => "Embedded Ghostty passed the last startup probe.".into(), + RuntimeCapability::Fallback { message } => { + format!("Shell is still live, but terminal startup fell back: {message}") + } + RuntimeCapability::Unavailable { message } => { + format!("Embedded terminal host is unavailable: {message}") + } + }, + "Linux host runtime", + match core.runtime_status.terminal_host { + RuntimeCapability::Ready => AttentionState::Completed, + RuntimeCapability::Fallback { .. } => AttentionState::WaitingInput, + RuntimeCapability::Unavailable { .. } => AttentionState::Error, + }, + main, + None, + None, + ); + core.seed_activity( + "Research workspace waiting", + "A browser review is staged in the Research workspace.", + "Workspace Research · browser", + AttentionState::WaitingInput, + research, + None, + None, + ); + core.seed_activity( + "Release checks need attention", + "The release terminal recorded a failing verification run.", + "Workspace Release · terminal", + AttentionState::Error, + release, + None, + None, + ); + core } @@ -411,8 +698,15 @@ impl TaskersCore { self.revision } + fn current_workspace(&self) -> &WorkspaceRecord { + self.model + .workspaces + .get(&self.model.active_workspace) + .expect("active workspace should exist") + } + fn snapshot(&self) -> ShellSnapshot { - let layout = self.snapshot_layout(&self.model.layout); + let workspace = self.current_workspace(); let content = self.content_frame(); let portal = SurfacePortalPlan { window: Frame::new( @@ -422,39 +716,178 @@ impl TaskersCore { self.model.window_size.height, ), content, - panes: self.collect_surface_plans(&self.model.layout, content), + panes: if matches!(self.model.section, ShellSection::Workspace) { + self.collect_surface_plans(workspace, &workspace.layout, content) + } else { + Vec::new() + }, }; ShellSnapshot { revision: self.revision, - workspace_title: self.model.workspace_title.clone(), - workspace_count: 1, - active_pane: self.model.active_pane, - layout, + section: self.model.section, + overview_mode: self.model.overview_mode, + workspaces: self.workspace_summaries(), + current_workspace: WorkspaceViewSnapshot { + id: workspace.id, + title: workspace.title.clone(), + attention: self.workspace_attention(workspace.id), + pane_count: workspace.panes.len(), + surface_count: workspace + .panes + .values() + .map(|pane| pane.surfaces.len()) + .sum(), + active_pane: workspace.active_pane, + layout: self.snapshot_layout(workspace, &workspace.layout), + }, + activity: self.activity_snapshot(), portal, metrics: self.metrics, runtime_status: self.runtime_status.clone(), + settings: self.settings_snapshot(), + } + } + + fn workspace_summaries(&self) -> Vec { + self.model + .workspaces + .values() + .map(|workspace| WorkspaceSummary { + id: workspace.id, + title: workspace.title.clone(), + preview: self.workspace_preview(workspace), + active: workspace.id == self.model.active_workspace, + pane_count: workspace.panes.len(), + surface_count: workspace + .panes + .values() + .map(|pane| pane.surfaces.len()) + .sum(), + unread_activity: self + .model + .activity + .values() + .filter(|item| item.workspace_id == workspace.id && item.unread) + .count(), + attention: self.workspace_attention(workspace.id), + }) + .collect() + } + + fn workspace_preview(&self, workspace: &WorkspaceRecord) -> String { + let pane = workspace + .panes + .get(&workspace.active_pane) + .or_else(|| workspace.panes.values().next()); + let Some(pane) = pane else { + return "No surfaces".into(); + }; + let surface = pane + .surfaces + .get(&pane.active_surface) + .or_else(|| pane.surfaces.values().next()) + .expect("pane should have at least one surface"); + match surface.kind { + SurfaceKind::Terminal => surface + .cwd + .clone() + .unwrap_or_else(|| "Embedded terminal".into()), + SurfaceKind::Browser => surface + .url + .clone() + .unwrap_or_else(|| "Native browser view".into()), + } + } + + fn workspace_attention(&self, workspace_id: WorkspaceId) -> AttentionState { + let pane_attention = self + .model + .workspaces + .get(&workspace_id) + .map(|workspace| { + workspace + .panes + .values() + .flat_map(|pane| pane.surfaces.values().map(|surface| surface.attention)) + .fold(AttentionState::Normal, strongest_attention) + }) + .unwrap_or(AttentionState::Normal); + + self.model + .activity + .values() + .filter(|item| item.workspace_id == workspace_id && item.unread) + .map(|item| item.attention) + .fold(pane_attention, strongest_attention) + } + + fn activity_snapshot(&self) -> Vec { + self.model + .activity + .values() + .rev() + .map(|item| ActivityItemSnapshot { + id: item.id, + title: item.title.clone(), + preview: item.preview.clone(), + meta: item.meta.clone(), + attention: item.attention, + workspace_id: item.workspace_id, + pane_id: item.pane_id, + surface_id: item.surface_id, + unread: item.unread, + }) + .collect() + } + + fn settings_snapshot(&self) -> SettingsSnapshot { + SettingsSnapshot { + selected_theme_id: self.model.selected_theme_id.clone(), + theme_options: builtin_theme_options(&self.model.selected_theme_id), + shortcut_presets: ShortcutPreset::ALL + .into_iter() + .map(|preset| ShortcutPresetSnapshot { + id: preset.id().into(), + label: preset.label().into(), + detail: preset.detail().into(), + active: preset == self.model.selected_shortcut_preset, + }) + .collect(), + shortcuts: shortcut_bindings(self.model.selected_shortcut_preset), } } - fn snapshot_layout(&self, node: &LayoutNode) -> LayoutNodeSnapshot { + fn snapshot_layout( + &self, + workspace: &WorkspaceRecord, + node: &LayoutNode, + ) -> LayoutNodeSnapshot { match node { LayoutNode::Leaf(pane_id) => { - let pane = self - .model + let pane = workspace .panes .get(pane_id) .expect("layout pane should exist"); + let surfaces = pane + .surfaces + .values() + .map(|surface| SurfaceSnapshot { + id: surface.id, + kind: surface.kind, + title: surface.title.clone(), + url: surface.url.clone(), + cwd: surface.cwd.clone(), + attention: surface.attention, + }) + .collect::>(); + LayoutNodeSnapshot::Pane(PaneSnapshot { id: pane.id, - active: pane.id == self.model.active_pane, - surface: SurfaceSnapshot { - id: pane.surface.id, - kind: pane.surface.kind, - title: pane.surface.title.clone(), - url: pane.surface.url.clone(), - cwd: pane.surface.cwd.clone(), - }, + active: pane.id == workspace.active_pane, + attention: pane_attention(pane), + active_surface: pane.active_surface, + surfaces, }) } LayoutNode::Split { @@ -465,8 +898,8 @@ impl TaskersCore { } => LayoutNodeSnapshot::Split { axis: *axis, ratio: f32::from(*ratio_millis) / 1000.0, - first: Box::new(self.snapshot_layout(first)), - second: Box::new(self.snapshot_layout(second)), + first: Box::new(self.snapshot_layout(workspace, first)), + second: Box::new(self.snapshot_layout(workspace, second)), }, } } @@ -476,34 +909,51 @@ impl TaskersCore { let padding = metrics.workspace_padding; let x = metrics.sidebar_width + padding; let y = metrics.toolbar_height + padding; - let width = (self.model.window_size.width - metrics.sidebar_width - (padding * 2)).max(320); + let width = (self.model.window_size.width + - metrics.sidebar_width + - metrics.activity_width + - (padding * 2)) + .max(360); let height = (self.model.window_size.height - metrics.toolbar_height - (padding * 2)).max(240); Frame::new(x, y, width, height) } - fn collect_surface_plans(&self, node: &LayoutNode, frame: Frame) -> Vec { + fn collect_surface_plans( + &self, + workspace: &WorkspaceRecord, + node: &LayoutNode, + frame: Frame, + ) -> Vec { let mut panes = Vec::new(); - self.collect_surface_plans_into(node, frame, &mut panes); + self.collect_surface_plans_into(workspace, node, frame, &mut panes); panes } fn collect_surface_plans_into( &self, + workspace: &WorkspaceRecord, node: &LayoutNode, frame: Frame, out: &mut Vec, ) { match node { LayoutNode::Leaf(pane_id) => { - if let Some(pane) = self.model.panes.get(pane_id) { - out.push(PortalSurfacePlan { - pane_id: pane.id, - surface_id: pane.surface.id, - active: pane.id == self.model.active_pane, - frame: frame.inset_top(self.metrics.pane_header_height), - mount: self.mount_spec_for(pane), - }); + if let Some(pane) = workspace.panes.get(pane_id) { + if let Some(surface) = pane + .surfaces + .get(&pane.active_surface) + .or_else(|| pane.surfaces.values().next()) + { + out.push(PortalSurfacePlan { + pane_id: pane.id, + surface_id: surface.id, + active: pane.id == workspace.active_pane, + frame: frame + .inset_top(self.metrics.pane_header_height + self.metrics.surface_tab_height), + mount: self.mount_spec_for(workspace.id, pane.id, surface), + }); + } } } LayoutNode::Split { @@ -514,29 +964,33 @@ impl TaskersCore { } => { let (first_frame, second_frame) = split_frame(frame, *axis, *ratio_millis, self.metrics.split_gap); - self.collect_surface_plans_into(first, first_frame, out); - self.collect_surface_plans_into(second, second_frame, out); + self.collect_surface_plans_into(workspace, first, first_frame, out); + self.collect_surface_plans_into(workspace, second, second_frame, out); } } } - fn mount_spec_for(&self, pane: &PaneRecord) -> SurfaceMountSpec { - match pane.surface.kind { + fn mount_spec_for( + &self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface: &SurfaceRecord, + ) -> SurfaceMountSpec { + match surface.kind { SurfaceKind::Browser => SurfaceMountSpec::Browser(BrowserMountSpec { - url: pane - .surface + url: surface .url .clone() .unwrap_or_else(|| "https://dioxuslabs.com/learn/0.7/".into()), }), SurfaceKind::Terminal => { let mut env = self.terminal_defaults.env.clone(); - env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); - env.insert("TASKERS_SURFACE_ID".into(), pane.surface.id.to_string()); - env.insert("TASKERS_WORKSPACE_ID".into(), "main".into()); + env.insert("TASKERS_PANE_ID".into(), pane_id.to_string()); + env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); + env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); SurfaceMountSpec::Terminal(TerminalMountSpec { - title: pane.surface.title.clone(), - cwd: pane.surface.cwd.clone(), + title: surface.title.clone(), + cwd: surface.cwd.clone(), cols: self.terminal_defaults.cols, rows: self.terminal_defaults.rows, command_argv: self.terminal_defaults.command_argv.clone(), @@ -547,28 +1001,108 @@ impl TaskersCore { } fn split_pane(&mut self, target: PaneId, kind: SurfaceKind, axis: SplitAxis) -> bool { - if !self.model.panes.contains_key(&target) { + let workspace_id = self.model.active_workspace; + let new_pane = self.make_pane( + kind, + default_surface_title(kind), + default_surface_url(kind), + None, + initial_attention_for(kind), + ); + let pane_id = new_pane.id; + let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { + return false; + }; + if !workspace.panes.contains_key(&target) { return false; } + workspace.panes.insert(pane_id, new_pane); + if workspace.layout.split_leaf(target, axis, pane_id, 500) { + workspace.active_pane = pane_id; + self.revision += 1; + true + } else { + let _ = workspace.panes.shift_remove(&pane_id); + false + } + } - let pane = match kind { - SurfaceKind::Terminal => self.make_surface(kind, self.next_terminal_title(), None, None), - SurfaceKind::Browser => self.make_surface( - kind, - "Browser".into(), - Some("https://dioxuslabs.com/learn/0.7/".into()), - None, - ), + fn add_surface_to_pane(&mut self, target: PaneId, kind: SurfaceKind) -> bool { + let workspace_id = self.model.active_workspace; + let surface = self.make_surface_record( + kind, + default_surface_title(kind), + default_surface_url(kind), + None, + initial_attention_for(kind), + ); + let surface_id = surface.id; + let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { + return false; }; - let pane_id = pane.id; - self.model.panes.insert(pane.id, pane); + let Some(pane) = workspace.panes.get_mut(&target) else { + return false; + }; + pane.surfaces.insert(surface_id, surface); + pane.active_surface = surface_id; + workspace.active_pane = target; + self.revision += 1; + true + } - if self - .model - .layout - .split_leaf(target, axis, pane_id, 500) + fn focus_pane(&mut self, pane_id: PaneId) -> bool { + let workspace_id = self.model.active_workspace; + let active_surface_id = { + let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { + return false; + }; + if !workspace.panes.contains_key(&pane_id) { + return false; + } + workspace.active_pane = pane_id; + if let Some(pane) = workspace.panes.get_mut(&pane_id) { + if let Some(surface) = pane.surfaces.get_mut(&pane.active_surface) { + surface.attention = AttentionState::Normal; + } + Some(pane.active_surface) + } else { + None + } + }; + self.dismiss_surface_activity(workspace_id, Some(pane_id), active_surface_id); + self.revision += 1; + true + } + + fn focus_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { + let workspace_id = self.model.active_workspace; { - self.model.active_pane = pane_id; + let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { + return false; + }; + let Some(pane) = workspace.panes.get_mut(&pane_id) else { + return false; + }; + if !pane.surfaces.contains_key(&surface_id) { + return false; + } + pane.active_surface = surface_id; + workspace.active_pane = pane_id; + if let Some(surface) = pane.surfaces.get_mut(&surface_id) { + surface.attention = AttentionState::Normal; + } + } + self.dismiss_surface_activity(workspace_id, Some(pane_id), Some(surface_id)); + self.revision += 1; + true + } + + fn focus_workspace(&mut self, workspace_id: WorkspaceId) -> bool { + if self.model.workspaces.contains_key(&workspace_id) + && self.model.active_workspace != workspace_id + { + self.model.active_workspace = workspace_id; + self.model.section = ShellSection::Workspace; self.revision += 1; true } else { @@ -576,9 +1110,67 @@ impl TaskersCore { } } - fn focus_pane(&mut self, pane_id: PaneId) -> bool { - if self.model.panes.contains_key(&pane_id) && self.model.active_pane != pane_id { - self.model.active_pane = pane_id; + fn create_workspace(&mut self) -> bool { + let title = format!("Workspace {}", self.model.workspaces.len() + 1); + let workspace_id = self.seed_workspace( + &title, + vec![SeedPane::new(SeedSurface::terminal( + "Agent shell", + None, + initial_attention_for(SurfaceKind::Terminal), + ))], + ); + self.model.active_workspace = workspace_id; + self.model.section = ShellSection::Workspace; + self.revision += 1; + true + } + + fn set_section(&mut self, section: ShellSection) -> bool { + if self.model.section != section { + self.model.section = section; + self.revision += 1; + true + } else { + false + } + } + + fn toggle_overview(&mut self) -> bool { + self.model.overview_mode = !self.model.overview_mode; + self.revision += 1; + true + } + + fn dismiss_activity(&mut self, activity_id: ActivityId) -> bool { + if self.model.activity.shift_remove(&activity_id).is_some() { + self.revision += 1; + true + } else { + false + } + } + + fn select_theme(&mut self, theme_id: String) -> bool { + if builtin_theme_options("") + .iter() + .any(|option| option.id == theme_id) + && self.model.selected_theme_id != theme_id + { + self.model.selected_theme_id = theme_id; + self.revision += 1; + true + } else { + false + } + } + + fn select_shortcut_preset(&mut self, preset_id: String) -> bool { + let Some(preset) = ShortcutPreset::parse(&preset_id) else { + return false; + }; + if self.model.selected_shortcut_preset != preset { + self.model.selected_shortcut_preset = preset; self.revision += 1; true } else { @@ -607,83 +1199,158 @@ impl TaskersCore { self.update_surface(surface_id, |surface| { if surface.title != title { surface.title = title.clone(); + surface.attention = AttentionState::Completed; true } else { false } - }) + }); + self.push_surface_activity( + surface_id, + "Surface title updated", + title, + AttentionState::Completed, + ) } HostEvent::SurfaceUrlChanged { surface_id, url } => { self.update_surface(surface_id, |surface| { if surface.url.as_deref() != Some(url.as_str()) { surface.url = Some(url.clone()); + surface.attention = AttentionState::Busy; true } else { false } - }) + }); + self.push_surface_activity( + surface_id, + "Browser navigated", + url, + AttentionState::Busy, + ) } HostEvent::SurfaceCwdChanged { surface_id, cwd } => { self.update_surface(surface_id, |surface| { if surface.cwd.as_deref() != Some(cwd.as_str()) { surface.cwd = Some(cwd.clone()); + surface.attention = AttentionState::Busy; true } else { false } - }) + }); + self.push_surface_activity( + surface_id, + "Terminal changed directory", + cwd, + AttentionState::Busy, + ) } } } fn dispatch_shell_action(&mut self, action: ShellAction) -> bool { match action { + ShellAction::ShowSection { section } => self.set_section(section), + ShellAction::ToggleOverview => self.toggle_overview(), + ShellAction::FocusWorkspace { workspace_id } => self.focus_workspace(workspace_id), + ShellAction::CreateWorkspace => self.create_workspace(), ShellAction::SplitBrowser { pane_id } => self.split_pane( - pane_id.unwrap_or(self.model.active_pane), + pane_id.unwrap_or(self.current_workspace().active_pane), SurfaceKind::Browser, SplitAxis::Horizontal, ), ShellAction::SplitTerminal { pane_id } => self.split_pane( - pane_id.unwrap_or(self.model.active_pane), + pane_id.unwrap_or(self.current_workspace().active_pane), SurfaceKind::Terminal, SplitAxis::Vertical, ), + ShellAction::AddBrowserSurface { pane_id } => self.add_surface_to_pane( + pane_id.unwrap_or(self.current_workspace().active_pane), + SurfaceKind::Browser, + ), + ShellAction::AddTerminalSurface { pane_id } => self.add_surface_to_pane( + pane_id.unwrap_or(self.current_workspace().active_pane), + SurfaceKind::Terminal, + ), ShellAction::FocusPane { pane_id } => self.focus_pane(pane_id), - ShellAction::ClosePane { + ShellAction::FocusSurface { + pane_id, + surface_id, + } => self.focus_surface(pane_id, surface_id), + ShellAction::CloseSurface { pane_id, surface_id, } => self.close_surface(pane_id, surface_id), + ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), + ShellAction::SelectTheme { theme_id } => self.select_theme(theme_id), + ShellAction::SelectShortcutPreset { preset_id } => { + self.select_shortcut_preset(preset_id) + } } } fn close_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { - let Some(pane) = self.model.panes.get(&pane_id) else { - return false; - }; - if pane.surface.id != surface_id { - return false; - } + let workspace_id = self.model.active_workspace; + let mut needs_replacement = false; + { + let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { + return false; + }; + let Some(pane) = workspace.panes.get(&pane_id) else { + return false; + }; + if !pane.surfaces.contains_key(&surface_id) { + return false; + } - self.model.panes.shift_remove(&pane_id); - self.model.layout = match self.model.layout.clone().remove_leaf(pane_id) { - Some(layout) => layout, - None => { - let replacement = self.make_surface( - SurfaceKind::Terminal, - "Agent shell".into(), - None, - None, - ); - let replacement_id = replacement.id; - self.model.panes.insert(replacement_id, replacement); - LayoutNode::Leaf(replacement_id) + if pane.surfaces.len() > 1 { + let pane = workspace + .panes + .get_mut(&pane_id) + .expect("pane should still exist"); + pane.surfaces.shift_remove(&surface_id); + if pane.active_surface == surface_id { + pane.active_surface = *pane + .surfaces + .keys() + .next() + .expect("pane should still have a surface"); + } + workspace.active_pane = pane_id; + } else { + workspace.panes.shift_remove(&pane_id); + if let Some(layout) = workspace.layout.clone().remove_leaf(pane_id) { + workspace.layout = layout; + } else { + needs_replacement = true; + } + if !needs_replacement && !workspace.panes.contains_key(&workspace.active_pane) { + workspace.active_pane = workspace.layout.first_leaf_id(); + } } - }; + } - if !self.model.panes.contains_key(&self.model.active_pane) { - self.model.active_pane = self.model.layout.first_leaf_id(); + if needs_replacement { + let replacement = self.make_pane( + SurfaceKind::Terminal, + "Agent shell".into(), + None, + None, + initial_attention_for(SurfaceKind::Terminal), + ); + let replacement_id = replacement.id; + let workspace = self + .model + .workspaces + .get_mut(&workspace_id) + .expect("active workspace should exist"); + workspace.panes.insert(replacement_id, replacement); + workspace.layout = LayoutNode::Leaf(replacement_id); + workspace.active_pane = replacement_id; } + self.dismiss_surface_activity(workspace_id, Some(pane_id), Some(surface_id)); self.revision += 1; true } @@ -693,39 +1360,221 @@ impl TaskersCore { surface_id: SurfaceId, mut update: impl FnMut(&mut SurfaceRecord) -> bool, ) -> bool { - for pane in self.model.panes.values_mut() { - if pane.surface.id == surface_id && update(&mut pane.surface) { - self.revision += 1; - return true; + for workspace in self.model.workspaces.values_mut() { + for pane in workspace.panes.values_mut() { + if let Some(surface) = pane.surfaces.get_mut(&surface_id) { + return update(surface); + } } } false } - fn next_terminal_title(&self) -> String { - format!("Task {}", self.next_id) + fn push_surface_activity( + &mut self, + surface_id: SurfaceId, + title: impl Into, + preview: impl Into, + attention: AttentionState, + ) -> bool { + let Some((workspace_id, pane_id, meta)) = self.lookup_surface_context(surface_id) else { + return false; + }; + self.model.activity.insert( + ActivityId(self.next_id), + ActivityRecord { + id: ActivityId(self.next_id), + title: title.into(), + preview: preview.into(), + meta, + attention, + workspace_id, + pane_id: Some(pane_id), + surface_id: Some(surface_id), + unread: true, + }, + ); + self.next_id += 1; + self.revision += 1; + true + } + + fn lookup_surface_context(&self, surface_id: SurfaceId) -> Option<(WorkspaceId, PaneId, String)> { + for workspace in self.model.workspaces.values() { + for pane in workspace.panes.values() { + if let Some(surface) = pane.surfaces.get(&surface_id) { + let meta = format!( + "{} · {}", + workspace.title, + match surface.kind { + SurfaceKind::Terminal => surface + .cwd + .clone() + .unwrap_or_else(|| surface.kind.label().into()), + SurfaceKind::Browser => surface + .url + .clone() + .unwrap_or_else(|| surface.kind.label().into()), + } + ); + return Some((workspace.id, pane.id, meta)); + } + } + } + None } - fn make_surface( + fn dismiss_surface_activity( + &mut self, + workspace_id: WorkspaceId, + pane_id: Option, + surface_id: Option, + ) { + let remove = self + .model + .activity + .iter() + .filter_map(|(id, item)| { + (item.workspace_id == workspace_id + && item.pane_id == pane_id + && item.surface_id == surface_id) + .then_some(*id) + }) + .collect::>(); + for id in remove { + self.model.activity.shift_remove(&id); + } + } + + fn seed_workspace(&mut self, title: &str, panes: Vec) -> WorkspaceId { + let workspace_id = WorkspaceId(self.next_id); + self.next_id += 1; + let mut pane_records = IndexMap::new(); + let mut layout: Option = None; + let mut active_pane = None; + + for (index, seed) in panes.into_iter().enumerate() { + let pane = self.make_seeded_pane(seed); + let pane_id = pane.id; + if index == 0 { + active_pane = Some(pane_id); + layout = Some(LayoutNode::Leaf(pane_id)); + } else if let Some(layout_node) = layout.as_mut() { + let axis = if index % 2 == 0 { + SplitAxis::Vertical + } else { + SplitAxis::Horizontal + }; + let _ = layout_node.split_leaf(active_pane.expect("first pane"), axis, pane_id, 500); + } + pane_records.insert(pane_id, pane); + } + + let active_pane = active_pane.expect("workspace should contain at least one pane"); + self.model.workspaces.insert( + workspace_id, + WorkspaceRecord { + id: workspace_id, + title: title.into(), + active_pane, + panes: pane_records, + layout: layout.expect("workspace should contain at least one pane"), + }, + ); + workspace_id + } + + fn seed_activity( + &mut self, + title: impl Into, + preview: impl Into, + meta: impl Into, + attention: AttentionState, + workspace_id: WorkspaceId, + pane_id: Option, + surface_id: Option, + ) { + let activity_id = ActivityId(self.next_id); + self.next_id += 1; + self.model.activity.insert( + activity_id, + ActivityRecord { + id: activity_id, + title: title.into(), + preview: preview.into(), + meta: meta.into(), + attention, + workspace_id, + pane_id, + surface_id, + unread: true, + }, + ); + } + + fn make_seeded_pane(&mut self, seed: SeedPane) -> PaneRecord { + let pane_id = PaneId(self.next_id); + self.next_id += 1; + let mut surfaces = IndexMap::new(); + let mut active_surface = None; + for (index, seed_surface) in seed.surfaces.into_iter().enumerate() { + let surface = self.make_surface_record( + seed_surface.kind, + seed_surface.title, + seed_surface.url, + seed_surface.cwd, + seed_surface.attention, + ); + if index == 0 { + active_surface = Some(surface.id); + } + surfaces.insert(surface.id, surface); + } + PaneRecord { + id: pane_id, + active_surface: active_surface.expect("seed pane should contain a surface"), + surfaces, + } + } + + fn make_pane( &mut self, kind: SurfaceKind, title: String, url: Option, cwd: Option, + attention: AttentionState, ) -> PaneRecord { let pane_id = PaneId(self.next_id); self.next_id += 1; - let surface_id = SurfaceId(self.next_id); - self.next_id += 1; + let surface = self.make_surface_record(kind, title, url, cwd, attention); + let active_surface = surface.id; + let mut surfaces = IndexMap::new(); + surfaces.insert(surface.id, surface); PaneRecord { id: pane_id, - surface: SurfaceRecord { - id: surface_id, - kind, - title, - url, - cwd, - }, + active_surface, + surfaces, + } + } + + fn make_surface_record( + &mut self, + kind: SurfaceKind, + title: String, + url: Option, + cwd: Option, + attention: AttentionState, + ) -> SurfaceRecord { + let surface_id = SurfaceId(self.next_id); + self.next_id += 1; + SurfaceRecord { + id: surface_id, + kind, + title, + url, + cwd, + attention, } } } @@ -769,6 +1618,176 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio_millis: u16, gap: i32) -> (F } } +fn strongest_attention(lhs: AttentionState, rhs: AttentionState) -> AttentionState { + match (attention_rank(lhs), attention_rank(rhs)) { + (left, right) if left >= right => lhs, + _ => rhs, + } +} + +fn attention_rank(state: AttentionState) -> u8 { + match state { + AttentionState::Normal => 0, + AttentionState::Completed => 1, + AttentionState::Busy => 2, + AttentionState::WaitingInput => 3, + AttentionState::Error => 4, + } +} + +fn pane_attention(pane: &PaneRecord) -> AttentionState { + pane.surfaces + .values() + .map(|surface| surface.attention) + .fold(AttentionState::Normal, strongest_attention) +} + +fn initial_attention_for(kind: SurfaceKind) -> AttentionState { + match kind { + SurfaceKind::Terminal => AttentionState::Busy, + SurfaceKind::Browser => AttentionState::Completed, + } +} + +fn default_surface_title(kind: SurfaceKind) -> String { + match kind { + SurfaceKind::Terminal => "Agent shell".into(), + SurfaceKind::Browser => "Browser".into(), + } +} + +fn default_surface_url(kind: SurfaceKind) -> Option { + match kind { + SurfaceKind::Terminal => None, + SurfaceKind::Browser => Some("https://dioxuslabs.com/learn/0.7/".into()), + } +} + +#[derive(Debug)] +struct SeedSurface { + kind: SurfaceKind, + title: String, + url: Option, + cwd: Option, + attention: AttentionState, +} + +impl SeedSurface { + fn terminal(title: &str, cwd: Option<&str>, attention: AttentionState) -> Self { + Self { + kind: SurfaceKind::Terminal, + title: title.into(), + url: None, + cwd: cwd.map(str::to_string), + attention, + } + } + + fn browser(title: &str, url: &str, attention: AttentionState) -> Self { + Self { + kind: SurfaceKind::Browser, + title: title.into(), + url: Some(url.into()), + cwd: None, + attention, + } + } + + fn with_secondary(self, secondary: SeedSurface) -> SeedPane { + SeedPane { + surfaces: vec![self, secondary], + } + } +} + +#[derive(Debug)] +struct SeedPane { + surfaces: Vec, +} + +impl SeedPane { + fn new(surface: SeedSurface) -> Self { + Self { + surfaces: vec![surface], + } + } +} + +const THEME_OPTIONS: &[(&str, &str, &str)] = &[ + ("dark", "Dark", "Default"), + ("catppuccin-mocha", "Catppuccin Mocha", "Catppuccin"), + ("tokyo-night", "Tokyo Night", "Tokyo Night"), + ("gruvbox-dark", "Gruvbox Dark", "Other"), +]; + +fn builtin_theme_options(selected_theme_id: &str) -> Vec { + THEME_OPTIONS + .iter() + .map(|(id, label, family)| ThemeOptionSnapshot { + id: (*id).into(), + label: (*label).into(), + family: (*family).into(), + active: *id == selected_theme_id, + }) + .collect() +} + +#[derive(Clone, Copy)] +struct ShortcutBindingSpec { + id: &'static str, + label: &'static str, + detail: &'static str, + category: &'static str, + balanced: &'static [&'static str], + power_user: &'static [&'static str], +} + +const SHORTCUT_BINDINGS: &[ShortcutBindingSpec] = &[ + ShortcutBindingSpec { id: "toggle_overview", label: "Toggle overview", detail: "Zoom the current workspace out to fit the full column strip.", category: "General", balanced: &["o"], power_user: &["o"] }, + ShortcutBindingSpec { id: "close_terminal", label: "Close terminal", detail: "Close the active pane or active top-level window.", category: "General", balanced: &["x"], power_user: &["x"] }, + ShortcutBindingSpec { id: "open_browser_split", label: "Open browser in split", detail: "Split the active pane to the right and open a browser surface.", category: "Browser", balanced: &["l"], power_user: &["l"] }, + ShortcutBindingSpec { id: "focus_browser_address", label: "Focus browser address bar", detail: "Focus the address bar for the active browser surface.", category: "Browser", balanced: &["l"], power_user: &["l"] }, + ShortcutBindingSpec { id: "reload_browser_page", label: "Reload browser page", detail: "Reload the active browser surface.", category: "Browser", balanced: &["r"], power_user: &["r"] }, + ShortcutBindingSpec { id: "toggle_browser_devtools", label: "Toggle browser devtools", detail: "Show or hide devtools for the active browser surface.", category: "Browser", balanced: &["i"], power_user: &["i"] }, + ShortcutBindingSpec { id: "focus_left", label: "Focus left", detail: "Move focus to the column on the left, then fall back to pane focus.", category: "Focus", balanced: &["h", "Left"], power_user: &["h", "Left"] }, + ShortcutBindingSpec { id: "focus_right", label: "Focus right", detail: "Move focus to the column on the right, then fall back to pane focus.", category: "Focus", balanced: &["l", "Right"], power_user: &["l", "Right"] }, + ShortcutBindingSpec { id: "focus_up", label: "Focus up", detail: "Move focus to the stacked window above, then fall back to pane focus.", category: "Focus", balanced: &["k", "Up"], power_user: &["k", "Up"] }, + ShortcutBindingSpec { id: "focus_down", label: "Focus down", detail: "Move focus to the stacked window below, then fall back to pane focus.", category: "Focus", balanced: &["j", "Down"], power_user: &["j", "Down"] }, + ShortcutBindingSpec { id: "new_window_left", label: "New window left", detail: "Create a top-level window in a new column on the left.", category: "Top-level windows", balanced: &[], power_user: &["h", "Left"] }, + ShortcutBindingSpec { id: "new_window_right", label: "New window right", detail: "Create a top-level window in a new column on the right.", category: "Top-level windows", balanced: &["t"], power_user: &["t"] }, + ShortcutBindingSpec { id: "new_window_up", label: "New window up", detail: "Create a stacked top-level window above the active window.", category: "Top-level windows", balanced: &[], power_user: &["k", "Up"] }, + ShortcutBindingSpec { id: "new_window_down", label: "New window down", detail: "Create a stacked top-level window below the active window.", category: "Top-level windows", balanced: &["g"], power_user: &["g"] }, + ShortcutBindingSpec { id: "resize_window_left", label: "Make window narrower", detail: "Reduce the active column width.", category: "Advanced resize", balanced: &[], power_user: &["Home"] }, + ShortcutBindingSpec { id: "resize_window_right", label: "Make window wider", detail: "Increase the active column width.", category: "Advanced resize", balanced: &[], power_user: &["End"] }, + ShortcutBindingSpec { id: "resize_window_up", label: "Make window shorter", detail: "Reduce the active top-level window height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Up"] }, + ShortcutBindingSpec { id: "resize_window_down", label: "Make window taller", detail: "Increase the active top-level window height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Down"] }, + ShortcutBindingSpec { id: "resize_split_left", label: "Make split narrower", detail: "Reduce the active split width.", category: "Advanced resize", balanced: &[], power_user: &["Home"] }, + ShortcutBindingSpec { id: "resize_split_right", label: "Make split wider", detail: "Increase the active split width.", category: "Advanced resize", balanced: &[], power_user: &["End"] }, + ShortcutBindingSpec { id: "resize_split_up", label: "Make split shorter", detail: "Reduce the active split height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Up"] }, + ShortcutBindingSpec { id: "resize_split_down", label: "Make split taller", detail: "Increase the active split height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Down"] }, + ShortcutBindingSpec { id: "split_right", label: "Split right", detail: "Split the active pane to the right inside the current window.", category: "Pane splits", balanced: &["t"], power_user: &["t"] }, + ShortcutBindingSpec { id: "split_down", label: "Split down", detail: "Split the active pane downward inside the current window.", category: "Pane splits", balanced: &["g"], power_user: &["g"] }, +]; + +fn shortcut_bindings(preset: ShortcutPreset) -> Vec { + SHORTCUT_BINDINGS + .iter() + .map(|binding| ShortcutBindingSnapshot { + id: binding.id.into(), + label: binding.label.into(), + detail: binding.detail.into(), + category: binding.category.into(), + accelerators: match preset { + ShortcutPreset::Balanced => binding.balanced, + ShortcutPreset::PowerUser => binding.power_user, + } + .iter() + .map(|value| (*value).into()) + .collect(), + }) + .collect() +} + #[derive(Clone)] pub struct SharedCore { inner: Arc>, @@ -853,7 +1872,8 @@ impl Eq for SharedCore {} mod tests { use super::{ BootstrapModel, BrowserMountSpec, HostEvent, RuntimeCapability, RuntimeStatus, SharedCore, - SurfaceMountSpec, SurfaceKind, TerminalDefaults, + ShellAction, ShellSection, ShortcutPreset, SurfaceMountSpec, SurfaceKind, + TerminalDefaults, WorkspaceId, }; use std::collections::BTreeMap; @@ -865,8 +1885,7 @@ mod tests { ghostty_runtime: RuntimeCapability::Ready, shell_integration: RuntimeCapability::Ready, terminal_host: RuntimeCapability::Fallback { - message: "GTK4 Ghostty bridge cannot mount into the GTK3 Dioxus host yet." - .into(), + message: "GTK4 Ghostty bridge probe failed on this machine.".into(), }, }, terminal_defaults: TerminalDefaults { @@ -910,8 +1929,6 @@ mod tests { #[test] fn browser_host_events_update_surface_metadata() { let core = SharedCore::bootstrap(bootstrap()); - core.split_with_browser(); - let browser = core .snapshot() .portal @@ -930,7 +1947,7 @@ mod tests { }); let snapshot = core.snapshot(); - let pane = match snapshot.layout { + let pane = match snapshot.current_workspace.layout { super::LayoutNodeSnapshot::Split { second, .. } => second, _ => panic!("expected split layout"), }; @@ -938,9 +1955,14 @@ mod tests { super::LayoutNodeSnapshot::Pane(pane) => pane, _ => panic!("expected pane node"), }; - assert_eq!(pane.surface.title, "Taskers Docs"); + let surface = pane + .surfaces + .into_iter() + .find(|surface| surface.id == browser.surface_id) + .expect("browser surface"); + assert_eq!(surface.title, "Taskers Docs"); assert_eq!( - pane.surface.url.as_deref(), + surface.url.as_deref(), Some("https://taskers.invalid/docs") ); } @@ -948,7 +1970,6 @@ mod tests { #[test] fn closing_a_split_surface_collapses_the_layout() { let core = SharedCore::bootstrap(bootstrap()); - core.split_with_browser(); let browser = core .snapshot() .portal @@ -963,10 +1984,86 @@ mod tests { }); let snapshot = core.snapshot(); - assert!(matches!(snapshot.layout, super::LayoutNodeSnapshot::Pane(_))); + assert!(matches!( + snapshot.current_workspace.layout, + super::LayoutNodeSnapshot::Pane(_) + )); assert_eq!(snapshot.portal.panes.len(), 1); } + #[test] + fn adding_a_surface_switches_the_active_tab_and_portal_mount() { + let core = SharedCore::bootstrap(bootstrap()); + let pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(pane_id), + }); + + let snapshot = core.snapshot(); + let pane = match snapshot.current_workspace.layout { + super::LayoutNodeSnapshot::Split { first, .. } => first, + super::LayoutNodeSnapshot::Pane(pane) => Box::new(super::LayoutNodeSnapshot::Pane(pane)), + }; + let pane = match *pane { + super::LayoutNodeSnapshot::Pane(pane) => pane, + _ => panic!("expected pane"), + }; + assert!(pane.surfaces.len() >= 3); + let mounted = snapshot + .portal + .panes + .into_iter() + .find(|plan| plan.pane_id == pane_id) + .expect("mounted plan for active pane"); + assert_eq!(mounted.surface_id, pane.active_surface); + assert_eq!(mounted.mount.kind(), SurfaceKind::Browser); + } + + #[test] + fn switching_workspaces_updates_snapshot_and_portal() { + let core = SharedCore::bootstrap(bootstrap()); + let workspace = core + .snapshot() + .workspaces + .into_iter() + .find(|workspace| workspace.title == "Research") + .expect("research workspace"); + + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: workspace.id, + }); + + let snapshot = core.snapshot(); + assert_eq!(snapshot.current_workspace.id, workspace.id); + assert_eq!(snapshot.current_workspace.title, "Research"); + assert_eq!(snapshot.portal.panes.len(), 1); + } + + #[test] + fn settings_snapshot_tracks_selected_theme_and_shortcut_preset() { + let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Settings, + }); + core.dispatch_shell_action(ShellAction::SelectTheme { + theme_id: "tokyo-night".into(), + }); + core.dispatch_shell_action(ShellAction::SelectShortcutPreset { + preset_id: ShortcutPreset::PowerUser.id().into(), + }); + + let snapshot = core.snapshot(); + assert_eq!(snapshot.section, ShellSection::Settings); + assert_eq!(snapshot.settings.selected_theme_id, "tokyo-night"); + assert!( + snapshot + .settings + .shortcut_presets + .iter() + .any(|preset| preset.id == "power-user" && preset.active) + ); + } + #[test] fn runtime_status_round_trips_through_snapshot() { let core = SharedCore::bootstrap(bootstrap()); @@ -985,4 +2082,17 @@ mod tests { RuntimeCapability::Fallback { .. } )); } + + #[test] + fn create_workspace_adds_new_sidebar_entry() { + let core = SharedCore::bootstrap(bootstrap()); + let before = core.snapshot().workspaces.len(); + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let snapshot = core.snapshot(); + assert_eq!(snapshot.workspaces.len(), before + 1); + assert!(snapshot + .workspaces + .iter() + .any(|workspace| workspace.id == WorkspaceId(0) || workspace.active)); + } } diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 372a68c..10d381e 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -702,7 +702,6 @@ mod tests { #[test] fn partitions_portal_plans_by_surface_kind() { let core = SharedCore::bootstrap(BootstrapModel::default()); - core.split_with_browser(); let snapshot = core.snapshot(); let browsers = browser_plans(&snapshot.portal); diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 45061ba..6c7de90 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -2,12 +2,13 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ - LayoutNodeSnapshot, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, SplitAxis, - SurfaceKind, + ActivityItemSnapshot, AttentionState, LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, + RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, + ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceSummary, }; -fn app_css() -> String { - theme::generate_css(&theme::default_dark()) +fn app_css(snapshot: &ShellSnapshot) -> String { + theme::generate_css(&theme::resolve_palette(&snapshot.settings.selected_theme_id)) } #[component] @@ -32,14 +33,35 @@ pub fn TaskersShell(core: SharedCore) -> Element { let _ = revision(); let snapshot = core.snapshot(); - let stylesheet = app_css(); - let focus_active = { + let stylesheet = app_css(&snapshot); + let show_workspace_nav = { let core = core.clone(); - let active_pane = snapshot.active_pane; - move |_| core.dispatch_shell_action(ShellAction::FocusPane { - pane_id: active_pane, + move |_| core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Workspace, }) }; + let show_workspace_header = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Workspace, + }) + }; + let show_settings_nav = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Settings, + }) + }; + let show_settings_header = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Settings, + }) + }; + let create_workspace = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::CreateWorkspace) + }; let split_terminal = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }) @@ -48,31 +70,50 @@ pub fn TaskersShell(core: SharedCore) -> Element { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }) }; + let toggle_overview = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ToggleOverview) + }; + + let main_class = match snapshot.section { + ShellSection::Workspace => { + if snapshot.overview_mode { + "workspace-main workspace-main-overview" + } else { + "workspace-main" + } + } + ShellSection::Settings => "workspace-main workspace-main-settings", + }; rsx! { style { "{stylesheet}" } div { class: "app-shell", aside { class: "workspace-sidebar", div { class: "sidebar-brand", - div { class: "sidebar-heading", "Taskers shell" } + div { class: "sidebar-heading", "Taskers" } h1 { "Taskers" } + div { class: "workspace-preview", "Shared Dioxus shell over native browser and terminal hosts." } } - div { class: "workspace-list", - button { class: "workspace-button", - div { class: "workspace-item workspace-item-active", - div { - div { class: "workspace-label", "{snapshot.workspace_title}" } - div { class: "workspace-preview", "Unified Dioxus shell over native platform hosts." } - div { class: "workspace-meta", "{snapshot.workspace_count} workspace · revision {snapshot.revision}" } - } - div { class: "workspace-status-badge", "{snapshot.portal.panes.len()}" } - } + div { class: "sidebar-nav", + button { + class: if matches!(snapshot.section, ShellSection::Workspace) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, + onclick: show_workspace_nav, + "Workspaces" + } + button { + class: if matches!(snapshot.section, ShellSection::Settings) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, + onclick: show_settings_nav, + "Settings" } } - div { class: "runtime-card", - div { class: "sidebar-heading", "Shell notes" } - div { class: "status-copy", - "The shell chrome is shared Dioxus. Browser and terminal pane bodies are mounted by the platform host." + div { class: "sidebar-section-header", + div { class: "sidebar-heading", "Workspaces" } + button { class: "workspace-add", onclick: create_workspace, "+" } + } + div { class: "workspace-list", + for workspace in &snapshot.workspaces { + {render_workspace_item(workspace, core.clone())} } } div { class: "runtime-card", @@ -83,30 +124,116 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } - main { class: "workspace-main", + main { class: "{main_class}", header { class: "workspace-header", - button { - class: "workspace-header-title-btn", - onclick: focus_active, - span { class: "workspace-header-label", "{snapshot.workspace_title}" } - span { class: "workspace-header-meta", "Shared shell · native pane bodies" } + div { class: "workspace-header-main", + button { + class: "workspace-header-title-btn", + onclick: show_workspace_header, + span { class: "workspace-header-label", "{snapshot.current_workspace.title}" } + span { class: "workspace-header-meta", + "{snapshot.current_workspace.pane_count} panes · {snapshot.current_workspace.surface_count} surfaces · revision {snapshot.revision}" + } + } } div { class: "workspace-header-actions", - button { - class: "workspace-header-action", - onclick: split_terminal, - "+ terminal" + if matches!(snapshot.section, ShellSection::Workspace) { + button { + class: if snapshot.overview_mode { + "workspace-header-action workspace-header-action-active" + } else { + "workspace-header-action" + }, + onclick: toggle_overview, + "Overview" + } + button { + class: "workspace-header-action", + onclick: split_terminal, + "+ split" + } + button { + class: "workspace-header-action workspace-header-action-primary", + onclick: split_browser, + "+ browser" + } + } else { + button { + class: "workspace-header-action workspace-header-action-active", + onclick: show_settings_header, + "Preferences" + } } - button { - class: "workspace-header-action workspace-header-action-primary", - onclick: split_browser, - "+ browser" + } + } + + if matches!(snapshot.section, ShellSection::Workspace) { + div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, + {render_layout(&snapshot.current_workspace.layout, core.clone(), &snapshot.runtime_status)} + } + } else { + div { class: "settings-canvas", + {render_settings(&snapshot.settings, core.clone())} + } + } + } + + aside { class: "attention-panel", + div { class: "sidebar-heading", "Attention" } + div { class: "attention-summary", + div { class: "workspace-label", "{snapshot.activity.len()} unread items" } + div { class: "workspace-meta", "Focus stays in the shared shell while native hosts report metadata and lifecycle changes back into core." } + } + if snapshot.activity.is_empty() { + div { class: "empty-state", "No unread items." } + } else { + div { class: "activity-list", + for item in &snapshot.activity { + {render_activity_item(item, core.clone(), &snapshot.current_workspace)} } } } + } + } + } +} - div { class: "workspace-canvas", - {render_layout(&snapshot.layout, core.clone(), &snapshot.runtime_status)} +fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Element { + let attention_class = format!("workspace-item-state-{}", workspace.attention.slug()); + let item_class = if workspace.active { + format!("workspace-item workspace-item-active {attention_class}") + } else { + format!("workspace-item {attention_class}") + }; + let badge_class = if workspace.attention == AttentionState::Normal { + "workspace-status-badge".to_string() + } else { + format!( + "workspace-status-badge workspace-status-badge-state-{}", + workspace.attention.slug() + ) + }; + let workspace_id = workspace.id; + let focus_workspace = move |_| { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + }; + + rsx! { + button { class: "workspace-button", onclick: focus_workspace, + div { class: "{item_class}", + div { + div { class: "workspace-label", "{workspace.title}" } + div { class: "workspace-preview", "{workspace.preview}" } + div { class: "workspace-meta", + "{workspace.pane_count} panes · {workspace.surface_count} surfaces" + } + } + div { class: "{badge_class}", + if workspace.unread_activity > 0 { + "{workspace.unread_activity}" + } else { + "{workspace.attention.label()}" + } } } } @@ -121,7 +248,7 @@ fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability }; rsx! { - div { + div { class: "runtime-row", div { class: "runtime-status-row", span { class: "workspace-preview", "{label}" } span { class: "{class}", "{capability.label()}" } @@ -165,135 +292,338 @@ fn render_layout( } } } - LayoutNodeSnapshot::Pane(pane) => { - let pane_class = if pane.active { - "pane-card pane-card-active" - } else { - "pane-card" - }; - let pane_id = pane.id; - let surface_id = pane.surface.id; - let kind_label = pane.surface.kind.label(); - let surface_copy = match pane.surface.kind { - SurfaceKind::Browser => { - let url = pane - .surface - .url - .clone() - .unwrap_or_else(|| "about:blank".into()); - rsx! { - div { class: "surface-backdrop", - div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "Browser surface" } - div { class: "surface-backdrop-title", "{pane.surface.title}" } - div { class: "surface-backdrop-note", - "The platform host mounts a native browser view into this body region while the shared shell keeps the chrome and actions consistent." - } - } - div { class: "surface-meta", - span { class: "surface-chip", "URL: {url}" } - } + LayoutNodeSnapshot::Pane(pane) => render_pane(pane, core, runtime_status), + } +} + +fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeStatus) -> Element { + let pane_class = if pane.active { + format!("pane-card pane-card-active pane-card-state-{}", pane.attention.slug()) + } else { + format!("pane-card pane-card-state-{}", pane.attention.slug()) + }; + let active_surface = pane + .surfaces + .iter() + .find(|surface| surface.id == pane.active_surface) + .unwrap_or_else(|| pane.surfaces.first().expect("pane snapshot should contain surfaces")); + let subtitle = match active_surface.kind { + SurfaceKind::Terminal => active_surface + .cwd + .clone() + .unwrap_or_else(|| "Embedded terminal".into()), + SurfaceKind::Browser => active_surface + .url + .clone() + .unwrap_or_else(|| "Native browser surface".into()), + }; + let status_class = format!("status-dot status-dot-{}", active_surface.attention.slug()); + let pane_id = pane.id; + let active_surface_id = active_surface.id; + + let focus_pane = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::FocusPane { pane_id }) + }; + let add_browser_surface = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(pane_id), + }) + }; + let add_terminal_surface = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + }) + }; + let split_browser = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { + pane_id: Some(pane_id), + }) + }; + let split_terminal = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { + pane_id: Some(pane_id), + }) + }; + let close_surface = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::CloseSurface { + pane_id, + surface_id: active_surface_id, + }) + }; + + rsx! { + section { class: "{pane_class}", onclick: focus_pane, + div { class: "pane-header", + div { class: "pane-header-main", + span { class: "{status_class}", "●" } + div { class: "pane-title-stack", + div { class: "pane-title", "{active_surface.title}" } + div { class: "pane-meta", "{subtitle}" } + } + } + div { class: "pane-action-cluster", + button { class: "pane-action pane-action-tab", onclick: add_browser_surface, "+ tab" } + button { class: "pane-action pane-action-tab", onclick: add_terminal_surface, "+ term" } + button { class: "pane-action pane-window-action", onclick: split_browser, "+ web" } + button { class: "pane-action pane-split-action", onclick: split_terminal, "+ split" } + button { class: "pane-action pane-close-action", onclick: close_surface, "×" } + } + } + div { class: "surface-tabs", + for surface in &pane.surfaces { + {render_surface_tab(pane.id, pane.active_surface, surface, core.clone())} + } + } + div { class: "pane-body", + {render_surface_backdrop(active_surface, runtime_status)} + } + } + } +} + +fn render_surface_tab( + pane_id: taskers_core::PaneId, + active_surface_id: taskers_core::SurfaceId, + surface: &SurfaceSnapshot, + core: SharedCore, +) -> Element { + let tab_class = if surface.id == active_surface_id { + format!( + "surface-tab surface-tab-active surface-tab-state-{}", + surface.attention.slug() + ) + } else { + format!("surface-tab surface-tab-state-{}", surface.attention.slug()) + }; + let surface_id = surface.id; + let focus_surface = move |_| { + core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + }; + + rsx! { + button { class: "{tab_class}", onclick: focus_surface, + span { class: "surface-tab-label", "{surface.kind.label()}" } + span { class: "pane-meta", "{surface.title}" } + } + } +} + +fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { + let badge_class = format!("status-pill status-pill-inline status-pill-{}", surface.attention.slug()); + match surface.kind { + SurfaceKind::Browser => { + let url = surface + .url + .clone() + .unwrap_or_else(|| "about:blank".into()); + rsx! { + div { class: "surface-backdrop", + div { class: "surface-backdrop-copy", + div { class: "surface-backdrop-eyebrow", "Browser surface" } + div { class: "surface-backdrop-title", "{surface.title}" } + div { class: "surface-backdrop-note", + "The platform host mounts a native browser view here while the shared shell keeps tabs, workspace chrome, settings, and activity state consistent." } } + div { class: "surface-meta", + span { class: "{badge_class}", "{surface.attention.label()}" } + span { class: "surface-chip", "URL: {url}" } + } } - SurfaceKind::Terminal => { - let host_message = runtime_status - .terminal_host - .message() - .unwrap_or("Terminal hosting is ready."); - rsx! { - div { class: "surface-backdrop", - div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "Terminal surface" } - div { class: "surface-backdrop-title", "{pane.surface.title}" } - div { class: "surface-backdrop-note", "{host_message}" } - } - if let Some(cwd) = &pane.surface.cwd { - div { class: "surface-meta", - span { class: "surface-chip", "cwd: {cwd}" } - } - } + } + } + SurfaceKind::Terminal => { + let host_message = runtime_status + .terminal_host + .message() + .unwrap_or("Embedded terminal hosting is ready."); + rsx! { + div { class: "surface-backdrop", + div { class: "surface-backdrop-copy", + div { class: "surface-backdrop-eyebrow", "Terminal surface" } + div { class: "surface-backdrop-title", "{surface.title}" } + div { class: "surface-backdrop-note", "{host_message}" } + } + div { class: "surface-meta", + span { class: "{badge_class}", "{surface.attention.label()}" } + if let Some(cwd) = &surface.cwd { + span { class: "surface-chip", "cwd: {cwd}" } } } } - }; - let status_class = match pane.surface.kind { - SurfaceKind::Browser => "status-dot status-dot-busy", - SurfaceKind::Terminal => { - if matches!(runtime_status.terminal_host, RuntimeCapability::Ready) { - "status-dot status-dot-completed" - } else { - "status-dot status-dot-waiting" + } + } + } +} + +fn render_activity_item( + item: &ActivityItemSnapshot, + core: SharedCore, + current_workspace: &taskers_core::WorkspaceViewSnapshot, +) -> Element { + let row_class = format!("activity-item activity-item-state-{}", item.attention.slug()); + let activity_id = item.id; + let dismiss = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::DismissActivity { activity_id }) + }; + let focus_target = { + let core = core.clone(); + let workspace_id = item.workspace_id; + let pane_id = item.pane_id; + let surface_id = item.surface_id; + let current_workspace_id = current_workspace.id; + move |_| { + if workspace_id != current_workspace_id { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + } else if let (Some(pane_id), Some(surface_id)) = (pane_id, surface_id) { + core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + } else if let Some(pane_id) = pane_id { + core.dispatch_shell_action(ShellAction::FocusPane { pane_id }); + } else { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + } + } + }; + + rsx! { + div { class: "activity-item-shell", + button { class: "activity-item-button", onclick: focus_target, + div { class: "{row_class}", + div { class: "activity-header", + div { class: "workspace-label", "{item.title}" } + div { class: "activity-time", "{item.attention.label()}" } } + div { class: "activity-meta", "{item.meta}" } + div { class: "activity-preview", "{item.preview}" } } - }; - let subtitle = match &pane.surface.cwd { - Some(cwd) => format!("{kind_label} · {cwd}"), - None => format!("{kind_label} · {}", pane.id), - }; - let focus_pane = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::FocusPane { pane_id }) - }; - let split_browser = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { - pane_id: Some(pane_id), - }) - }; - let split_terminal = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { - pane_id: Some(pane_id), - }) - }; - let close_pane = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ClosePane { - pane_id, - surface_id, - }) - }; + } + button { class: "activity-action", onclick: dismiss, "Done" } + } + } +} - rsx! { - section { - class: "{pane_class}", - onclick: focus_pane, - div { class: "pane-header", - div { class: "pane-header-main", - span { class: "{status_class}", "●" } - div { class: "pane-title-stack", - div { class: "pane-title", "{pane.surface.title}" } - div { class: "pane-meta", "{subtitle}" } - } +fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { + rsx! { + div { class: "settings-grid", + section { class: "settings-card", + div { class: "sidebar-heading", "Themes" } + div { class: "settings-copy", + "Use the legacy Taskers palette vocabulary as the visual source of truth for the shared shell." + } + div { class: "theme-grid", + for theme in &settings.theme_options { + {render_theme_option(theme, core.clone())} + } + } + } + section { class: "settings-card", + div { class: "sidebar-heading", "Shortcut Presets" } + div { class: "settings-copy", + "Balanced keeps common navigation bound. Power User restores dense directional resizing and split controls." + } + div { class: "preset-grid", + for preset in &settings.shortcut_presets { + {render_shortcut_preset(preset, core.clone())} + } + } + } + section { class: "settings-card settings-card-span", + div { class: "sidebar-heading", "Shortcut Reference" } + div { class: "shortcut-groups", + for category in ["General", "Browser", "Focus", "Top-level windows", "Pane splits", "Advanced resize"] { + {render_shortcut_group(category, &settings.shortcuts)} + } + } + } + } + } +} + +fn render_theme_option( + option: &taskers_core::ThemeOptionSnapshot, + core: SharedCore, +) -> Element { + let option_id = option.id.clone(); + let select = move |_| { + core.dispatch_shell_action(ShellAction::SelectTheme { + theme_id: option_id.clone(), + }) + }; + let class = if option.active { + "theme-card theme-card-active" + } else { + "theme-card" + }; + rsx! { + button { class: "{class}", onclick: select, + div { class: "workspace-label", "{option.label}" } + div { class: "workspace-meta", "{option.family}" } + } + } +} + +fn render_shortcut_preset( + preset: &taskers_core::ShortcutPresetSnapshot, + core: SharedCore, +) -> Element { + let preset_id = preset.id.clone(); + let select = move |_| { + core.dispatch_shell_action(ShellAction::SelectShortcutPreset { + preset_id: preset_id.clone(), + }) + }; + let class = if preset.active { + "preset-card preset-card-active" + } else { + "preset-card" + }; + rsx! { + button { class: "{class}", onclick: select, + div { class: "workspace-label", "{preset.label}" } + div { class: "settings-copy", "{preset.detail}" } + } + } +} + +fn render_shortcut_group( + category: &'static str, + bindings: &[ShortcutBindingSnapshot], +) -> Element { + let entries = bindings + .iter() + .filter(|binding| binding.category == category) + .collect::>(); + if entries.is_empty() { + return rsx! {}; + } + + rsx! { + section { class: "shortcut-group", + div { class: "workspace-label", "{category}" } + div { class: "shortcut-list", + for binding in entries { + div { class: "shortcut-row", + div { + div { class: "shortcut-label", "{binding.label}" } + div { class: "settings-copy", "{binding.detail}" } } - div { class: "pane-action-cluster", - button { - class: "pane-action pane-window-action", - onclick: split_browser, - "+web" - } - button { - class: "pane-action pane-split-action", - onclick: split_terminal, - "+term" - } - button { - class: "pane-action pane-close-action", - onclick: close_pane, - "×" + div { class: "shortcut-accelerators", + if binding.accelerators.is_empty() { + span { class: "shortcut-pill shortcut-pill-muted", "Unbound" } + } else { + for accelerator in &binding.accelerators { + span { class: "shortcut-pill", "{accelerator}" } + } } } } - div { class: "surface-tabs", - div { class: "surface-tab surface-tab-active", - span { class: "surface-tab-label", "{kind_label}" } - span { class: "pane-meta", "{pane.surface.id}" } - } - } - div { class: "pane-body", - {surface_copy} - } } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 44b816a..6f64f6a 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -44,6 +44,15 @@ pub struct ThemePalette { pub action_teal: Color, } +pub fn resolve_palette(theme_id: &str) -> ThemePalette { + match theme_id { + "catppuccin-mocha" => catppuccin_mocha(), + "tokyo-night" => tokyo_night(), + "gruvbox-dark" => gruvbox_dark(), + _ => default_dark(), + } +} + pub fn default_dark() -> ThemePalette { ThemePalette { base: Color::new(0x0f, 0x11, 0x17), @@ -72,12 +81,96 @@ pub fn default_dark() -> ThemePalette { } } +fn catppuccin_mocha() -> ThemePalette { + ThemePalette { + base: Color::new(0x1e, 0x1e, 0x2e), + surface: Color::new(0x18, 0x18, 0x25), + elevated: Color::new(0x31, 0x32, 0x44), + overlay: Color::new(0x45, 0x47, 0x5a), + text: Color::new(0xcd, 0xd6, 0xf4), + text_bright: Color::new(0xe4, 0xe8, 0xfb), + text_muted: Color::new(0xa6, 0xad, 0xc8), + text_subtle: Color::new(0x93, 0x99, 0xb2), + text_dim: Color::new(0x7f, 0x84, 0x9c), + text_faint: Color::new(0x6c, 0x70, 0x86), + border: Color::new(0xff, 0xff, 0xff), + accent: Color::new(0xb4, 0xbe, 0xfe), + busy: Color::new(0x89, 0xb4, 0xfa), + completed: Color::new(0xa6, 0xe3, 0xa1), + waiting: Color::new(0x94, 0xe2, 0xd5), + error: Color::new(0xf3, 0x8b, 0xa8), + busy_text: Color::new(0xbc, 0xd3, 0xfc), + completed_text: Color::new(0xc3, 0xed, 0xbe), + waiting_text: Color::new(0xb8, 0xed, 0xe6), + error_text: Color::new(0xf7, 0xb8, 0xc8), + action_window: Color::new(0xcb, 0xa6, 0xf7), + action_split: Color::new(0x89, 0xdc, 0xeb), + action_teal: Color::new(0x94, 0xe2, 0xd5), + } +} + +fn tokyo_night() -> ThemePalette { + ThemePalette { + base: Color::new(0x1a, 0x1b, 0x26), + surface: Color::new(0x16, 0x16, 0x1e), + elevated: Color::new(0x29, 0x2e, 0x42), + overlay: Color::new(0x41, 0x48, 0x68), + text: Color::new(0xc0, 0xca, 0xf5), + text_bright: Color::new(0xdc, 0xe0, 0xf8), + text_muted: Color::new(0xa9, 0xb1, 0xd6), + text_subtle: Color::new(0x73, 0x7a, 0xa2), + text_dim: Color::new(0x56, 0x5f, 0x89), + text_faint: Color::new(0x3b, 0x42, 0x61), + border: Color::new(0xff, 0xff, 0xff), + accent: Color::new(0x7d, 0xcf, 0xff), + busy: Color::new(0x7a, 0xa2, 0xf7), + completed: Color::new(0x9e, 0xce, 0x6a), + waiting: Color::new(0x7d, 0xcf, 0xff), + error: Color::new(0xf7, 0x76, 0x8e), + busy_text: Color::new(0xb0, 0xc8, 0xfa), + completed_text: Color::new(0xc4, 0xe4, 0xa6), + waiting_text: Color::new(0xb0, 0xe3, 0xff), + error_text: Color::new(0xfa, 0xb0, 0xbc), + action_window: Color::new(0xbb, 0x9a, 0xf7), + action_split: Color::new(0x2a, 0xc3, 0xde), + action_teal: Color::new(0x1a, 0xbc, 0x9c), + } +} + +fn gruvbox_dark() -> ThemePalette { + ThemePalette { + base: Color::new(0x28, 0x28, 0x28), + surface: Color::new(0x1d, 0x20, 0x21), + elevated: Color::new(0x3c, 0x38, 0x36), + overlay: Color::new(0x50, 0x49, 0x45), + text: Color::new(0xeb, 0xdb, 0xb2), + text_bright: Color::new(0xfb, 0xf1, 0xc7), + text_muted: Color::new(0xd5, 0xc4, 0xa1), + text_subtle: Color::new(0xbd, 0xae, 0x93), + text_dim: Color::new(0xa8, 0x99, 0x84), + text_faint: Color::new(0x92, 0x83, 0x74), + border: Color::new(0xff, 0xff, 0xff), + accent: Color::new(0x83, 0xa5, 0x98), + busy: Color::new(0x83, 0xa5, 0x98), + completed: Color::new(0xb8, 0xbb, 0x26), + waiting: Color::new(0x8e, 0xc0, 0x7c), + error: Color::new(0xfb, 0x49, 0x34), + busy_text: Color::new(0xb4, 0xcf, 0xc5), + completed_text: Color::new(0xd5, 0xd7, 0x8a), + waiting_text: Color::new(0xbc, 0xdb, 0xac), + error_text: Color::new(0xfc, 0xa0, 0x9a), + action_window: Color::new(0xd3, 0x86, 0x9b), + action_split: Color::new(0x8e, 0xc0, 0x7c), + action_teal: Color::new(0x68, 0x9d, 0x6a), + } +} + fn rgba(color: Color, alpha: f32) -> String { format!("rgba({},{},{},{alpha:.2})", color.r, color.g, color.b) } pub fn generate_css(p: &ThemePalette) -> String { - let mut css = String::with_capacity(8192); + let mut css = String::with_capacity(18_000); let _ = write!( css, r#" @@ -89,466 +182,831 @@ html, body, #main {{ color: {text}; font-family: "IBM Plex Sans", "SF Pro Text", system-ui, sans-serif; }} -* {{ box-sizing: border-box; }} -button {{ font: inherit; }} + +* {{ + box-sizing: border-box; +}} + +button {{ + font: inherit; +}} + .app-shell {{ width: 100vw; height: 100vh; - background: linear-gradient(180deg, {base} 0%, {surface} 100%); - display: flex; + background: {base}; + display: grid; + grid-template-columns: 248px minmax(0, 1fr) 312px; overflow: hidden; }} -.workspace-sidebar {{ - width: 248px; - flex: 0 0 248px; + +.workspace-sidebar, +.attention-panel {{ background: {surface}; - border-right: 1px solid {border_04}; - padding: 10px 8px; display: flex; flex-direction: column; + min-height: 0; +}} + +.workspace-sidebar {{ + border-right: 1px solid {border_04}; + padding: 8px; gap: 12px; }} -.sidebar-heading {{ - font-weight: 600; - font-size: 11px; - color: {text_dim}; - letter-spacing: 0.10em; - text-transform: uppercase; + +.attention-panel {{ + border-left: 1px solid {border_04}; + padding: 10px 12px; + gap: 10px; }} + .sidebar-brand {{ - padding: 6px 8px 2px; + padding: 8px; display: flex; flex-direction: column; gap: 4px; }} + .sidebar-brand h1 {{ margin: 0; - font-size: 26px; + font-size: 24px; line-height: 1; color: {text_bright}; }} -.workspace-list {{ + +.sidebar-heading {{ + font-weight: 600; + font-size: 11px; + color: {text_dim}; + letter-spacing: 0.10em; + text-transform: uppercase; +}} + +.sidebar-nav, +.workspace-list, +.activity-list {{ display: flex; flex-direction: column; gap: 6px; }} -.workspace-button {{ + +.sidebar-nav-button, +.workspace-button, +.theme-card, +.preset-card {{ + width: 100%; padding: 0; border: 0; background: transparent; text-align: left; }} + +.sidebar-nav-button {{ + border-radius: 7px; + padding: 8px 10px; + color: {text_subtle}; +}} + +.sidebar-nav-button:hover, +.sidebar-nav-button-active {{ + background: {border_06}; + color: {text_bright}; +}} + +.sidebar-section-header {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 6px; +}} + +.workspace-add {{ + background: transparent; + color: {text_dim}; + border: 1px solid {border_10}; + border-radius: 999px; + min-width: 24px; + min-height: 24px; + padding: 0; + font-size: 16px; +}} + +.workspace-add:hover {{ + background: {waiting_10}; + color: {waiting_text}; + border-color: {waiting_25}; +}} + .workspace-item {{ - padding: 8px 9px; + padding: 8px 10px; border-radius: 8px; border: 1px solid transparent; - background: transparent; display: flex; align-items: flex-start; justify-content: space-between; - gap: 8px; - transition: background 160ms ease-in-out, border-color 160ms ease-in-out; + gap: 10px; + transition: background 140ms ease, border-color 140ms ease; }} + .workspace-button:hover .workspace-item {{ background: {border_04}; border-color: {border_10}; }} + .workspace-item-active {{ - background: {border_05}; - border-color: {border_10}; + background: {border_06}; + border-color: {border_12}; +}} + +.workspace-item-state-busy {{ + border-color: {busy_18}; +}} + +.workspace-item-state-completed {{ + border-color: {completed_18}; +}} + +.workspace-item-state-waiting {{ + border-color: {waiting_20}; }} + +.workspace-item-state-error {{ + border-color: {error_18}; +}} + .workspace-label {{ font-weight: 600; font-size: 13px; color: {text_bright}; }} + .workspace-preview {{ color: {text_subtle}; font-size: 12px; line-height: 1.35; }} -.workspace-meta {{ + +.workspace-meta, +.activity-meta, +.activity-time {{ color: {text_dim}; font-size: 11px; }} + .workspace-status-badge {{ - background: {accent_14}; - color: {busy_text}; + flex: 0 0 auto; border-radius: 999px; - padding: 2px 6px; - min-width: 18px; + padding: 3px 7px; + min-width: 22px; text-align: center; - font-size: 11px; + font-size: 10px; font-weight: 700; + background: {accent_14}; + color: {busy_text}; +}} + +.workspace-status-badge-state-busy {{ + background: {busy_16}; + color: {busy_text}; +}} + +.workspace-status-badge-state-completed {{ + background: {completed_16}; + color: {completed_text}; +}} + +.workspace-status-badge-state-waiting {{ + background: {waiting_18}; + color: {waiting_text}; }} -.runtime-card {{ + +.workspace-status-badge-state-error {{ + background: {error_16}; + color: {error_text}; +}} + +.runtime-card, +.settings-card {{ background: transparent; border: 1px solid {border_06}; - border-radius: 8px; - padding: 9px 10px; + border-radius: 9px; + padding: 10px; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; }} + +.runtime-row, +.attention-summary {{ + display: flex; + flex-direction: column; + gap: 4px; +}} + .runtime-status-row {{ display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; }} + .status-pill {{ + display: inline-flex; + align-items: center; border-radius: 999px; - padding: 3px 8px; + padding: 4px 8px; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }} -.status-pill-ready {{ + +.status-pill-inline {{ + letter-spacing: normal; + text-transform: none; + font-size: 11px; +}} + +.status-pill-ready, +.status-pill-completed {{ background: {completed_16}; color: {completed_text}; }} -.status-pill-fallback {{ + +.status-pill-fallback, +.status-pill-waiting {{ background: {waiting_18}; color: {waiting_text}; }} -.status-pill-unavailable {{ + +.status-pill-unavailable, +.status-pill-error {{ background: {error_16}; color: {error_text}; }} -.status-copy {{ + +.status-pill-busy {{ + background: {busy_16}; + color: {busy_text}; +}} + +.status-copy, +.settings-copy, +.activity-preview {{ color: {text_subtle}; font-size: 12px; line-height: 1.4; }} + .workspace-main {{ min-width: 0; - flex: 1; display: flex; flex-direction: column; + background: {base}; +}} + +.workspace-main-overview .workspace-canvas {{ + background: {border_03}; }} + .workspace-header {{ - height: 52px; - min-height: 52px; + height: 56px; + min-height: 56px; border-bottom: 1px solid {border_07}; - padding: 0 12px; + padding: 0 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: {base}; }} + +.workspace-header-main, +.workspace-header-actions, +.pane-header-main, +.pane-action-cluster, +.surface-meta, +.activity-header, +.activity-item-shell, +.shortcut-row {{ + display: flex; + align-items: center; + gap: 8px; +}} + +.workspace-header-main, +.shortcut-row {{ + justify-content: space-between; +}} + .workspace-header-title-btn {{ background: transparent; border: 0; - border-radius: 6px; - color: {text_bright}; + border-radius: 7px; + color: inherit; padding: 6px 8px; text-align: left; }} + .workspace-header-title-btn:hover {{ background: {border_06}; }} + .workspace-header-label {{ display: block; font-weight: 600; font-size: 14px; color: {text_bright}; }} + .workspace-header-meta {{ display: block; font-size: 12px; color: {text_dim}; }} -.workspace-header-actions {{ - display: flex; - align-items: center; - gap: 6px; -}} -.workspace-header-action {{ + +.workspace-header-action, +.pane-action, +.activity-action, +.shortcut-pill {{ + border: 1px solid {border_10}; + border-radius: 999px; background: transparent; - border: 0; - border-radius: 6px; - min-width: 28px; +}} + +.workspace-header-action, +.pane-action, +.activity-action {{ min-height: 28px; - color: {text_faint}; padding: 0 10px; + color: {text_subtle}; }} -.workspace-header-action:hover {{ + +.workspace-header-action:hover, +.pane-action:hover {{ background: {border_06}; - color: {text_muted}; + color: {text_bright}; }} + +.workspace-header-action-active {{ + background: {accent_14}; + color: {text_bright}; +}} + .workspace-header-action-primary {{ background: {accent_14}; color: {text_bright}; + border-color: {accent_24}; }} + .workspace-header-action-primary:hover {{ background: {accent_22}; }} -.workspace-canvas {{ + +.workspace-canvas, +.settings-canvas {{ flex: 1; min-height: 0; - padding: 14px; + padding: 16px; }} + .split-container {{ width: 100%; height: 100%; - display: flex; - gap: 12px; min-width: 0; min-height: 0; + display: flex; + gap: 12px; }} + .split-child {{ min-width: 0; min-height: 0; }} + .pane-card {{ width: 100%; height: 100%; - display: flex; - flex-direction: column; min-width: 0; min-height: 0; + display: flex; + flex-direction: column; background: {elevated}; border: 1px solid {border_07}; border-radius: 8px; overflow: hidden; }} + .pane-card-active {{ border-color: {accent_20}; }} -.pane-header {{ - background: {border_02}; - border-bottom: 1px solid {border_05}; - padding: 5px 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - transition: background 160ms ease-in-out; + +.pane-card-state-busy {{ + box-shadow: inset 0 0 0 1px {busy_10}; }} -.pane-card:hover .pane-header {{ - background: {border_04}; + +.pane-card-state-completed {{ + box-shadow: inset 0 0 0 1px {completed_10}; }} -.pane-card-active .pane-header {{ - background: {accent_06}; - border-bottom-color: {accent_15}; + +.pane-card-state-waiting {{ + box-shadow: inset 0 0 0 1px {waiting_12}; }} -.pane-header-main {{ - min-width: 0; + +.pane-card-state-error {{ + box-shadow: inset 0 0 0 1px {error_10}; +}} + +.pane-header {{ + min-height: 38px; + border-bottom: 1px solid {border_07}; + padding: 0 10px; display: flex; align-items: center; + justify-content: space-between; gap: 8px; }} -.status-dot {{ - font-size: 12px; - line-height: 1; -}} -.status-dot-normal {{ color: {text_faint}; }} -.status-dot-busy {{ color: {busy}; }} -.status-dot-completed {{ color: {completed}; }} -.status-dot-waiting {{ color: {waiting}; }} -.status-dot-error {{ color: {error}; }} + .pane-title-stack {{ min-width: 0; display: flex; flex-direction: column; gap: 1px; }} + .pane-title {{ - font-weight: 500; - color: {text_muted}; - font-size: 12px; + color: {text_bright}; + font-size: 13px; + font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} -.pane-card-active .pane-title {{ - color: {text}; -}} -.pane-meta {{ - color: {text_faint}; + +.pane-meta, +.surface-tab-label, +.shortcut-label {{ + color: {text_subtle}; font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -}} -.pane-action-cluster {{ - display: flex; - align-items: center; - gap: 4px; - background: {border_04}; - border: 1px solid {border_05}; - border-radius: 999px; - padding: 2px; }} -.pane-card-active .pane-action-cluster {{ - background: {accent_06}; - border-color: {accent_12}; -}} -.pane-action {{ - background: transparent; - border: 1px solid transparent; - border-radius: 999px; - min-width: 24px; - min-height: 22px; - padding: 0 8px; - color: {text_faint}; -}} -.pane-action:hover {{ - background: {accent_12}; - border-color: {accent_15}; - color: {text}; + +.pane-action-tab {{ + border-color: {accent_20}; }} + .pane-window-action {{ - color: {action_window}; + border-color: {action_window_22}; }} + .pane-split-action {{ - color: {action_split}; + border-color: {action_split_22}; }} -.pane-close-action:hover {{ - background: {error_18}; + +.pane-close-action {{ border-color: {error_18}; - color: {error_text}; }} + +.pane-close-action:hover {{ + background: {error_10}; + color: {error}; +}} + .surface-tabs {{ - margin: 4px 8px 6px; - min-height: 24px; + min-height: 34px; + border-bottom: 1px solid {border_06}; display: flex; - align-items: center; + align-items: stretch; gap: 6px; + padding: 6px 8px; + overflow-x: auto; + background: {border_03}; }} + .surface-tab {{ - background: {border_03}; - border: 1px solid {border_07}; - border-radius: 6px; - padding: 3px 8px; display: inline-flex; align-items: center; - gap: 7px; + gap: 6px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + padding: 5px 9px; + color: {text_muted}; + white-space: nowrap; }} + +.surface-tab:hover {{ + background: {border_06}; + border-color: {border_10}; +}} + .surface-tab-active {{ background: {accent_14}; - border-color: {accent_35}; + border-color: {accent_24}; + color: {text_bright}; }} -.surface-tab-label {{ - color: {text_muted}; - font-size: 12px; + +.surface-tab-state-busy {{ + box-shadow: inset 0 0 0 1px {busy_10}; +}} + +.surface-tab-state-completed {{ + box-shadow: inset 0 0 0 1px {completed_10}; +}} + +.surface-tab-state-waiting {{ + box-shadow: inset 0 0 0 1px {waiting_10}; }} + +.surface-tab-state-error {{ + box-shadow: inset 0 0 0 1px {error_10}; +}} + .pane-body {{ flex: 1; min-height: 0; - position: relative; - overflow: hidden; + padding: 18px; + background: {border_02}; }} + .surface-backdrop {{ width: 100%; height: 100%; - border-top: 1px solid {border_04}; - background: - linear-gradient(180deg, {overlay} 0%, {elevated} 100%); + min-height: 0; display: flex; flex-direction: column; justify-content: space-between; - padding: 16px; + gap: 14px; + border: 1px dashed {border_12}; + border-radius: 8px; + padding: 18px; + background: + linear-gradient(180deg, {overlay_16} 0%, {overlay_05} 100%), + {overlay_03}; }} + .surface-backdrop-copy {{ - max-width: 520px; display: flex; flex-direction: column; - gap: 8px; + gap: 6px; }} + .surface-backdrop-eyebrow {{ - font-weight: 600; font-size: 11px; + font-weight: 700; letter-spacing: 0.10em; text-transform: uppercase; color: {text_dim}; }} + .surface-backdrop-title {{ - font-size: 18px; - font-weight: 600; color: {text_bright}; + font-size: 22px; + font-weight: 600; }} + .surface-backdrop-note {{ + max-width: 70ch; color: {text_subtle}; font-size: 13px; line-height: 1.45; }} + .surface-meta {{ - display: flex; flex-wrap: wrap; - gap: 8px; }} -.surface-chip {{ - border-radius: 999px; - padding: 6px 10px; - background: {border_04}; - border: 1px solid {border_06}; - color: {text_subtle}; + +.surface-chip, +.shortcut-pill {{ + padding: 4px 8px; + color: {text_muted}; + font-size: 11px; +}} + +.shortcut-pill-muted {{ + opacity: 0.72; +}} + +.status-dot {{ + font-size: 10px; +}} + +.status-dot-normal {{ + color: {text_dim}; +}} + +.status-dot-busy {{ + color: {busy}; +}} + +.status-dot-completed {{ + color: {completed}; +}} + +.status-dot-waiting {{ + color: {waiting}; +}} + +.status-dot-error {{ + color: {error}; +}} + +.empty-state {{ + border: 1px dashed {border_10}; + border-radius: 8px; + padding: 12px; + color: {text_dim}; font-size: 12px; }} -@media (max-width: 960px) {{ - .workspace-sidebar {{ - display: none; + +.activity-item-shell {{ + align-items: stretch; +}} + +.activity-item-button {{ + flex: 1; + border: 0; + padding: 0; + background: transparent; + text-align: left; +}} + +.activity-item {{ + border-left: 2px solid transparent; + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 4px; +}} + +.activity-item-button:hover .activity-item {{ + background: {border_04}; +}} + +.activity-item-state-busy {{ + border-left-color: {busy_55}; +}} + +.activity-item-state-completed {{ + border-left-color: {completed_55}; +}} + +.activity-item-state-waiting {{ + border-left-color: {waiting_70}; +}} + +.activity-item-state-error {{ + border-left-color: {error_65}; +}} + +.activity-action {{ + align-self: center; + color: {text_dim}; + padding: 0 10px; +}} + +.activity-action:hover {{ + background: {waiting_10}; + color: {waiting_text}; + border-color: {waiting_25}; +}} + +.settings-grid {{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +}} + +.settings-card-span {{ + grid-column: 1 / -1; +}} + +.theme-grid, +.preset-grid {{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +}} + +.theme-card, +.preset-card {{ + border: 1px solid {border_08}; + border-radius: 8px; + padding: 10px; +}} + +.theme-card:hover, +.preset-card:hover {{ + background: {border_04}; + border-color: {border_12}; +}} + +.theme-card-active, +.preset-card-active {{ + background: {accent_12}; + border-color: {accent_24}; +}} + +.shortcut-groups {{ + display: flex; + flex-direction: column; + gap: 14px; +}} + +.shortcut-group {{ + display: flex; + flex-direction: column; + gap: 8px; +}} + +.shortcut-list {{ + display: flex; + flex-direction: column; + gap: 8px; +}} + +.shortcut-row {{ + align-items: flex-start; + border-top: 1px solid {border_06}; + padding-top: 8px; +}} + +.shortcut-row:first-child {{ + border-top: 0; + padding-top: 0; +}} + +.shortcut-accelerators {{ + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: 40%; +}} + +@media (max-width: 1180px) {{ + .app-shell {{ + grid-template-columns: 228px minmax(0, 1fr); }} - .workspace-canvas {{ - padding: 10px; + + .attention-panel {{ + display: none; }} }} "#, base = p.base.to_hex(), surface = p.surface.to_hex(), elevated = p.elevated.to_hex(), - overlay = p.overlay.to_hex(), + overlay_03 = rgba(p.overlay, 0.03), + overlay_05 = rgba(p.overlay, 0.05), + overlay_16 = rgba(p.overlay, 0.16), text = p.text.to_hex(), text_bright = p.text_bright.to_hex(), text_muted = p.text_muted.to_hex(), text_subtle = p.text_subtle.to_hex(), text_dim = p.text_dim.to_hex(), - text_faint = p.text_faint.to_hex(), - busy = p.busy.to_hex(), - completed = p.completed.to_hex(), - waiting = p.waiting.to_hex(), - error = p.error.to_hex(), - busy_text = p.busy_text.to_hex(), - completed_text = p.completed_text.to_hex(), - waiting_text = p.waiting_text.to_hex(), - error_text = p.error_text.to_hex(), - action_window = p.action_window.to_hex(), - action_split = p.action_split.to_hex(), border_02 = rgba(p.border, 0.02), border_03 = rgba(p.border, 0.03), border_04 = rgba(p.border, 0.04), - border_05 = rgba(p.border, 0.05), border_06 = rgba(p.border, 0.06), border_07 = rgba(p.border, 0.07), + border_08 = rgba(p.border, 0.08), border_10 = rgba(p.border, 0.10), - accent_06 = rgba(p.accent, 0.06), + border_12 = rgba(p.border, 0.12), accent_12 = rgba(p.accent, 0.12), accent_14 = rgba(p.accent, 0.14), - accent_15 = rgba(p.accent, 0.15), accent_20 = rgba(p.accent, 0.20), accent_22 = rgba(p.accent, 0.22), - accent_35 = rgba(p.accent, 0.35), + accent_24 = rgba(p.accent, 0.24), + busy = p.busy.to_hex(), + busy_10 = rgba(p.busy, 0.10), + busy_16 = rgba(p.busy, 0.16), + busy_18 = rgba(p.busy, 0.18), + busy_55 = rgba(p.busy, 0.55), + busy_text = p.busy_text.to_hex(), + completed = p.completed.to_hex(), + completed_10 = rgba(p.completed, 0.10), completed_16 = rgba(p.completed, 0.16), + completed_18 = rgba(p.completed, 0.18), + completed_55 = rgba(p.completed, 0.55), + completed_text = p.completed_text.to_hex(), + waiting = p.waiting.to_hex(), + waiting_10 = rgba(p.waiting, 0.10), + waiting_12 = rgba(p.waiting, 0.12), waiting_18 = rgba(p.waiting, 0.18), + waiting_20 = rgba(p.waiting, 0.20), + waiting_25 = rgba(p.waiting, 0.25), + waiting_70 = rgba(p.waiting, 0.70), + waiting_text = p.waiting_text.to_hex(), + error = p.error.to_hex(), + error_10 = rgba(p.error, 0.10), error_16 = rgba(p.error, 0.16), error_18 = rgba(p.error, 0.18), + error_65 = rgba(p.error, 0.65), + error_text = p.error_text.to_hex(), + action_window_22 = rgba(p.action_window, 0.22), + action_split_22 = rgba(p.action_split, 0.22), ); css } - -#[cfg(test)] -mod tests { - use super::{default_dark, generate_css}; - - #[test] - fn generated_css_contains_legacy_shell_landmarks() { - let css = generate_css(&default_dark()); - assert!(css.contains(".workspace-sidebar")); - assert!(css.contains(".workspace-header")); - assert!(css.contains(".pane-card")); - assert!(css.contains(".surface-tabs")); - } -} diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index ed83349..d693c1f 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -530,7 +530,7 @@ fn sync_window( format!( "syncing snapshot panes={} active={}", snapshot.portal.panes.len(), - snapshot.active_pane + snapshot.current_workspace.active_pane ), ), ); @@ -707,7 +707,7 @@ fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) ), ); - let (browser_count, terminal_count) = surface_counts(&snapshot.layout); + let (browser_count, terminal_count) = surface_counts(&snapshot.current_workspace.layout); log_diagnostic( diagnostics, DiagnosticRecord::new( @@ -718,7 +718,7 @@ fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) snapshot.portal.panes.len(), browser_count, terminal_count, - snapshot.active_pane + snapshot.current_workspace.active_pane ), ), ); @@ -728,7 +728,8 @@ fn wait_for_browser_title(core: &SharedCore, timeout: Duration) -> Option Option Option { match node { - LayoutNodeSnapshot::Pane(pane) if pane.surface.kind == SurfaceKind::Browser => { - Some(pane.surface.title.clone()) - } - LayoutNodeSnapshot::Pane(_) => None, + LayoutNodeSnapshot::Pane(pane) => pane + .surfaces + .iter() + .find(|surface| surface.id == pane.active_surface) + .or_else(|| pane.surfaces.first()) + .filter(|surface| surface.kind == SurfaceKind::Browser) + .map(|surface| surface.title.clone()), LayoutNodeSnapshot::Split { first, second, .. } => { first_browser_title(first).or_else(|| first_browser_title(second)) } @@ -751,10 +755,13 @@ fn first_browser_title(node: &LayoutNodeSnapshot) -> Option { fn surface_counts(node: &LayoutNodeSnapshot) -> (usize, usize) { match node { - LayoutNodeSnapshot::Pane(pane) => match pane.surface.kind { - SurfaceKind::Browser => (1, 0), - SurfaceKind::Terminal => (0, 1), - }, + LayoutNodeSnapshot::Pane(pane) => pane.surfaces.iter().fold( + (0usize, 0usize), + |(browser_count, terminal_count), surface| match surface.kind { + SurfaceKind::Browser => (browser_count + 1, terminal_count), + SurfaceKind::Terminal => (browser_count, terminal_count + 1), + }, + ), LayoutNodeSnapshot::Split { first, second, .. } => { let (first_browser, first_terminal) = surface_counts(first); let (second_browser, second_terminal) = surface_counts(second); From 1d50c94cb331ac661eabb98065e8e43205672035 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 13:20:54 +0100 Subject: [PATCH 20/63] refactor: bridge greenfield shell onto app state --- greenfield/Cargo.lock | 42 +- greenfield/Cargo.toml | 2 + greenfield/crates/taskers-core/Cargo.toml | 6 +- greenfield/crates/taskers-core/src/lib.rs | 2234 ++++++++------------- greenfield/crates/taskers/Cargo.toml | 2 + greenfield/crates/taskers/src/main.rs | 105 +- 6 files changed, 997 insertions(+), 1394 deletions(-) diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index 15318b1..e5f2e82 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -2391,7 +2391,9 @@ dependencies = [ "dioxus-liveview", "gtk4", "libadwaita", - "taskers-core", + "taskers-core 0.1.0-alpha.1", + "taskers-core 0.3.0", + "taskers-domain", "taskers-ghostty", "taskers-host", "taskers-paths", @@ -2401,15 +2403,47 @@ dependencies = [ "webkit6", ] +[[package]] +name = "taskers-control" +version = "0.3.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "taskers-domain", + "taskers-paths", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "taskers-core" version = "0.1.0-alpha.1" dependencies = [ - "indexmap", "parking_lot", + "taskers-control", + "taskers-core 0.3.0", + "taskers-domain", + "taskers-ghostty", + "taskers-runtime", "tokio", ] +[[package]] +name = "taskers-core" +version = "0.3.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "taskers-control", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", +] + [[package]] name = "taskers-domain" version = "0.3.0" @@ -2444,7 +2478,7 @@ version = "0.1.0-alpha.1" dependencies = [ "anyhow", "gtk4", - "taskers-core", + "taskers-core 0.1.0-alpha.1", "taskers-domain", "taskers-ghostty", "webkit6", @@ -2471,7 +2505,7 @@ name = "taskers-shell" version = "0.1.0-alpha.1" dependencies = [ "dioxus", - "taskers-core", + "taskers-core 0.1.0-alpha.1", ] [[package]] diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml index 003b28c..d629040 100644 --- a/greenfield/Cargo.toml +++ b/greenfield/Cargo.toml @@ -25,6 +25,8 @@ indexmap = "2" parking_lot = "0.12" tokio = { version = "1.50.0", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } webkit6 = { version = "0.6.1", features = ["v2_50"] } +taskers-app-core = { package = "taskers-core", path = "../crates/taskers-core" } +taskers-control = { path = "../crates/taskers-control" } taskers-core = { path = "crates/taskers-core" } taskers-domain = { path = "../crates/taskers-domain" } taskers-ghostty = { path = "../crates/taskers-ghostty" } diff --git a/greenfield/crates/taskers-core/Cargo.toml b/greenfield/crates/taskers-core/Cargo.toml index f732bd2..bffaf3a 100644 --- a/greenfield/crates/taskers-core/Cargo.toml +++ b/greenfield/crates/taskers-core/Cargo.toml @@ -6,6 +6,10 @@ repository.workspace = true version.workspace = true [dependencies] -indexmap.workspace = true parking_lot.workspace = true +taskers-app-core.workspace = true +taskers-control.workspace = true +taskers-domain.workspace = true +taskers-ghostty.workspace = true +taskers-runtime.workspace = true tokio.workspace = true diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 3e905df..c009936 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -1,34 +1,39 @@ -use indexmap::IndexMap; use parking_lot::Mutex; -use std::{collections::BTreeMap, fmt, sync::Arc}; -use tokio::sync::{broadcast, watch}; +use std::{ + collections::BTreeMap, + fmt, + path::PathBuf, + sync::Arc, +}; +use taskers_app_core::{AppState, default_session_path}; +use taskers_control::{ControlCommand, ControlResponse}; +use taskers_domain::{ + ActivityItem, AppModel, PaneKind, PaneMetadata, PaneMetadataPatch, + SplitAxis as DomainSplitAxis, SurfaceRecord, Workspace, + WorkspaceSummary as DomainWorkspaceSummary, +}; +use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; +use taskers_runtime::ShellLaunchSpec; +use tokio::sync::watch; + +pub use taskers_domain::{PaneId, SurfaceId, WorkspaceId}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct WorkspaceId(pub u64); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct PaneId(pub u64); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SurfaceId(pub u64); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ActivityId(pub u64); - -macro_rules! impl_display_id { - ($name:ident, $prefix:literal) => { - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, concat!($prefix, "-{}"), self.0) - } - } - }; +pub struct ActivityId { + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub surface_id: SurfaceId, } -impl_display_id!(WorkspaceId, "workspace"); -impl_display_id!(PaneId, "pane"); -impl_display_id!(SurfaceId, "surface"); -impl_display_id!(ActivityId, "activity"); +impl fmt::Display for ActivityId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "activity-{}-{}-{}", + self.workspace_id, self.pane_id, self.surface_id + ) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplitAxis { @@ -36,6 +41,15 @@ pub enum SplitAxis { Vertical, } +impl SplitAxis { + fn from_domain(axis: DomainSplitAxis) -> Self { + match axis { + DomainSplitAxis::Horizontal => Self::Horizontal, + DomainSplitAxis::Vertical => Self::Vertical, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SurfaceKind { Terminal, @@ -49,9 +63,16 @@ impl SurfaceKind { Self::Browser => "Browser", } } + + fn from_domain(kind: &PaneKind) -> Self { + match kind { + PaneKind::Terminal => Self::Terminal, + PaneKind::Browser => Self::Browser, + } + } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum AttentionState { Normal, Busy, @@ -82,21 +103,24 @@ impl AttentionState { } } +impl From for AttentionState { + fn from(value: taskers_domain::AttentionState) -> Self { + match value { + taskers_domain::AttentionState::Normal => Self::Normal, + taskers_domain::AttentionState::Busy => Self::Busy, + taskers_domain::AttentionState::Completed => Self::Completed, + taskers_domain::AttentionState::WaitingInput => Self::WaitingInput, + taskers_domain::AttentionState::Error => Self::Error, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShellSection { Workspace, Settings, } -impl ShellSection { - pub fn label(self) -> &'static str { - match self { - Self::Workspace => "Workspace", - Self::Settings => "Settings", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShortcutPreset { Balanced, @@ -115,18 +139,18 @@ impl ShortcutPreset { pub fn label(self) -> &'static str { match self { - Self::Balanced => "Balanced Defaults", - Self::PowerUser => "Power User Defaults", + Self::Balanced => "Apply Balanced Defaults", + Self::PowerUser => "Apply Power User Defaults", } } pub fn detail(self) -> &'static str { match self { Self::Balanced => { - "Keep common focus, overview, browser, and split actions bound." + "Keep common focus, top-level window, split, overview, and close actions bound." } Self::PowerUser => { - "Restore dense direction and resize bindings for full keyboard-driven control." + "Restore the dense direction and resize bindings for full keyboard-driven control." } } } @@ -184,31 +208,25 @@ impl Default for RuntimeStatus { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalDefaults { - pub cols: u16, - pub rows: u16, - pub command_argv: Vec, - pub env: BTreeMap, +#[derive(Clone)] +pub struct BootstrapModel { + pub app_state: AppState, + pub runtime_status: RuntimeStatus, + pub selected_theme_id: String, + pub selected_shortcut_preset: ShortcutPreset, } -impl Default for TerminalDefaults { +impl Default for BootstrapModel { fn default() -> Self { Self { - cols: 120, - rows: 40, - command_argv: vec!["/bin/sh".into()], - env: BTreeMap::new(), + app_state: default_preview_app_state(), + runtime_status: RuntimeStatus::default(), + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::Balanced, } } } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct BootstrapModel { - pub runtime_status: RuntimeStatus, - pub terminal_defaults: TerminalDefaults, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PixelSize { pub width: i32, @@ -462,276 +480,68 @@ pub enum ShellAction { } #[derive(Debug, Clone)] -struct SurfaceRecord { - id: SurfaceId, - kind: SurfaceKind, - title: String, - url: Option, - cwd: Option, - attention: AttentionState, -} - -#[derive(Debug, Clone)] -struct PaneRecord { - id: PaneId, - active_surface: SurfaceId, - surfaces: IndexMap, -} - -#[derive(Debug, Clone)] -enum LayoutNode { - Leaf(PaneId), - Split { - axis: SplitAxis, - ratio_millis: u16, - first: Box, - second: Box, - }, -} - -impl LayoutNode { - fn split_leaf( - &mut self, - target: PaneId, - axis: SplitAxis, - new_pane: PaneId, - ratio_millis: u16, - ) -> bool { - match self { - Self::Leaf(existing) if *existing == target => { - let old = *existing; - *self = Self::Split { - axis, - ratio_millis, - first: Box::new(Self::Leaf(old)), - second: Box::new(Self::Leaf(new_pane)), - }; - true - } - Self::Split { first, second, .. } => { - first.split_leaf(target, axis, new_pane, ratio_millis) - || second.split_leaf(target, axis, new_pane, ratio_millis) - } - Self::Leaf(_) => false, - } - } - - fn remove_leaf(self, target: PaneId) -> Option { - match self { - Self::Leaf(existing) if existing == target => None, - Self::Leaf(existing) => Some(Self::Leaf(existing)), - Self::Split { - axis, - ratio_millis, - first, - second, - } => { - let first = first.remove_leaf(target); - let second = second.remove_leaf(target); - match (first, second) { - (Some(first), Some(second)) => Some(Self::Split { - axis, - ratio_millis, - first: Box::new(first), - second: Box::new(second), - }), - (Some(first), None) => Some(first), - (None, Some(second)) => Some(second), - (None, None) => None, - } - } - } - } - - fn first_leaf_id(&self) -> PaneId { - match self { - Self::Leaf(pane_id) => *pane_id, - Self::Split { first, .. } => first.first_leaf_id(), - } - } -} - -#[derive(Debug, Clone)] -struct WorkspaceRecord { - id: WorkspaceId, - title: String, - active_pane: PaneId, - panes: IndexMap, - layout: LayoutNode, -} - -#[derive(Debug, Clone)] -struct ActivityRecord { - id: ActivityId, - title: String, - preview: String, - meta: String, - attention: AttentionState, - workspace_id: WorkspaceId, - pane_id: Option, - surface_id: Option, - unread: bool, -} - -#[derive(Debug, Clone)] -struct AppModel { +struct UiState { section: ShellSection, overview_mode: bool, - active_workspace: WorkspaceId, - workspaces: IndexMap, - activity: IndexMap, selected_theme_id: String, selected_shortcut_preset: ShortcutPreset, window_size: PixelSize, } -#[derive(Debug)] +#[derive(Clone)] struct TaskersCore { - next_id: u64, + app_state: AppState, revision: u64, metrics: LayoutMetrics, - model: AppModel, runtime_status: RuntimeStatus, - terminal_defaults: TerminalDefaults, + ui: UiState, } impl TaskersCore { fn with_bootstrap(bootstrap: BootstrapModel) -> Self { - let metrics = LayoutMetrics::default(); - let mut core = Self { - next_id: 1, - revision: 1, - metrics, - model: AppModel { + let revision = bootstrap.app_state.revision().max(1); + Self { + app_state: bootstrap.app_state, + revision, + metrics: LayoutMetrics::default(), + runtime_status: bootstrap.runtime_status, + ui: UiState { section: ShellSection::Workspace, overview_mode: false, - active_workspace: WorkspaceId(0), - workspaces: IndexMap::new(), - activity: IndexMap::new(), - selected_theme_id: "dark".into(), - selected_shortcut_preset: ShortcutPreset::Balanced, + selected_theme_id: bootstrap.selected_theme_id, + selected_shortcut_preset: bootstrap.selected_shortcut_preset, window_size: PixelSize::new(1440, 900), }, - runtime_status: bootstrap.runtime_status, - terminal_defaults: bootstrap.terminal_defaults, - }; - - let main = core.seed_workspace( - "Main", - vec![ - SeedSurface::terminal("Agent shell", None, AttentionState::Busy).with_secondary( - SeedSurface::browser( - "Docs", - "https://taskers.app/docs", - AttentionState::Completed, - ), - ), - SeedPane::new(SeedSurface::browser( - "Preview", - "https://dioxuslabs.com/learn/0.7/", - AttentionState::Normal, - )), - ], - ); - let research = core.seed_workspace( - "Research", - vec![SeedPane::new(SeedSurface::browser( - "Taskers docs", - "https://taskers.invalid/docs", - AttentionState::WaitingInput, - ))], - ); - let release = core.seed_workspace( - "Release", - vec![SeedPane::new(SeedSurface::terminal( - "Release checks", - Some("/home/notes/Projects/taskers"), - AttentionState::Error, - ))], - ); - - core.model.active_workspace = main; - core.seed_activity( - "Embedded terminal host", - match &core.runtime_status.terminal_host { - RuntimeCapability::Ready => "Embedded Ghostty passed the last startup probe.".into(), - RuntimeCapability::Fallback { message } => { - format!("Shell is still live, but terminal startup fell back: {message}") - } - RuntimeCapability::Unavailable { message } => { - format!("Embedded terminal host is unavailable: {message}") - } - }, - "Linux host runtime", - match core.runtime_status.terminal_host { - RuntimeCapability::Ready => AttentionState::Completed, - RuntimeCapability::Fallback { .. } => AttentionState::WaitingInput, - RuntimeCapability::Unavailable { .. } => AttentionState::Error, - }, - main, - None, - None, - ); - core.seed_activity( - "Research workspace waiting", - "A browser review is staged in the Research workspace.", - "Workspace Research · browser", - AttentionState::WaitingInput, - research, - None, - None, - ); - core.seed_activity( - "Release checks need attention", - "The release terminal recorded a failing verification run.", - "Workspace Release · terminal", - AttentionState::Error, - release, - None, - None, - ); - - core + } } fn revision(&self) -> u64 { self.revision } - fn current_workspace(&self) -> &WorkspaceRecord { - self.model - .workspaces - .get(&self.model.active_workspace) - .expect("active workspace should exist") - } - fn snapshot(&self) -> ShellSnapshot { - let workspace = self.current_workspace(); + let model = self.app_state.snapshot_model(); + let workspace_id = model + .active_workspace_id() + .expect("active workspace should exist"); + let workspace = model + .workspaces + .get(&workspace_id) + .expect("active workspace should exist"); + let active_window = workspace + .active_window_record() + .expect("active workspace window should exist"); let content = self.content_frame(); - let portal = SurfacePortalPlan { - window: Frame::new( - 0, - 0, - self.model.window_size.width, - self.model.window_size.height, - ), - content, - panes: if matches!(self.model.section, ShellSection::Workspace) { - self.collect_surface_plans(workspace, &workspace.layout, content) - } else { - Vec::new() - }, - }; ShellSnapshot { revision: self.revision, - section: self.model.section, - overview_mode: self.model.overview_mode, - workspaces: self.workspace_summaries(), + section: self.ui.section, + overview_mode: self.ui.overview_mode, + workspaces: self.workspace_summaries(&model), current_workspace: WorkspaceViewSnapshot { - id: workspace.id, - title: workspace.title.clone(), - attention: self.workspace_attention(workspace.id), + id: workspace_id, + title: workspace.label.clone(), + attention: workspace_attention(workspace), pane_count: workspace.panes.len(), surface_count: workspace .panes @@ -739,977 +549,572 @@ impl TaskersCore { .map(|pane| pane.surfaces.len()) .sum(), active_pane: workspace.active_pane, - layout: self.snapshot_layout(workspace, &workspace.layout), + layout: self.snapshot_layout(workspace, &active_window.layout), + }, + activity: self.activity_snapshot(&model), + portal: SurfacePortalPlan { + window: Frame::new( + 0, + 0, + self.ui.window_size.width, + self.ui.window_size.height, + ), + content, + panes: if matches!(self.ui.section, ShellSection::Workspace) { + self.collect_surface_plans(workspace_id, workspace, &active_window.layout, content) + } else { + Vec::new() + }, }, - activity: self.activity_snapshot(), - portal, metrics: self.metrics, runtime_status: self.runtime_status.clone(), settings: self.settings_snapshot(), } } - fn workspace_summaries(&self) -> Vec { - self.model - .workspaces - .values() - .map(|workspace| WorkspaceSummary { - id: workspace.id, - title: workspace.title.clone(), - preview: self.workspace_preview(workspace), - active: workspace.id == self.model.active_workspace, - pane_count: workspace.panes.len(), - surface_count: workspace - .panes - .values() - .map(|pane| pane.surfaces.len()) - .sum(), - unread_activity: self - .model - .activity - .values() - .filter(|item| item.workspace_id == workspace.id && item.unread) - .count(), - attention: self.workspace_attention(workspace.id), - }) - .collect() - } - - fn workspace_preview(&self, workspace: &WorkspaceRecord) -> String { - let pane = workspace - .panes - .get(&workspace.active_pane) - .or_else(|| workspace.panes.values().next()); - let Some(pane) = pane else { - return "No surfaces".into(); - }; - let surface = pane - .surfaces - .get(&pane.active_surface) - .or_else(|| pane.surfaces.values().next()) - .expect("pane should have at least one surface"); - match surface.kind { - SurfaceKind::Terminal => surface - .cwd - .clone() - .unwrap_or_else(|| "Embedded terminal".into()), - SurfaceKind::Browser => surface - .url - .clone() - .unwrap_or_else(|| "Native browser view".into()), - } - } - - fn workspace_attention(&self, workspace_id: WorkspaceId) -> AttentionState { - let pane_attention = self - .model - .workspaces - .get(&workspace_id) - .map(|workspace| { - workspace - .panes - .values() - .flat_map(|pane| pane.surfaces.values().map(|surface| surface.attention)) - .fold(AttentionState::Normal, strongest_attention) - }) - .unwrap_or(AttentionState::Normal); - - self.model - .activity - .values() - .filter(|item| item.workspace_id == workspace_id && item.unread) - .map(|item| item.attention) - .fold(pane_attention, strongest_attention) - } - - fn activity_snapshot(&self) -> Vec { - self.model - .activity - .values() - .rev() - .map(|item| ActivityItemSnapshot { - id: item.id, - title: item.title.clone(), - preview: item.preview.clone(), - meta: item.meta.clone(), - attention: item.attention, - workspace_id: item.workspace_id, - pane_id: item.pane_id, - surface_id: item.surface_id, - unread: item.unread, - }) - .collect() + fn content_frame(&self) -> Frame { + let metrics = self.metrics; + let width = (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width) + .max(640); + let height = self.ui.window_size.height.max(320); + Frame::new(metrics.sidebar_width, 0, width, height) } fn settings_snapshot(&self) -> SettingsSnapshot { SettingsSnapshot { - selected_theme_id: self.model.selected_theme_id.clone(), - theme_options: builtin_theme_options(&self.model.selected_theme_id), + selected_theme_id: self.ui.selected_theme_id.clone(), + theme_options: builtin_theme_options(&self.ui.selected_theme_id), shortcut_presets: ShortcutPreset::ALL .into_iter() .map(|preset| ShortcutPresetSnapshot { id: preset.id().into(), label: preset.label().into(), detail: preset.detail().into(), - active: preset == self.model.selected_shortcut_preset, + active: preset == self.ui.selected_shortcut_preset, }) .collect(), - shortcuts: shortcut_bindings(self.model.selected_shortcut_preset), + shortcuts: shortcut_bindings(self.ui.selected_shortcut_preset), } } + fn workspace_summaries(&self, model: &AppModel) -> Vec { + let active_window = model.active_window; + model + .workspace_summaries(active_window) + .unwrap_or_default() + .into_iter() + .map(|summary| WorkspaceSummary { + id: summary.workspace_id, + title: summary.label.clone(), + preview: workspace_preview(&summary), + active: model.active_workspace_id() == Some(summary.workspace_id), + pane_count: model + .workspaces + .get(&summary.workspace_id) + .map(|workspace| workspace.panes.len()) + .unwrap_or_default(), + surface_count: model + .workspaces + .get(&summary.workspace_id) + .map(workspace_surface_count) + .unwrap_or_default(), + unread_activity: summary.unread_count, + attention: summary.display_attention.into(), + }) + .collect() + } + + fn activity_snapshot(&self, model: &AppModel) -> Vec { + model.activity_items() + .into_iter() + .map(|item| ActivityItemSnapshot { + id: ActivityId { + workspace_id: item.workspace_id, + pane_id: item.pane_id, + surface_id: item.surface_id, + }, + title: activity_title(model, &item), + preview: compact_preview(&item.message), + meta: activity_context_line(model, &item), + attention: item.state.into(), + workspace_id: item.workspace_id, + pane_id: Some(item.pane_id), + surface_id: Some(item.surface_id), + unread: true, + }) + .collect() + } + fn snapshot_layout( &self, - workspace: &WorkspaceRecord, - node: &LayoutNode, + workspace: &Workspace, + node: &taskers_domain::LayoutNode, ) -> LayoutNodeSnapshot { match node { - LayoutNode::Leaf(pane_id) => { - let pane = workspace - .panes - .get(pane_id) - .expect("layout pane should exist"); - let surfaces = pane - .surfaces - .values() - .map(|surface| SurfaceSnapshot { - id: surface.id, - kind: surface.kind, - title: surface.title.clone(), - url: surface.url.clone(), - cwd: surface.cwd.clone(), - attention: surface.attention, - }) - .collect::>(); - - LayoutNodeSnapshot::Pane(PaneSnapshot { - id: pane.id, - active: pane.id == workspace.active_pane, - attention: pane_attention(pane), - active_surface: pane.active_surface, - surfaces, - }) - } - LayoutNode::Split { + taskers_domain::LayoutNode::Leaf { pane_id } => LayoutNodeSnapshot::Pane( + self.pane_snapshot( + workspace, + workspace + .panes + .get(pane_id) + .expect("layout leaf should reference a pane"), + ), + ), + taskers_domain::LayoutNode::Split { axis, - ratio_millis, + ratio, first, second, } => LayoutNodeSnapshot::Split { - axis: *axis, - ratio: f32::from(*ratio_millis) / 1000.0, + axis: SplitAxis::from_domain(*axis), + ratio: f32::from(*ratio) / 1000.0, first: Box::new(self.snapshot_layout(workspace, first)), second: Box::new(self.snapshot_layout(workspace, second)), }, } } - fn content_frame(&self) -> Frame { - let metrics = self.metrics; - let padding = metrics.workspace_padding; - let x = metrics.sidebar_width + padding; - let y = metrics.toolbar_height + padding; - let width = (self.model.window_size.width - - metrics.sidebar_width - - metrics.activity_width - - (padding * 2)) - .max(360); - let height = - (self.model.window_size.height - metrics.toolbar_height - (padding * 2)).max(240); - Frame::new(x, y, width, height) + fn pane_snapshot(&self, workspace: &Workspace, pane: &taskers_domain::PaneRecord) -> PaneSnapshot { + PaneSnapshot { + id: pane.id, + active: workspace.active_pane == pane.id, + attention: pane.highest_attention().into(), + active_surface: pane.active_surface, + surfaces: pane + .surfaces + .values() + .map(|surface| SurfaceSnapshot { + id: surface.id, + kind: SurfaceKind::from_domain(&surface.kind), + title: display_surface_title(surface), + url: normalized_surface_url(surface), + cwd: normalized_cwd(&surface.metadata), + attention: surface.attention.into(), + }) + .collect(), + } } fn collect_surface_plans( &self, - workspace: &WorkspaceRecord, - node: &LayoutNode, + workspace_id: WorkspaceId, + workspace: &Workspace, + node: &taskers_domain::LayoutNode, frame: Frame, ) -> Vec { - let mut panes = Vec::new(); - self.collect_surface_plans_into(workspace, node, frame, &mut panes); - panes - } - - fn collect_surface_plans_into( - &self, - workspace: &WorkspaceRecord, - node: &LayoutNode, - frame: Frame, - out: &mut Vec, - ) { match node { - LayoutNode::Leaf(pane_id) => { - if let Some(pane) = workspace.panes.get(pane_id) { - if let Some(surface) = pane - .surfaces - .get(&pane.active_surface) - .or_else(|| pane.surfaces.values().next()) - { - out.push(PortalSurfacePlan { - pane_id: pane.id, - surface_id: surface.id, - active: pane.id == workspace.active_pane, - frame: frame - .inset_top(self.metrics.pane_header_height + self.metrics.surface_tab_height), - mount: self.mount_spec_for(workspace.id, pane.id, surface), - }); - } - } - } - LayoutNode::Split { + taskers_domain::LayoutNode::Leaf { pane_id } => workspace + .panes + .get(pane_id) + .and_then(|pane| { + let active_surface = pane.active_surface()?; + Some(PortalSurfacePlan { + pane_id: pane.id, + surface_id: active_surface.id, + active: workspace.active_pane == pane.id, + frame: pane_body_frame(frame, self.metrics), + mount: self.mount_spec_for_active_surface(workspace_id, pane, active_surface), + }) + }) + .into_iter() + .collect(), + taskers_domain::LayoutNode::Split { axis, - ratio_millis, + ratio, first, second, } => { let (first_frame, second_frame) = - split_frame(frame, *axis, *ratio_millis, self.metrics.split_gap); - self.collect_surface_plans_into(workspace, first, first_frame, out); - self.collect_surface_plans_into(workspace, second, second_frame, out); + split_frame(frame, SplitAxis::from_domain(*axis), *ratio, self.metrics.split_gap); + let mut plans = + self.collect_surface_plans(workspace_id, workspace, first, first_frame); + plans.extend(self.collect_surface_plans( + workspace_id, + workspace, + second, + second_frame, + )); + plans } } } - fn mount_spec_for( + fn mount_spec_for_active_surface( &self, workspace_id: WorkspaceId, - pane_id: PaneId, - surface: &SurfaceRecord, + pane: &taskers_domain::PaneRecord, + active_surface: &SurfaceRecord, ) -> SurfaceMountSpec { - match surface.kind { - SurfaceKind::Browser => SurfaceMountSpec::Browser(BrowserMountSpec { - url: surface - .url - .clone() - .unwrap_or_else(|| "https://dioxuslabs.com/learn/0.7/".into()), - }), - SurfaceKind::Terminal => { - let mut env = self.terminal_defaults.env.clone(); - env.insert("TASKERS_PANE_ID".into(), pane_id.to_string()); - env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); - env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); - SurfaceMountSpec::Terminal(TerminalMountSpec { - title: surface.title.clone(), - cwd: surface.cwd.clone(), - cols: self.terminal_defaults.cols, - rows: self.terminal_defaults.rows, - command_argv: self.terminal_defaults.command_argv.clone(), - env, - }) - } - } + let descriptor = self + .app_state + .surface_descriptor_for_pane(workspace_id, pane.id) + .unwrap_or_else(|_| fallback_surface_descriptor(active_surface)); + mount_spec_from_descriptor(active_surface, descriptor) } - fn split_pane(&mut self, target: PaneId, kind: SurfaceKind, axis: SplitAxis) -> bool { - let workspace_id = self.model.active_workspace; - let new_pane = self.make_pane( - kind, - default_surface_title(kind), - default_surface_url(kind), - None, - initial_attention_for(kind), - ); - let pane_id = new_pane.id; - let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { - return false; - }; - if !workspace.panes.contains_key(&target) { + fn set_window_size(&mut self, size: PixelSize) -> bool { + if self.ui.window_size == size { return false; } - workspace.panes.insert(pane_id, new_pane); - if workspace.layout.split_leaf(target, axis, pane_id, 500) { - workspace.active_pane = pane_id; - self.revision += 1; - true - } else { - let _ = workspace.panes.shift_remove(&pane_id); - false - } - } - - fn add_surface_to_pane(&mut self, target: PaneId, kind: SurfaceKind) -> bool { - let workspace_id = self.model.active_workspace; - let surface = self.make_surface_record( - kind, - default_surface_title(kind), - default_surface_url(kind), - None, - initial_attention_for(kind), - ); - let surface_id = surface.id; - let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { - return false; - }; - let Some(pane) = workspace.panes.get_mut(&target) else { - return false; - }; - pane.surfaces.insert(surface_id, surface); - pane.active_surface = surface_id; - workspace.active_pane = target; - self.revision += 1; + self.ui.window_size = size; + self.bump_local_revision(); true } - fn focus_pane(&mut self, pane_id: PaneId) -> bool { - let workspace_id = self.model.active_workspace; - let active_surface_id = { - let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { - return false; - }; - if !workspace.panes.contains_key(&pane_id) { - return false; - } - workspace.active_pane = pane_id; - if let Some(pane) = workspace.panes.get_mut(&pane_id) { - if let Some(surface) = pane.surfaces.get_mut(&pane.active_surface) { - surface.attention = AttentionState::Normal; - } - Some(pane.active_surface) - } else { - None + fn apply_host_event(&mut self, event: HostEvent) -> bool { + match event { + HostEvent::PaneFocused { pane_id } => self.focus_pane_by_id(pane_id), + HostEvent::SurfaceClosed { pane_id, surface_id } => { + self.close_surface_by_id(pane_id, surface_id) } - }; - self.dismiss_surface_activity(workspace_id, Some(pane_id), active_surface_id); - self.revision += 1; - true + HostEvent::SurfaceTitleChanged { surface_id, title } => self.update_surface_metadata( + surface_id, + PaneMetadataPatch { + title: Some(title), + ..PaneMetadataPatch::default() + }, + ), + HostEvent::SurfaceUrlChanged { surface_id, url } => self.update_surface_metadata( + surface_id, + PaneMetadataPatch { + url: Some(url), + ..PaneMetadataPatch::default() + }, + ), + HostEvent::SurfaceCwdChanged { surface_id, cwd } => self.update_surface_metadata( + surface_id, + PaneMetadataPatch { + cwd: Some(cwd), + ..PaneMetadataPatch::default() + }, + ), + } } - fn focus_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { - let workspace_id = self.model.active_workspace; - { - let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { - return false; - }; - let Some(pane) = workspace.panes.get_mut(&pane_id) else { - return false; - }; - if !pane.surfaces.contains_key(&surface_id) { - return false; + fn dispatch_shell_action(&mut self, action: ShellAction) -> bool { + match action { + ShellAction::ShowSection { section } => { + if self.ui.section == section { + return false; + } + self.ui.section = section; + self.bump_local_revision(); + true + } + ShellAction::ToggleOverview => { + self.ui.overview_mode = !self.ui.overview_mode; + self.bump_local_revision(); + true + } + ShellAction::FocusWorkspace { workspace_id } => self.focus_workspace(workspace_id), + ShellAction::CreateWorkspace => self.create_workspace(), + ShellAction::SplitBrowser { pane_id } => self.split_with_kind(pane_id, PaneKind::Browser), + ShellAction::SplitTerminal { pane_id } => self.split_with_kind(pane_id, PaneKind::Terminal), + ShellAction::AddBrowserSurface { pane_id } => { + self.add_surface_to_pane(pane_id, PaneKind::Browser) + } + ShellAction::AddTerminalSurface { pane_id } => { + self.add_surface_to_pane(pane_id, PaneKind::Terminal) } - pane.active_surface = surface_id; - workspace.active_pane = pane_id; - if let Some(surface) = pane.surfaces.get_mut(&surface_id) { - surface.attention = AttentionState::Normal; + ShellAction::FocusPane { pane_id } => self.focus_pane_by_id(pane_id), + ShellAction::FocusSurface { pane_id, surface_id } => { + self.focus_surface_by_id(pane_id, surface_id) + } + ShellAction::CloseSurface { pane_id, surface_id } => { + self.close_surface_by_id(pane_id, surface_id) + } + ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), + ShellAction::SelectTheme { theme_id } => { + if self.ui.selected_theme_id == theme_id { + return false; + } + self.ui.selected_theme_id = theme_id; + self.bump_local_revision(); + true + } + ShellAction::SelectShortcutPreset { preset_id } => { + let Some(preset) = ShortcutPreset::parse(&preset_id) else { + return false; + }; + if self.ui.selected_shortcut_preset == preset { + return false; + } + self.ui.selected_shortcut_preset = preset; + self.bump_local_revision(); + true } } - self.dismiss_surface_activity(workspace_id, Some(pane_id), Some(surface_id)); - self.revision += 1; - true } fn focus_workspace(&mut self, workspace_id: WorkspaceId) -> bool { - if self.model.workspaces.contains_key(&workspace_id) - && self.model.active_workspace != workspace_id - { - self.model.active_workspace = workspace_id; - self.model.section = ShellSection::Workspace; - self.revision += 1; - true - } else { - false + let mut changed = false; + if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { + changed |= self.dispatch_control(ControlCommand::SwitchWorkspace { + window_id: None, + workspace_id, + }); } + if self.ui.section != ShellSection::Workspace { + self.ui.section = ShellSection::Workspace; + self.bump_local_revision(); + changed = true; + } + changed } fn create_workspace(&mut self) -> bool { - let title = format!("Workspace {}", self.model.workspaces.len() + 1); - let workspace_id = self.seed_workspace( - &title, - vec![SeedPane::new(SeedSurface::terminal( - "Agent shell", - None, - initial_attention_for(SurfaceKind::Terminal), - ))], - ); - self.model.active_workspace = workspace_id; - self.model.section = ShellSection::Workspace; - self.revision += 1; - true + let label = next_workspace_label(&self.app_state.snapshot_model()); + self.dispatch_control(ControlCommand::CreateWorkspace { label }) } - fn set_section(&mut self, section: ShellSection) -> bool { - if self.model.section != section { - self.model.section = section; - self.revision += 1; - true - } else { - false - } - } - - fn toggle_overview(&mut self) -> bool { - self.model.overview_mode = !self.model.overview_mode; - self.revision += 1; - true - } - - fn dismiss_activity(&mut self, activity_id: ActivityId) -> bool { - if self.model.activity.shift_remove(&activity_id).is_some() { - self.revision += 1; - true - } else { - false - } - } + fn split_with_kind(&mut self, pane_id: Option, kind: PaneKind) -> bool { + let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { + return false; + }; - fn select_theme(&mut self, theme_id: String) -> bool { - if builtin_theme_options("") - .iter() - .any(|option| option.id == theme_id) - && self.model.selected_theme_id != theme_id - { - self.model.selected_theme_id = theme_id; - self.revision += 1; - true - } else { - false - } - } + let response = match self.dispatch_control_with_response(ControlCommand::SplitPane { + workspace_id, + pane_id: Some(target_pane_id), + axis: DomainSplitAxis::Horizontal, + }) { + Some(response) => response, + None => return false, + }; - fn select_shortcut_preset(&mut self, preset_id: String) -> bool { - let Some(preset) = ShortcutPreset::parse(&preset_id) else { + let ControlResponse::PaneSplit { pane_id: new_pane_id } = response else { return false; }; - if self.model.selected_shortcut_preset != preset { - self.model.selected_shortcut_preset = preset; - self.revision += 1; - true - } else { - false - } - } - fn set_window_size(&mut self, size: PixelSize) -> bool { - if self.model.window_size != size { - self.model.window_size = size; - self.revision += 1; - true - } else { - false + if kind == PaneKind::Terminal { + return true; } - } - fn apply_host_event(&mut self, event: HostEvent) -> bool { - match event { - HostEvent::PaneFocused { pane_id } => self.focus_pane(pane_id), - HostEvent::SurfaceClosed { - pane_id, - surface_id, - } => self.close_surface(pane_id, surface_id), - HostEvent::SurfaceTitleChanged { surface_id, title } => { - self.update_surface(surface_id, |surface| { - if surface.title != title { - surface.title = title.clone(); - surface.attention = AttentionState::Completed; - true - } else { - false - } - }); - self.push_surface_activity( - surface_id, - "Surface title updated", - title, - AttentionState::Completed, - ) - } - HostEvent::SurfaceUrlChanged { surface_id, url } => { - self.update_surface(surface_id, |surface| { - if surface.url.as_deref() != Some(url.as_str()) { - surface.url = Some(url.clone()); - surface.attention = AttentionState::Busy; - true - } else { - false - } - }); - self.push_surface_activity( - surface_id, - "Browser navigated", - url, - AttentionState::Busy, - ) - } - HostEvent::SurfaceCwdChanged { surface_id, cwd } => { - self.update_surface(surface_id, |surface| { - if surface.cwd.as_deref() != Some(cwd.as_str()) { - surface.cwd = Some(cwd.clone()); - surface.attention = AttentionState::Busy; - true - } else { - false - } - }); - self.push_surface_activity( - surface_id, - "Terminal changed directory", - cwd, - AttentionState::Busy, - ) - } + let placeholder_surface = self + .app_state + .snapshot_model() + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&new_pane_id)) + .map(|pane| pane.active_surface); + + let created = self.dispatch_control(ControlCommand::CreateSurface { + workspace_id, + pane_id: new_pane_id, + kind, + }); + if !created { + return false; } - } - fn dispatch_shell_action(&mut self, action: ShellAction) -> bool { - match action { - ShellAction::ShowSection { section } => self.set_section(section), - ShellAction::ToggleOverview => self.toggle_overview(), - ShellAction::FocusWorkspace { workspace_id } => self.focus_workspace(workspace_id), - ShellAction::CreateWorkspace => self.create_workspace(), - ShellAction::SplitBrowser { pane_id } => self.split_pane( - pane_id.unwrap_or(self.current_workspace().active_pane), - SurfaceKind::Browser, - SplitAxis::Horizontal, - ), - ShellAction::SplitTerminal { pane_id } => self.split_pane( - pane_id.unwrap_or(self.current_workspace().active_pane), - SurfaceKind::Terminal, - SplitAxis::Vertical, - ), - ShellAction::AddBrowserSurface { pane_id } => self.add_surface_to_pane( - pane_id.unwrap_or(self.current_workspace().active_pane), - SurfaceKind::Browser, - ), - ShellAction::AddTerminalSurface { pane_id } => self.add_surface_to_pane( - pane_id.unwrap_or(self.current_workspace().active_pane), - SurfaceKind::Terminal, - ), - ShellAction::FocusPane { pane_id } => self.focus_pane(pane_id), - ShellAction::FocusSurface { - pane_id, - surface_id, - } => self.focus_surface(pane_id, surface_id), - ShellAction::CloseSurface { - pane_id, - surface_id, - } => self.close_surface(pane_id, surface_id), - ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), - ShellAction::SelectTheme { theme_id } => self.select_theme(theme_id), - ShellAction::SelectShortcutPreset { preset_id } => { - self.select_shortcut_preset(preset_id) - } + if let Some(placeholder_surface) = placeholder_surface { + let _ = self.dispatch_control(ControlCommand::CloseSurface { + workspace_id, + pane_id: new_pane_id, + surface_id: placeholder_surface, + }); } + true } - fn close_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { - let workspace_id = self.model.active_workspace; - let mut needs_replacement = false; - { - let Some(workspace) = self.model.workspaces.get_mut(&workspace_id) else { - return false; - }; - let Some(pane) = workspace.panes.get(&pane_id) else { - return false; - }; - if !pane.surfaces.contains_key(&surface_id) { - return false; - } - - if pane.surfaces.len() > 1 { - let pane = workspace - .panes - .get_mut(&pane_id) - .expect("pane should still exist"); - pane.surfaces.shift_remove(&surface_id); - if pane.active_surface == surface_id { - pane.active_surface = *pane - .surfaces - .keys() - .next() - .expect("pane should still have a surface"); - } - workspace.active_pane = pane_id; - } else { - workspace.panes.shift_remove(&pane_id); - if let Some(layout) = workspace.layout.clone().remove_leaf(pane_id) { - workspace.layout = layout; - } else { - needs_replacement = true; - } - if !needs_replacement && !workspace.panes.contains_key(&workspace.active_pane) { - workspace.active_pane = workspace.layout.first_leaf_id(); - } - } - } + fn add_surface_to_pane(&mut self, pane_id: Option, kind: PaneKind) -> bool { + let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { + return false; + }; + self.dispatch_control(ControlCommand::CreateSurface { + workspace_id, + pane_id: target_pane_id, + kind, + }) + } - if needs_replacement { - let replacement = self.make_pane( - SurfaceKind::Terminal, - "Agent shell".into(), - None, - None, - initial_attention_for(SurfaceKind::Terminal), - ); - let replacement_id = replacement.id; - let workspace = self - .model - .workspaces - .get_mut(&workspace_id) - .expect("active workspace should exist"); - workspace.panes.insert(replacement_id, replacement); - workspace.layout = LayoutNode::Leaf(replacement_id); - workspace.active_pane = replacement_id; + fn focus_pane_by_id(&mut self, pane_id: PaneId) -> bool { + let Some((workspace_id, _)) = self.resolve_workspace_pane(&self.app_state.snapshot_model(), pane_id) else { + return false; + }; + if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { + let _ = self.dispatch_control(ControlCommand::SwitchWorkspace { + window_id: None, + workspace_id, + }); } - - self.dismiss_surface_activity(workspace_id, Some(pane_id), Some(surface_id)); - self.revision += 1; - true + self.dispatch_control(ControlCommand::FocusPane { workspace_id, pane_id }) } - fn update_surface( - &mut self, - surface_id: SurfaceId, - mut update: impl FnMut(&mut SurfaceRecord) -> bool, - ) -> bool { - for workspace in self.model.workspaces.values_mut() { - for pane in workspace.panes.values_mut() { - if let Some(surface) = pane.surfaces.get_mut(&surface_id) { - return update(surface); - } - } + fn focus_surface_by_id(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { + let Some((workspace_id, _)) = + self.resolve_surface_location(&self.app_state.snapshot_model(), surface_id) + else { + return false; + }; + if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { + let _ = self.dispatch_control(ControlCommand::SwitchWorkspace { + window_id: None, + workspace_id, + }); } - false + self.dispatch_control(ControlCommand::FocusSurface { + workspace_id, + pane_id, + surface_id, + }) } - fn push_surface_activity( - &mut self, - surface_id: SurfaceId, - title: impl Into, - preview: impl Into, - attention: AttentionState, - ) -> bool { - let Some((workspace_id, pane_id, meta)) = self.lookup_surface_context(surface_id) else { + fn close_surface_by_id(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { + let Some((workspace_id, _)) = + self.resolve_surface_location(&self.app_state.snapshot_model(), surface_id) + else { return false; }; - self.model.activity.insert( - ActivityId(self.next_id), - ActivityRecord { - id: ActivityId(self.next_id), - title: title.into(), - preview: preview.into(), - meta, - attention, - workspace_id, - pane_id: Some(pane_id), - surface_id: Some(surface_id), - unread: true, - }, - ); - self.next_id += 1; - self.revision += 1; - true + self.dispatch_control(ControlCommand::CloseSurface { + workspace_id, + pane_id, + surface_id, + }) } - fn lookup_surface_context(&self, surface_id: SurfaceId) -> Option<(WorkspaceId, PaneId, String)> { - for workspace in self.model.workspaces.values() { - for pane in workspace.panes.values() { - if let Some(surface) = pane.surfaces.get(&surface_id) { - let meta = format!( - "{} · {}", - workspace.title, - match surface.kind { - SurfaceKind::Terminal => surface - .cwd - .clone() - .unwrap_or_else(|| surface.kind.label().into()), - SurfaceKind::Browser => surface - .url - .clone() - .unwrap_or_else(|| surface.kind.label().into()), - } - ); - return Some((workspace.id, pane.id, meta)); - } - } - } - None + fn dismiss_activity(&mut self, activity_id: ActivityId) -> bool { + self.dispatch_control(ControlCommand::MarkSurfaceCompleted { + workspace_id: activity_id.workspace_id, + pane_id: activity_id.pane_id, + surface_id: activity_id.surface_id, + }) } - fn dismiss_surface_activity( + fn update_surface_metadata( &mut self, - workspace_id: WorkspaceId, - pane_id: Option, - surface_id: Option, - ) { - let remove = self - .model - .activity - .iter() - .filter_map(|(id, item)| { - (item.workspace_id == workspace_id - && item.pane_id == pane_id - && item.surface_id == surface_id) - .then_some(*id) - }) - .collect::>(); - for id in remove { - self.model.activity.shift_remove(&id); - } + surface_id: SurfaceId, + patch: PaneMetadataPatch, + ) -> bool { + self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { surface_id, patch }) } - fn seed_workspace(&mut self, title: &str, panes: Vec) -> WorkspaceId { - let workspace_id = WorkspaceId(self.next_id); - self.next_id += 1; - let mut pane_records = IndexMap::new(); - let mut layout: Option = None; - let mut active_pane = None; - - for (index, seed) in panes.into_iter().enumerate() { - let pane = self.make_seeded_pane(seed); - let pane_id = pane.id; - if index == 0 { - active_pane = Some(pane_id); - layout = Some(LayoutNode::Leaf(pane_id)); - } else if let Some(layout_node) = layout.as_mut() { - let axis = if index % 2 == 0 { - SplitAxis::Vertical - } else { - SplitAxis::Horizontal - }; - let _ = layout_node.split_leaf(active_pane.expect("first pane"), axis, pane_id, 500); - } - pane_records.insert(pane_id, pane); + fn resolve_target_pane(&self, pane_id: Option) -> Option<(WorkspaceId, PaneId)> { + let model = self.app_state.snapshot_model(); + if let Some(pane_id) = pane_id { + return self.resolve_workspace_pane(&model, pane_id); } - - let active_pane = active_pane.expect("workspace should contain at least one pane"); - self.model.workspaces.insert( - workspace_id, - WorkspaceRecord { - id: workspace_id, - title: title.into(), - active_pane, - panes: pane_records, - layout: layout.expect("workspace should contain at least one pane"), - }, - ); - workspace_id + let workspace_id = model.active_workspace_id()?; + let workspace = model.workspaces.get(&workspace_id)?; + Some((workspace_id, workspace.active_pane)) } - fn seed_activity( - &mut self, - title: impl Into, - preview: impl Into, - meta: impl Into, - attention: AttentionState, - workspace_id: WorkspaceId, - pane_id: Option, - surface_id: Option, - ) { - let activity_id = ActivityId(self.next_id); - self.next_id += 1; - self.model.activity.insert( - activity_id, - ActivityRecord { - id: activity_id, - title: title.into(), - preview: preview.into(), - meta: meta.into(), - attention, - workspace_id, - pane_id, - surface_id, - unread: true, - }, - ); + fn resolve_workspace_pane( + &self, + model: &AppModel, + pane_id: PaneId, + ) -> Option<(WorkspaceId, PaneId)> { + model.workspaces.iter().find_map(|(workspace_id, workspace)| { + workspace.panes.contains_key(&pane_id).then_some((*workspace_id, pane_id)) + }) } - fn make_seeded_pane(&mut self, seed: SeedPane) -> PaneRecord { - let pane_id = PaneId(self.next_id); - self.next_id += 1; - let mut surfaces = IndexMap::new(); - let mut active_surface = None; - for (index, seed_surface) in seed.surfaces.into_iter().enumerate() { - let surface = self.make_surface_record( - seed_surface.kind, - seed_surface.title, - seed_surface.url, - seed_surface.cwd, - seed_surface.attention, - ); - if index == 0 { - active_surface = Some(surface.id); - } - surfaces.insert(surface.id, surface); - } - PaneRecord { - id: pane_id, - active_surface: active_surface.expect("seed pane should contain a surface"), - surfaces, - } + fn resolve_surface_location( + &self, + model: &AppModel, + surface_id: SurfaceId, + ) -> Option<(WorkspaceId, PaneId)> { + model.workspaces.iter().find_map(|(workspace_id, workspace)| { + workspace.panes.iter().find_map(|(pane_id, pane)| { + pane.surfaces + .contains_key(&surface_id) + .then_some((*workspace_id, *pane_id)) + }) + }) } - fn make_pane( - &mut self, - kind: SurfaceKind, - title: String, - url: Option, - cwd: Option, - attention: AttentionState, - ) -> PaneRecord { - let pane_id = PaneId(self.next_id); - self.next_id += 1; - let surface = self.make_surface_record(kind, title, url, cwd, attention); - let active_surface = surface.id; - let mut surfaces = IndexMap::new(); - surfaces.insert(surface.id, surface); - PaneRecord { - id: pane_id, - active_surface, - surfaces, - } + fn dispatch_control(&mut self, command: ControlCommand) -> bool { + self.dispatch_control_with_response(command).is_some() } - fn make_surface_record( + fn dispatch_control_with_response( &mut self, - kind: SurfaceKind, - title: String, - url: Option, - cwd: Option, - attention: AttentionState, - ) -> SurfaceRecord { - let surface_id = SurfaceId(self.next_id); - self.next_id += 1; - SurfaceRecord { - id: surface_id, - kind, - title, - url, - cwd, - attention, + command: ControlCommand, + ) -> Option { + match self.app_state.dispatch(command) { + Ok(response) => { + self.sync_revision_from_app(); + Some(response) + } + Err(error) => { + eprintln!("greenfield control dispatch failed: {error}"); + None + } } } -} - -fn split_frame(frame: Frame, axis: SplitAxis, ratio_millis: u16, gap: i32) -> (Frame, Frame) { - let ratio = f32::from(ratio_millis.clamp(100, 900)) / 1000.0; - - match axis { - SplitAxis::Horizontal => { - let available = (frame.width - gap).max(2); - let first_width = ((available as f32) * ratio).round() as i32; - let second_width = (available - first_width).max(1); - let first_width = first_width.max(1); - ( - Frame::new(frame.x, frame.y, first_width, frame.height), - Frame::new( - frame.x + first_width + gap, - frame.y, - second_width, - frame.height, - ), - ) - } - SplitAxis::Vertical => { - let available = (frame.height - gap).max(2); - let first_height = ((available as f32) * ratio).round() as i32; - let second_height = (available - first_height).max(1); - let first_height = first_height.max(1); + fn sync_revision_from_app(&mut self) { + self.revision = self.revision.max(self.app_state.revision()); + } - ( - Frame::new(frame.x, frame.y, frame.width, first_height), - Frame::new( - frame.x, - frame.y + first_height + gap, - frame.width, - second_height, - ), - ) - } + fn bump_local_revision(&mut self) { + self.revision = self.revision.max(self.app_state.revision()) + 1; } } -fn strongest_attention(lhs: AttentionState, rhs: AttentionState) -> AttentionState { - match (attention_rank(lhs), attention_rank(rhs)) { - (left, right) if left >= right => lhs, - _ => rhs, - } +#[derive(Clone)] +pub struct SharedCore { + inner: Arc>, + revisions: watch::Sender, } -fn attention_rank(state: AttentionState) -> u8 { - match state { - AttentionState::Normal => 0, - AttentionState::Completed => 1, - AttentionState::Busy => 2, - AttentionState::WaitingInput => 3, - AttentionState::Error => 4, +impl PartialEq for SharedCore { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) } } -fn pane_attention(pane: &PaneRecord) -> AttentionState { - pane.surfaces - .values() - .map(|surface| surface.attention) - .fold(AttentionState::Normal, strongest_attention) -} +impl Eq for SharedCore {} -fn initial_attention_for(kind: SurfaceKind) -> AttentionState { - match kind { - SurfaceKind::Terminal => AttentionState::Busy, - SurfaceKind::Browser => AttentionState::Completed, +impl SharedCore { + pub fn bootstrap(bootstrap: BootstrapModel) -> Self { + let core = TaskersCore::with_bootstrap(bootstrap); + let revision = core.revision(); + let (revisions, _) = watch::channel(revision); + Self { + inner: Arc::new(Mutex::new(core)), + revisions, + } } -} -fn default_surface_title(kind: SurfaceKind) -> String { - match kind { - SurfaceKind::Terminal => "Agent shell".into(), - SurfaceKind::Browser => "Browser".into(), + pub fn subscribe_revisions(&self) -> watch::Receiver { + self.revisions.subscribe() } -} -fn default_surface_url(kind: SurfaceKind) -> Option { - match kind { - SurfaceKind::Terminal => None, - SurfaceKind::Browser => Some("https://dioxuslabs.com/learn/0.7/".into()), + pub fn revision(&self) -> u64 { + self.inner.lock().revision() } -} -#[derive(Debug)] -struct SeedSurface { - kind: SurfaceKind, - title: String, - url: Option, - cwd: Option, - attention: AttentionState, -} + pub fn snapshot(&self) -> ShellSnapshot { + self.inner.lock().snapshot() + } -impl SeedSurface { - fn terminal(title: &str, cwd: Option<&str>, attention: AttentionState) -> Self { - Self { - kind: SurfaceKind::Terminal, - title: title.into(), - url: None, - cwd: cwd.map(str::to_string), - attention, + pub fn set_window_size(&self, size: PixelSize) { + let mut inner = self.inner.lock(); + if inner.set_window_size(size) { + let _ = self.revisions.send(inner.revision()); } } - fn browser(title: &str, url: &str, attention: AttentionState) -> Self { - Self { - kind: SurfaceKind::Browser, - title: title.into(), - url: Some(url.into()), - cwd: None, - attention, + pub fn dispatch_shell_action(&self, action: ShellAction) { + let mut inner = self.inner.lock(); + if inner.dispatch_shell_action(action) { + let _ = self.revisions.send(inner.revision()); } } - fn with_secondary(self, secondary: SeedSurface) -> SeedPane { - SeedPane { - surfaces: vec![self, secondary], + pub fn apply_host_event(&self, event: HostEvent) { + let mut inner = self.inner.lock(); + if inner.apply_host_event(event) { + let _ = self.revisions.send(inner.revision()); } } -} -#[derive(Debug)] -struct SeedPane { - surfaces: Vec, -} + pub fn split_with_browser(&self) { + self.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); + } -impl SeedPane { - fn new(surface: SeedSurface) -> Self { - Self { - surfaces: vec![surface], - } + pub fn split_with_terminal(&self) { + self.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }); } } @@ -1788,311 +1193,428 @@ fn shortcut_bindings(preset: ShortcutPreset) -> Vec { .collect() } -#[derive(Clone)] -pub struct SharedCore { - inner: Arc>, - revisions: watch::Sender, - revision_events: broadcast::Sender, -} - -impl SharedCore { - pub fn bootstrap(bootstrap: BootstrapModel) -> Self { - let core = TaskersCore::with_bootstrap(bootstrap); - let (revisions, _) = watch::channel(core.revision()); - let (revision_events, _) = broadcast::channel(256); - Self { - inner: Arc::new(Mutex::new(core)), - revisions, - revision_events, +fn default_preview_app_state() -> AppState { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let browser_pane_id = model + .split_pane(workspace_id, Some(pane_id), DomainSplitAxis::Horizontal) + .expect("split pane"); + let placeholder_surface_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&browser_pane_id)) + .map(|pane| pane.active_surface); + let browser_surface_id = model + .create_surface(workspace_id, browser_pane_id, PaneKind::Browser) + .expect("browser surface"); + let _ = model.update_pane_metadata( + pane_id, + PaneMetadataPatch { + title: Some("Agent shell".into()), + cwd: std::env::current_dir() + .ok() + .map(|path| path.display().to_string()), + ..PaneMetadataPatch::default() + }, + ); + let _ = model.update_surface_metadata( + browser_surface_id, + PaneMetadataPatch { + title: Some("Dioxus Tutorial".into()), + url: Some("https://dioxuslabs.com/learn/0.7/tutorial/".into()), + ..PaneMetadataPatch::default() + }, + ); + if let Some(placeholder_surface_id) = placeholder_surface_id { + let _ = model.close_surface(workspace_id, browser_pane_id, placeholder_surface_id); + } + + AppState::new( + model, + default_session_path_for_preview("greenfield-preview-bootstrap"), + BackendChoice::Mock, + ShellLaunchSpec::fallback(), + ) + .expect("preview app state") +} + +fn default_session_path_for_preview(label: &str) -> PathBuf { + let base = default_session_path(); + let stem = base + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("taskers-session"); + let file = format!("{stem}-{label}.json"); + base.with_file_name(file) +} + +fn split_frame(frame: Frame, axis: SplitAxis, ratio: u16, gap: i32) -> (Frame, Frame) { + let ratio = i32::from(ratio.clamp(150, 850)); + match axis { + SplitAxis::Horizontal => { + let usable_width = (frame.width - gap).max(2); + let first_width = ((usable_width * ratio) / 1000).max(1); + let second_width = (usable_width - first_width).max(1); + ( + Frame::new(frame.x, frame.y, first_width, frame.height), + Frame::new( + frame.x + first_width + gap, + frame.y, + second_width, + frame.height, + ), + ) + } + SplitAxis::Vertical => { + let usable_height = (frame.height - gap).max(2); + let first_height = ((usable_height * ratio) / 1000).max(1); + let second_height = (usable_height - first_height).max(1); + ( + Frame::new(frame.x, frame.y, frame.width, first_height), + Frame::new( + frame.x, + frame.y + first_height + gap, + frame.width, + second_height, + ), + ) } } +} - pub fn demo() -> Self { - Self::bootstrap(BootstrapModel::default()) - } - - pub fn revision(&self) -> u64 { - self.inner.lock().revision() - } +fn pane_body_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { + frame.inset_top(metrics.pane_header_height + metrics.surface_tab_height) +} - pub fn subscribe_revisions(&self) -> watch::Receiver { - self.revisions.subscribe() +fn workspace_preview(summary: &DomainWorkspaceSummary) -> String { + if let Some(notification) = summary.latest_notification.as_deref() { + return compact_preview(notification); } - - pub fn subscribe_revision_events(&self) -> broadcast::Receiver { - self.revision_events.subscribe() + if let Some(repo_hint) = summary.repo_hint.as_deref() { + return repo_hint.to_string(); } - - pub fn snapshot(&self) -> ShellSnapshot { - self.inner.lock().snapshot() - } - - pub fn set_window_size(&self, size: PixelSize) { - self.mutate(|core| core.set_window_size(size)); + if let Some(agent) = summary.agent_summaries.first() { + return agent + .title + .clone() + .unwrap_or_else(|| format!("{} {}", agent.agent_kind, agent.state.label())); } + "No recent activity.".into() +} - pub fn dispatch_shell_action(&self, action: ShellAction) { - self.mutate(|core| core.dispatch_shell_action(action)); - } +fn workspace_surface_count(workspace: &Workspace) -> usize { + workspace + .panes + .values() + .map(|pane| pane.surfaces.len()) + .sum() +} - pub fn split_with_browser(&self) { - self.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); +fn workspace_attention(workspace: &Workspace) -> AttentionState { + workspace + .panes + .values() + .map(|pane| pane.highest_attention()) + .max_by_key(|attention| attention.rank()) + .unwrap_or(taskers_domain::AttentionState::Normal) + .into() +} + +fn display_surface_title(surface: &SurfaceRecord) -> String { + if let Some(title) = surface + .metadata + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + return title.to_string(); + } + + if matches!(surface.kind, PaneKind::Browser) + && let Some(url) = surface + .metadata + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + { + return url.to_string(); + } + + match surface.kind { + PaneKind::Terminal => "Terminal".into(), + PaneKind::Browser => "Browser".into(), + } +} + +fn normalized_surface_url(surface: &SurfaceRecord) -> Option { + surface + .metadata + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(str::to_string) +} + +fn normalized_cwd(metadata: &PaneMetadata) -> Option { + metadata + .cwd + .as_deref() + .map(str::trim) + .filter(|cwd| !cwd.is_empty()) + .map(str::to_string) +} + +fn compact_preview(message: &str) -> String { + let trimmed = message.split_whitespace().collect::>().join(" "); + if trimmed.len() <= 140 { + return trimmed; + } + format!("{}…", &trimmed[..139]) +} + +fn activity_title(model: &AppModel, item: &ActivityItem) -> String { + model.workspaces + .get(&item.workspace_id) + .and_then(|workspace| workspace.panes.get(&item.pane_id)) + .and_then(|pane| { + pane.surfaces + .get(&item.surface_id) + .or_else(|| pane.active_surface()) + }) + .map(display_surface_title) + .unwrap_or_else(|| "Terminal pane".into()) +} + +fn activity_context_line(model: &AppModel, item: &ActivityItem) -> String { + let workspace_label = model + .workspaces + .get(&item.workspace_id) + .map(|workspace| workspace.label.clone()) + .unwrap_or_else(|| "Workspace".into()); + let mut parts = vec![format!("Workspace {workspace_label}")]; + if let Some(surface) = model + .workspaces + .get(&item.workspace_id) + .and_then(|workspace| workspace.panes.get(&item.pane_id)) + .and_then(|pane| { + pane.surfaces + .get(&item.surface_id) + .or_else(|| pane.active_surface()) + }) + { + parts.push(match surface.kind { + PaneKind::Terminal => "terminal".into(), + PaneKind::Browser => "browser".into(), + }); + if let Some(repo) = surface.metadata.repo_name.as_deref() { + parts.push(repo.to_string()); + } + if let Some(branch) = surface.metadata.git_branch.as_deref() { + parts.push(branch.to_string()); + } } + parts.join(" · ") +} - pub fn split_with_terminal(&self) { - self.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }); - } +fn next_workspace_label(model: &AppModel) -> String { + format!("Workspace {}", model.workspaces.len() + 1) +} - pub fn focus_pane(&self, pane_id: PaneId) { - self.dispatch_shell_action(ShellAction::FocusPane { pane_id }); +fn fallback_surface_descriptor(surface: &SurfaceRecord) -> SurfaceDescriptor { + SurfaceDescriptor { + cols: 120, + rows: 40, + kind: surface.kind.clone(), + cwd: normalized_cwd(&surface.metadata), + title: surface + .metadata + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(str::to_string), + url: normalized_surface_url(surface), + command_argv: Vec::new(), + env: BTreeMap::new(), } +} - pub fn apply_host_event(&self, event: HostEvent) { - self.mutate(|core| core.apply_host_event(event)); +fn mount_spec_from_descriptor( + surface: &SurfaceRecord, + descriptor: SurfaceDescriptor, +) -> SurfaceMountSpec { + match surface.kind { + PaneKind::Browser => SurfaceMountSpec::Browser(BrowserMountSpec { + url: descriptor + .url + .as_deref() + .map(resolved_browser_uri) + .unwrap_or_else(|| "about:blank".into()), + }), + PaneKind::Terminal => SurfaceMountSpec::Terminal(TerminalMountSpec { + title: descriptor + .title + .unwrap_or_else(|| display_surface_title(surface)), + cwd: descriptor.cwd, + cols: descriptor.cols, + rows: descriptor.rows, + command_argv: descriptor.command_argv, + env: descriptor.env, + }), + } +} + +fn resolved_browser_uri(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return "about:blank".into(); + } + if trimmed.contains("://") { + return trimmed.to_string(); + } + if trimmed.chars().any(char::is_whitespace) { + return format!( + "https://duckduckgo.com/?q={}", + trimmed.split_whitespace().collect::>().join("+") + ); } - - fn mutate(&self, update: impl FnOnce(&mut TaskersCore) -> bool) { - let mut core = self.inner.lock(); - if update(&mut core) { - let _ = self.revisions.send(core.revision()); - let _ = self.revision_events.send(core.revision()); - } + if is_local_browser_target(trimmed) { + return format!("http://{trimmed}"); } + format!("https://{trimmed}") } -impl PartialEq for SharedCore { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.inner, &other.inner) - } +fn is_local_browser_target(value: &str) -> bool { + value.starts_with("localhost") + || value.starts_with("127.0.0.1") + || value.starts_with("0.0.0.0") + || value.contains(":3000") + || value.contains(":5173") + || value.contains(":8000") + || value.contains(":8080") } -impl Eq for SharedCore {} - #[cfg(test)] mod tests { use super::{ BootstrapModel, BrowserMountSpec, HostEvent, RuntimeCapability, RuntimeStatus, SharedCore, - ShellAction, ShellSection, ShortcutPreset, SurfaceMountSpec, SurfaceKind, - TerminalDefaults, WorkspaceId, + ShellAction, ShellSection, SurfaceMountSpec, default_preview_app_state, }; - use std::collections::BTreeMap; fn bootstrap() -> BootstrapModel { - let mut env = BTreeMap::new(); - env.insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); BootstrapModel { + app_state: default_preview_app_state(), runtime_status: RuntimeStatus { ghostty_runtime: RuntimeCapability::Ready, shell_integration: RuntimeCapability::Ready, terminal_host: RuntimeCapability::Fallback { - message: "GTK4 Ghostty bridge probe failed on this machine.".into(), + message: "Probe failed".into(), }, }, - terminal_defaults: TerminalDefaults { - cols: 132, - rows: 48, - command_argv: vec!["/bin/zsh".into(), "-i".into()], - env, - }, + selected_theme_id: "dark".into(), + selected_shortcut_preset: super::ShortcutPreset::Balanced, } } #[test] - fn terminal_mount_spec_inherits_shell_defaults() { + fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); let snapshot = core.snapshot(); - let terminal = snapshot + + let browser_count = snapshot .portal .panes - .into_iter() - .find(|pane| pane.mount.kind() == SurfaceKind::Terminal) - .expect("terminal surface plan"); - - match terminal.mount { - SurfaceMountSpec::Terminal(spec) => { - assert_eq!(spec.command_argv, vec!["/bin/zsh", "-i"]); - assert_eq!(spec.cols, 132); - assert_eq!(spec.rows, 48); - assert_eq!( - spec.env.get("TASKERS_SOCKET").map(String::as_str), - Some("/tmp/taskers.sock") - ); - assert_eq!( - spec.env.get("TASKERS_PANE_ID").map(String::as_str), - Some(terminal.pane_id.to_string().as_str()) - ); - } - SurfaceMountSpec::Browser(_) => panic!("expected terminal mount spec"), - } + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) + .count(); + let terminal_count = snapshot + .portal + .panes + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) + .count(); + + assert_eq!(browser_count, 1); + assert_eq!(terminal_count, 1); } #[test] - fn browser_host_events_update_surface_metadata() { + fn split_browser_creates_real_browser_pane() { let core = SharedCore::bootstrap(bootstrap()); - let browser = core - .snapshot() - .portal - .panes - .into_iter() - .find(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(_))) - .expect("browser pane"); + let before = core.snapshot().portal.panes.len(); - core.apply_host_event(HostEvent::SurfaceTitleChanged { - surface_id: browser.surface_id, - title: "Taskers Docs".into(), - }); - core.apply_host_event(HostEvent::SurfaceUrlChanged { - surface_id: browser.surface_id, - url: "https://taskers.invalid/docs".into(), - }); + core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); let snapshot = core.snapshot(); - let pane = match snapshot.current_workspace.layout { - super::LayoutNodeSnapshot::Split { second, .. } => second, - _ => panic!("expected split layout"), - }; - let pane = match *pane { - super::LayoutNodeSnapshot::Pane(pane) => pane, - _ => panic!("expected pane node"), - }; - let surface = pane - .surfaces - .into_iter() - .find(|surface| surface.id == browser.surface_id) - .expect("browser surface"); - assert_eq!(surface.title, "Taskers Docs"); - assert_eq!( - surface.url.as_deref(), - Some("https://taskers.invalid/docs") - ); + assert!(snapshot.portal.panes.len() > before); + assert!(snapshot.portal.panes.iter().any(|plan| { + matches!( + &plan.mount, + SurfaceMountSpec::Browser(BrowserMountSpec { url }) + if url.starts_with("http") + ) + })); } #[test] - fn closing_a_split_surface_collapses_the_layout() { + fn host_events_round_trip_surface_metadata_into_snapshot() { let core = SharedCore::bootstrap(bootstrap()); - let browser = core + let browser_surface = core .snapshot() .portal .panes .into_iter() - .find(|pane| matches!(pane.mount, SurfaceMountSpec::Browser(BrowserMountSpec { .. }))) - .expect("browser pane"); + .find(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) + .expect("browser surface"); - core.apply_host_event(HostEvent::SurfaceClosed { - pane_id: browser.pane_id, - surface_id: browser.surface_id, + core.apply_host_event(HostEvent::SurfaceTitleChanged { + surface_id: browser_surface.surface_id, + title: "Taskers Docs".into(), }); - - let snapshot = core.snapshot(); - assert!(matches!( - snapshot.current_workspace.layout, - super::LayoutNodeSnapshot::Pane(_) - )); - assert_eq!(snapshot.portal.panes.len(), 1); - } - - #[test] - fn adding_a_surface_switches_the_active_tab_and_portal_mount() { - let core = SharedCore::bootstrap(bootstrap()); - let pane_id = core.snapshot().current_workspace.active_pane; - core.dispatch_shell_action(ShellAction::AddBrowserSurface { - pane_id: Some(pane_id), + core.apply_host_event(HostEvent::SurfaceUrlChanged { + surface_id: browser_surface.surface_id, + url: "https://example.com/docs".into(), }); let snapshot = core.snapshot(); - let pane = match snapshot.current_workspace.layout { - super::LayoutNodeSnapshot::Split { first, .. } => first, - super::LayoutNodeSnapshot::Pane(pane) => Box::new(super::LayoutNodeSnapshot::Pane(pane)), - }; - let pane = match *pane { - super::LayoutNodeSnapshot::Pane(pane) => pane, - _ => panic!("expected pane"), + let pane = match &snapshot.current_workspace.layout { + super::LayoutNodeSnapshot::Split { first, second, .. } => [first.as_ref(), second.as_ref()] + .into_iter() + .find_map(|node| match node { + super::LayoutNodeSnapshot::Pane(pane) => pane + .surfaces + .iter() + .any(|surface| surface.id == browser_surface.surface_id) + .then_some(pane), + _ => None, + }) + .expect("browser pane"), + super::LayoutNodeSnapshot::Pane(_) => panic!("expected split layout"), }; - assert!(pane.surfaces.len() >= 3); - let mounted = snapshot - .portal - .panes - .into_iter() - .find(|plan| plan.pane_id == pane_id) - .expect("mounted plan for active pane"); - assert_eq!(mounted.surface_id, pane.active_surface); - assert_eq!(mounted.mount.kind(), SurfaceKind::Browser); - } - #[test] - fn switching_workspaces_updates_snapshot_and_portal() { - let core = SharedCore::bootstrap(bootstrap()); - let workspace = core - .snapshot() - .workspaces - .into_iter() - .find(|workspace| workspace.title == "Research") - .expect("research workspace"); - - core.dispatch_shell_action(ShellAction::FocusWorkspace { - workspace_id: workspace.id, - }); - - let snapshot = core.snapshot(); - assert_eq!(snapshot.current_workspace.id, workspace.id); - assert_eq!(snapshot.current_workspace.title, "Research"); - assert_eq!(snapshot.portal.panes.len(), 1); + let surface = pane + .surfaces + .iter() + .find(|surface| surface.id == browser_surface.surface_id) + .expect("browser surface"); + assert_eq!(surface.title, "Taskers Docs"); + assert_eq!(surface.url.as_deref(), Some("https://example.com/docs")); } #[test] - fn settings_snapshot_tracks_selected_theme_and_shortcut_preset() { + fn local_shell_state_revisions_advance_without_app_mutation() { let core = SharedCore::bootstrap(bootstrap()); + let before = core.revision(); + core.dispatch_shell_action(ShellAction::ShowSection { section: ShellSection::Settings, }); - core.dispatch_shell_action(ShellAction::SelectTheme { - theme_id: "tokyo-night".into(), - }); - core.dispatch_shell_action(ShellAction::SelectShortcutPreset { - preset_id: ShortcutPreset::PowerUser.id().into(), - }); - - let snapshot = core.snapshot(); - assert_eq!(snapshot.section, ShellSection::Settings); - assert_eq!(snapshot.settings.selected_theme_id, "tokyo-night"); - assert!( - snapshot - .settings - .shortcut_presets - .iter() - .any(|preset| preset.id == "power-user" && preset.active) - ); - } - #[test] - fn runtime_status_round_trips_through_snapshot() { - let core = SharedCore::bootstrap(bootstrap()); - let snapshot = core.snapshot(); - - assert!(matches!( - snapshot.runtime_status.ghostty_runtime, - RuntimeCapability::Ready - )); - assert!(matches!( - snapshot.runtime_status.shell_integration, - RuntimeCapability::Ready - )); - assert!(matches!( - snapshot.runtime_status.terminal_host, - RuntimeCapability::Fallback { .. } - )); - } - - #[test] - fn create_workspace_adds_new_sidebar_entry() { - let core = SharedCore::bootstrap(bootstrap()); - let before = core.snapshot().workspaces.len(); - core.dispatch_shell_action(ShellAction::CreateWorkspace); - let snapshot = core.snapshot(); - assert_eq!(snapshot.workspaces.len(), before + 1); - assert!(snapshot - .workspaces - .iter() - .any(|workspace| workspace.id == WorkspaceId(0) || workspace.active)); + assert!(core.revision() > before); + assert!(matches!(core.snapshot().section, ShellSection::Settings)); } } diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index 945332a..e2090cd 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -17,7 +17,9 @@ clap.workspace = true dioxus.workspace = true dioxus-liveview.workspace = true gtk.workspace = true +taskers-app-core.workspace = true taskers-core.workspace = true +taskers-domain.workspace = true taskers-ghostty.workspace = true taskers-host.workspace = true taskers-paths.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index d693c1f..386bf37 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -5,7 +5,6 @@ use clap::{Parser, ValueEnum}; use gtk::glib; use std::{ cell::{Cell, RefCell}, - collections::BTreeMap, fs::{File, OpenOptions, remove_file}, io::{self, Write}, net::TcpListener, @@ -18,9 +17,11 @@ use std::{ }; use taskers_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, - SurfaceKind, TerminalDefaults, + ShortcutPreset, SurfaceKind, }; -use taskers_ghostty::{GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; +use taskers_app_core::{AppState, load_or_bootstrap}; +use taskers_domain::AppModel; +use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; @@ -70,7 +71,7 @@ struct BootstrapContext { struct RuntimeBootstrap { ghostty_runtime: RuntimeCapability, shell_integration: RuntimeCapability, - terminal_defaults: TerminalDefaults, + shell_launch: ShellLaunchSpec, host_options: GhosttyHostOptions, startup_notes: Vec, } @@ -112,7 +113,7 @@ fn build_ui_result( cli: Cli, ) -> Result<()> { let diagnostics = DiagnosticsWriter::from_cli(&cli); - let bootstrap = bootstrap_runtime(diagnostics.as_ref()); + let bootstrap = bootstrap_runtime(diagnostics.as_ref())?; log_runtime_status(diagnostics.as_ref(), &bootstrap.core.snapshot().runtime_status); let shell_url = launch_liveview_server(bootstrap.core.clone())?; @@ -209,19 +210,32 @@ fn build_ui_result( Ok(()) } -fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContext { +fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result { let runtime = resolve_runtime_bootstrap(); let mut startup_notes = runtime.startup_notes; - - let (ghostty_host, terminal_host, terminal_note) = + let session_path = greenfield_session_path(); + let initial_model = load_or_bootstrap(&session_path, false).with_context(|| { + format!( + "failed to load or bootstrap greenfield session at {}", + session_path.display() + ) + })?; + + let (ghostty_host, backend_choice, terminal_host, terminal_note) = match probe_ghostty_backend_process(GhosttyProbeMode::Surface) { Ok(()) => match GhosttyHost::new_with_options(&runtime.host_options) { Ok(host) => { let _ = host.tick(); - (Some(host), RuntimeCapability::Ready, None) + ( + Some(host), + BackendChoice::GhosttyEmbedded, + RuntimeCapability::Ready, + None, + ) } Err(error) => ( None, + BackendChoice::Mock, RuntimeCapability::Fallback { message: format!("Ghostty host unavailable: {error}"), }, @@ -230,6 +244,7 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContex }, Err(error) => ( None, + BackendChoice::Mock, RuntimeCapability::Fallback { message: format!("Ghostty surface self-probe failed: {error}"), }, @@ -246,18 +261,27 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> BootstrapContex shell_integration: runtime.shell_integration, terminal_host, }; + let app_state = AppState::new( + initial_model, + session_path, + backend_choice, + runtime.shell_launch, + ) + .context("failed to initialize greenfield app state")?; let core = SharedCore::bootstrap(BootstrapModel { + app_state, runtime_status, - terminal_defaults: runtime.terminal_defaults, + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::Balanced, }); log_runtime_status(diagnostics, &core.snapshot().runtime_status); - BootstrapContext { + Ok(BootstrapContext { core, ghostty_host, startup_notes, - } + }) } fn resolve_runtime_bootstrap() -> RuntimeBootstrap { @@ -288,18 +312,31 @@ fn resolve_runtime_bootstrap() -> RuntimeBootstrap { ), }; - let terminal_defaults = terminal_defaults_from(shell_launch.clone()); let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); RuntimeBootstrap { ghostty_runtime, shell_integration, - terminal_defaults, + shell_launch, host_options, startup_notes, } } +fn greenfield_session_path() -> PathBuf { + taskers_paths::TaskersPaths::detect() + .state_dir() + .join("greenfield-session.json") +} + +fn greenfield_probe_session_path(mode: GhosttyProbeMode) -> PathBuf { + std::env::temp_dir().join(format!( + "taskers-greenfield-probe-{}-{}.json", + mode.as_arg(), + std::process::id() + )) +} + fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { let runtime = resolve_runtime_bootstrap(); if let Err(error) = gtk::init() { @@ -319,32 +356,16 @@ fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { }; if matches!(mode, GhosttyProbeMode::Surface) { - return run_internal_surface_probe(host, runtime.terminal_defaults, mode); + return run_internal_surface_probe(host, runtime.shell_launch, mode); } spin_probe_main_context(Duration::from_millis(350)); glib::ExitCode::SUCCESS } -fn terminal_defaults_from(shell_launch: ShellLaunchSpec) -> TerminalDefaults { - let mut command_argv = Vec::with_capacity(shell_launch.args.len() + 1); - command_argv.push(shell_launch.program.display().to_string()); - command_argv.extend(shell_launch.args); - - let mut env = BTreeMap::new(); - env.extend(shell_launch.env); - - TerminalDefaults { - cols: 120, - rows: 40, - command_argv, - env, - } -} - fn run_internal_surface_probe( host: GhosttyHost, - terminal_defaults: TerminalDefaults, + shell_launch: ShellLaunchSpec, mode: GhosttyProbeMode, ) -> glib::ExitCode { let settings = WebKitSettings::builder() @@ -361,13 +382,31 @@ fn run_internal_surface_probe( Some("http://127.0.0.1/"), ); + let app_state = match AppState::new( + AppModel::new("Ghostty Probe"), + greenfield_probe_session_path(mode), + BackendChoice::GhosttyEmbedded, + shell_launch, + ) { + Ok(app_state) => app_state, + Err(error) => { + eprintln!( + "ghostty {} self-probe failed during app state bootstrap: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; + } + }; + let core = SharedCore::bootstrap(BootstrapModel { + app_state, runtime_status: RuntimeStatus { ghostty_runtime: RuntimeCapability::Ready, shell_integration: RuntimeCapability::Ready, terminal_host: RuntimeCapability::Ready, }, - terminal_defaults, + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::Balanced, }); core.set_window_size(PixelSize::new(1200, 800)); From 923cad29719ef7a8b8770d794cf8b51245a35661 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 14:16:11 +0100 Subject: [PATCH 21/63] feat: stabilize greenfield linux ghostty startup --- crates/taskers-runtime/src/signals.rs | 29 ++++++++- greenfield/crates/taskers-host/src/lib.rs | 4 ++ greenfield/crates/taskers/src/main.rs | 78 ++++++++++++++++------- vendor/ghostty/src/taskers_bridge.zig | 11 ++-- 4 files changed, 91 insertions(+), 31 deletions(-) diff --git a/crates/taskers-runtime/src/signals.rs b/crates/taskers-runtime/src/signals.rs index 2d8638d..1b706a5 100644 --- a/crates/taskers-runtime/src/signals.rs +++ b/crates/taskers-runtime/src/signals.rs @@ -34,7 +34,10 @@ impl SignalStreamParser { let mut frames = Vec::new(); let mut cursor = 0usize; - let mut keep_from = self.pending.len().saturating_sub(OSC_PREFIX.len()); + let mut keep_from = floor_char_boundary( + &self.pending, + self.pending.len().saturating_sub(OSC_PREFIX.len()), + ); while let Some(found) = self.pending[cursor..].find(OSC_PREFIX) { let frame_start = cursor + found; @@ -54,7 +57,7 @@ impl SignalStreamParser { keep_from = cursor; } - self.pending = self.pending[keep_from..].to_string(); + self.pending = self.pending[floor_char_boundary(&self.pending, keep_from)..].to_string(); frames } } @@ -201,6 +204,14 @@ fn frame_slice(remainder: &str) -> Option<(&str, usize)> { None } +fn floor_char_boundary(value: &str, mut index: usize) -> usize { + index = index.min(value.len()); + while index > 0 && !value.is_char_boundary(index) { + index -= 1; + } + index +} + #[cfg(test)] mod tests { use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -247,6 +258,20 @@ mod tests { assert_eq!(frames[0].message.as_deref(), Some("Need approval")); } + #[test] + fn stream_parser_keeps_partial_prefix_on_utf8_boundary() { + let mut parser = SignalStreamParser::default(); + let noisy_prefix = "abbr'...\n⠙ "; + let partial = format!("{noisy_prefix}\u{1b}]777;taskers;kind=progress;message=Working"); + + assert!(parser.push(&partial).is_empty()); + + let frames = parser.push("\u{7}"); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].kind, SignalKind::Progress); + assert_eq!(frames[0].message.as_deref(), Some("Working")); + } + #[test] fn parses_metadata_snapshots_with_base64_fields() { let output = format!( diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 10d381e..25642eb 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -300,6 +300,10 @@ impl BrowserSurface { .settings(&settings) .build(); webview.load_uri(&url); + (event_sink)(HostEvent::SurfaceUrlChanged { + surface_id: plan.surface_id, + url: url.clone(), + }); position_widget(fixed, webview.upcast_ref(), plan.frame); let pane_id = plan.pane_id; diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 386bf37..824670b 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -78,16 +78,35 @@ struct RuntimeBootstrap { fn main() -> glib::ExitCode { let cli = Cli::parse(); + scrub_inherited_terminal_env(); if let Some(mode) = cli.internal_ghostty_probe { return run_internal_ghostty_probe(mode); } + + let bootstrap = match bootstrap_runtime(None) { + Ok(bootstrap) => bootstrap, + Err(error) => { + eprintln!("failed to bootstrap greenfield Taskers host: {error:?}"); + return glib::ExitCode::FAILURE; + } + }; + let app = adw::Application::builder().application_id(APP_ID).build(); + let bootstrap = Rc::new(RefCell::new(Some(bootstrap))); let hold_guard = Rc::new(RefCell::new(None)); + let bootstrap_for_startup = bootstrap.clone(); let hold_guard_for_startup = hold_guard.clone(); let cli_for_startup = cli.clone(); app.connect_startup(move |app| { *hold_guard_for_startup.borrow_mut() = Some(app.hold()); - build_ui(app, hold_guard_for_startup.clone(), cli_for_startup.clone()); + if let Some(bootstrap) = bootstrap_for_startup.borrow_mut().take() { + build_ui( + app, + bootstrap, + hold_guard_for_startup.clone(), + cli_for_startup.clone(), + ); + } }); app.connect_activate(|app| { if let Some(window) = app.active_window() { @@ -99,21 +118,22 @@ fn main() -> glib::ExitCode { fn build_ui( app: &adw::Application, + bootstrap: BootstrapContext, hold_guard: Rc>>, cli: Cli, ) { - if let Err(error) = build_ui_result(app, hold_guard, cli) { + if let Err(error) = build_ui_result(app, bootstrap, hold_guard, cli) { eprintln!("failed to launch greenfield Taskers host: {error:?}"); } } fn build_ui_result( app: &adw::Application, + bootstrap: BootstrapContext, hold_guard: Rc>>, cli: Cli, ) -> Result<()> { let diagnostics = DiagnosticsWriter::from_cli(&cli); - let bootstrap = bootstrap_runtime(diagnostics.as_ref())?; log_runtime_status(diagnostics.as_ref(), &bootstrap.core.snapshot().runtime_status); let shell_url = launch_liveview_server(bootstrap.core.clone())?; @@ -339,11 +359,6 @@ fn greenfield_probe_session_path(mode: GhosttyProbeMode) -> PathBuf { fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { let runtime = resolve_runtime_bootstrap(); - if let Err(error) = gtk::init() { - eprintln!("ghostty {} self-probe failed during gtk init: {error}", mode.as_arg()); - return glib::ExitCode::FAILURE; - } - let host = match GhosttyHost::new_with_options(&runtime.host_options) { Ok(host) => { let _ = host.tick(); @@ -368,6 +383,16 @@ fn run_internal_surface_probe( shell_launch: ShellLaunchSpec, mode: GhosttyProbeMode, ) -> glib::ExitCode { + if !gtk::is_initialized_main_thread() { + if let Err(error) = gtk::init() { + eprintln!( + "ghostty {} self-probe failed during gtk init: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; + } + } + let settings = WebKitSettings::builder() .enable_developer_extras(true) .build(); @@ -440,9 +465,12 @@ fn run_internal_surface_probe( thread::sleep(Duration::from_millis(16)); } - window.close(); - spin_probe_main_context(Duration::from_millis(100)); - glib::ExitCode::SUCCESS + // The probe only needs to prove that an embedded surface can initialize + // and stay alive briefly. Tearing the GTK/GL stack back down inside the + // child has been the flaky part on Linux, so exit immediately on success + // and let the parent make the real startup decision. + let _ = window; + std::process::exit(0); } fn spin_probe_main_context(duration: Duration) { @@ -706,13 +734,13 @@ fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) ), ); - if let Some(title) = wait_for_browser_title(&core, Duration::from_secs(4)) { + if let Some(metadata) = wait_for_browser_ready(&core, Duration::from_secs(4)) { log_diagnostic( diagnostics, DiagnosticRecord::new( DiagnosticCategory::Smoke, Some(core.revision()), - format!("browser metadata observed title={title}"), + format!("browser metadata observed {metadata}"), ), ); } else { @@ -721,7 +749,7 @@ fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) DiagnosticRecord::new( DiagnosticCategory::Smoke, Some(core.revision()), - "browser metadata timed out", + "browser surface did not appear before timeout", ), ); } @@ -763,21 +791,19 @@ fn run_baseline_smoke(core: SharedCore, diagnostics: Option<&DiagnosticsWriter>) ); } -fn wait_for_browser_title(core: &SharedCore, timeout: Duration) -> Option { +fn wait_for_browser_ready(core: &SharedCore, timeout: Duration) -> Option { let started_at = Instant::now(); while started_at.elapsed() < timeout { let snapshot = core.snapshot(); - if let Some(title) = - first_browser_title(&snapshot.current_workspace.layout).filter(|title| title != "Browser") - { - return Some(title); + if let Some(metadata) = first_browser_ready(&snapshot.current_workspace.layout) { + return Some(metadata); } thread::sleep(Duration::from_millis(100)); } None } -fn first_browser_title(node: &LayoutNodeSnapshot) -> Option { +fn first_browser_ready(node: &LayoutNodeSnapshot) -> Option { match node { LayoutNodeSnapshot::Pane(pane) => pane .surfaces @@ -785,9 +811,17 @@ fn first_browser_title(node: &LayoutNodeSnapshot) -> Option { .find(|surface| surface.id == pane.active_surface) .or_else(|| pane.surfaces.first()) .filter(|surface| surface.kind == SurfaceKind::Browser) - .map(|surface| surface.title.clone()), + .map(|surface| { + if surface.title != "Browser" { + format!("title={}", surface.title) + } else if let Some(url) = surface.url.as_deref() { + format!("url={url}") + } else { + "surface-present".into() + } + }), LayoutNodeSnapshot::Split { first, second, .. } => { - first_browser_title(first).or_else(|| first_browser_title(second)) + first_browser_ready(first).or_else(|| first_browser_ready(second)) } } } diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 37f2e07..a3d30cc 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -119,13 +119,7 @@ pub export fn taskers_ghostty_surface_new( ) ?*gtk.Widget { const ptr = host orelse return null; const opts = options orelse &SurfaceOptions{}; - const command = command: { - if (ptr.command_argv.len == 0) break :command null; - break :command configpkg.Command{ .direct = ptr.command_argv }; - }; - const surface = Surface.newForApp(ptr.rt_app.app, .{ - .command = command, .working_directory = if (opts.working_directory) |value| std.mem.span(value) else null, .title = if (opts.title) |value| std.mem.span(value) else null, }); @@ -155,7 +149,10 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti var cloned = try base.get().clone(alloc); defer cloned.deinit(); - cloned.command = null; + cloned.command = if (ptr.command_argv.len == 0) + null + else + configpkg.Command{ .direct = ptr.command_argv }; cloned.@"shell-integration" = .none; cloned.@"shell-integration-features" = .{}; cloned.@"linux-cgroup" = .never; From d81cc9c986c3e17d73ca495269410443371aeec8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 14:34:45 +0100 Subject: [PATCH 22/63] feat: wire greenfield shared shell to live control state --- crates/taskers-control/src/lib.rs | 2 +- crates/taskers-control/src/socket.rs | 31 +++++++++--- greenfield/Cargo.lock | 1 + greenfield/crates/taskers-core/src/lib.rs | 54 +++++++++++++++++++-- greenfield/crates/taskers/Cargo.toml | 1 + greenfield/crates/taskers/src/main.rs | 59 ++++++++++++++++++++++- 6 files changed, 134 insertions(+), 14 deletions(-) diff --git a/crates/taskers-control/src/lib.rs b/crates/taskers-control/src/lib.rs index d9aaed4..096ad68 100644 --- a/crates/taskers-control/src/lib.rs +++ b/crates/taskers-control/src/lib.rs @@ -8,4 +8,4 @@ pub use client::ControlClient; pub use controller::{ControllerSnapshot, InMemoryController}; pub use paths::default_socket_path; pub use protocol::{ControlCommand, ControlQuery, ControlResponse, RequestFrame, ResponseFrame}; -pub use socket::{bind_socket, serve}; +pub use socket::{bind_socket, serve, serve_with_handler}; diff --git a/crates/taskers-control/src/socket.rs b/crates/taskers-control/src/socket.rs index 0aa40d2..0e1290c 100644 --- a/crates/taskers-control/src/socket.rs +++ b/crates/taskers-control/src/socket.rs @@ -6,7 +6,11 @@ use tokio::{ net::{UnixListener, UnixStream}, }; -use crate::{RequestFrame, controller::InMemoryController, protocol::ResponseFrame}; +use crate::{ + RequestFrame, + controller::InMemoryController, + protocol::{ControlCommand, ControlResponse, ResponseFrame}, +}; pub fn bind_socket(path: impl AsRef) -> io::Result { let path = path.as_ref(); @@ -25,6 +29,18 @@ pub async fn serve( ) -> io::Result<()> where S: Future + Send, +{ + serve_with_handler(listener, move |command| controller.handle(command).map_err(|error| error.to_string()), shutdown).await +} + +pub async fn serve_with_handler( + listener: UnixListener, + handler: H, + shutdown: S, +) -> io::Result<()> +where + S: Future + Send, + H: Fn(ControlCommand) -> Result + Clone + Send + Sync + 'static, { tokio::pin!(shutdown); @@ -33,9 +49,9 @@ where _ = &mut shutdown => break, accepted = listener.accept() => { let (stream, _) = accepted?; - let controller = controller.clone(); + let handler = handler.clone(); tokio::spawn(async move { - let _ = handle_connection(stream, controller).await; + let _ = handle_connection_with_handler(stream, handler).await; }); } } @@ -44,7 +60,10 @@ where Ok(()) } -async fn handle_connection(stream: UnixStream, controller: InMemoryController) -> io::Result<()> { +async fn handle_connection_with_handler(stream: UnixStream, handler: H) -> io::Result<()> +where + H: Fn(ControlCommand) -> Result + Clone + Send + Sync + 'static, +{ let (read_half, mut write_half) = stream.into_split(); let mut reader = BufReader::new(read_half); let mut line = String::new(); @@ -53,9 +72,7 @@ async fn handle_connection(stream: UnixStream, controller: InMemoryController) - let request: RequestFrame = from_slice(line.trim_end().as_bytes()).map_err(invalid_data)?; let response = ResponseFrame { request_id: request.request_id, - response: controller - .handle(request.command) - .map_err(|error| error.to_string()), + response: handler(request.command), }; let payload = to_vec(&response).map_err(invalid_data)?; write_half.write_all(&payload).await?; diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index e5f2e82..740479c 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -2391,6 +2391,7 @@ dependencies = [ "dioxus-liveview", "gtk4", "libadwaita", + "taskers-control", "taskers-core 0.1.0-alpha.1", "taskers-core 0.3.0", "taskers-domain", diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index c009936..b785f83 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -492,6 +492,7 @@ struct UiState { struct TaskersCore { app_state: AppState, revision: u64, + observed_app_revision: u64, metrics: LayoutMetrics, runtime_status: RuntimeStatus, ui: UiState, @@ -499,10 +500,12 @@ struct TaskersCore { impl TaskersCore { fn with_bootstrap(bootstrap: BootstrapModel) -> Self { - let revision = bootstrap.app_state.revision().max(1); + let observed_app_revision = bootstrap.app_state.revision(); + let revision = observed_app_revision.max(1); Self { app_state: bootstrap.app_state, revision, + observed_app_revision, metrics: LayoutMetrics::default(), runtime_status: bootstrap.runtime_status, ui: UiState { @@ -1032,7 +1035,7 @@ impl TaskersCore { ) -> Option { match self.app_state.dispatch(command) { Ok(response) => { - self.sync_revision_from_app(); + let _ = self.sync_revision_from_app(); Some(response) } Err(error) => { @@ -1042,12 +1045,18 @@ impl TaskersCore { } } - fn sync_revision_from_app(&mut self) { - self.revision = self.revision.max(self.app_state.revision()); + fn sync_revision_from_app(&mut self) -> bool { + let app_revision = self.app_state.revision(); + if self.observed_app_revision == app_revision { + return false; + } + self.observed_app_revision = app_revision; + self.revision = self.revision.saturating_add(1).max(app_revision); + true } fn bump_local_revision(&mut self) { - self.revision = self.revision.max(self.app_state.revision()) + 1; + self.revision = self.revision.max(self.observed_app_revision).saturating_add(1); } } @@ -1109,6 +1118,13 @@ impl SharedCore { } } + pub fn sync_external_changes(&self) { + let mut inner = self.inner.lock(); + if inner.sync_revision_from_app() { + let _ = self.revisions.send(inner.revision()); + } + } + pub fn split_with_browser(&self) { self.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); } @@ -1500,6 +1516,8 @@ fn is_local_browser_target(value: &str) -> bool { #[cfg(test)] mod tests { + use taskers_control::ControlCommand; + use super::{ BootstrapModel, BrowserMountSpec, HostEvent, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, default_preview_app_state, @@ -1617,4 +1635,30 @@ mod tests { assert!(core.revision() > before); assert!(matches!(core.snapshot().section, ShellSection::Settings)); } + + #[test] + fn external_app_state_mutations_advance_shared_core_revision() { + let app_state = default_preview_app_state(); + let core = SharedCore::bootstrap(BootstrapModel { + app_state: app_state.clone(), + ..bootstrap() + }); + let before = core.revision(); + + let _ = app_state + .dispatch(ControlCommand::CreateWorkspace { + label: "External".into(), + }) + .expect("external mutation"); + + assert_eq!(core.revision(), before); + core.sync_external_changes(); + assert!(core.revision() > before); + assert!( + core.snapshot() + .workspaces + .iter() + .any(|workspace| workspace.title == "External") + ); + } } diff --git a/greenfield/crates/taskers/Cargo.toml b/greenfield/crates/taskers/Cargo.toml index e2090cd..ade6e4e 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/greenfield/crates/taskers/Cargo.toml @@ -18,6 +18,7 @@ dioxus.workspace = true dioxus-liveview.workspace = true gtk.workspace = true taskers-app-core.workspace = true +taskers-control.workspace = true taskers-core.workspace = true taskers-domain.workspace = true taskers-ghostty.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 824670b..6450be0 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -6,6 +6,7 @@ use gtk::glib; use std::{ cell::{Cell, RefCell}, fs::{File, OpenOptions, remove_file}, + future::pending, io::{self, Write}, net::TcpListener, path::PathBuf, @@ -20,6 +21,7 @@ use taskers_core::{ ShortcutPreset, SurfaceKind, }; use taskers_app_core::{AppState, load_or_bootstrap}; +use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; use taskers_domain::AppModel; use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; @@ -73,6 +75,7 @@ struct RuntimeBootstrap { shell_integration: RuntimeCapability, shell_launch: ShellLaunchSpec, host_options: GhosttyHostOptions, + socket_path: PathBuf, startup_notes: Vec, } @@ -288,6 +291,10 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result RuntimeBootstrap { scrub_inherited_terminal_env(); let mut startup_notes = Vec::new(); + let socket_path = default_socket_path(); let ghostty_runtime = match ensure_runtime_installed() { Ok(Some(runtime)) => { startup_notes.push(format!( @@ -322,7 +330,7 @@ fn resolve_runtime_bootstrap() -> RuntimeBootstrap { }, }; - let (shell_launch, shell_integration) = match install_shell_integration(None) { + let (mut shell_launch, shell_integration) = match install_shell_integration(None) { Ok(integration) => (integration.launch_spec(), RuntimeCapability::Ready), Err(error) => ( ShellLaunchSpec::fallback(), @@ -331,6 +339,9 @@ fn resolve_runtime_bootstrap() -> RuntimeBootstrap { }, ), }; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); @@ -339,6 +350,7 @@ fn resolve_runtime_bootstrap() -> RuntimeBootstrap { shell_integration, shell_launch, host_options, + socket_path, startup_notes, } } @@ -572,6 +584,8 @@ fn sync_window( last_size: &Cell<(i32, i32)>, diagnostics: Option<&DiagnosticsWriter>, ) { + core.sync_external_changes(); + let size = PixelSize::new(window.width().max(1), window.height().max(1)); if last_size.get() != (size.width, size.height) { core.set_window_size(size); @@ -618,6 +632,49 @@ fn sync_window( host.borrow().tick(); } +fn spawn_control_server(app_state: AppState, socket_path: PathBuf) -> String { + if let Some(parent) = socket_path.parent() + && let Err(error) = std::fs::create_dir_all(parent) + { + return format!( + "Control server disabled: failed to prepare socket directory for {} ({error})", + socket_path.display() + ); + } + + let note = format!("Control server starting on {}", socket_path.display()); + thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + runtime.block_on(async move { + match bind_socket(&socket_path) { + Ok(listener) => { + let handler = move |command| { + app_state + .dispatch(command) + .map_err(|error| error.to_string()) + }; + if let Err(error) = + serve_with_handler(listener, handler, pending::<()>()).await + { + eprintln!("control server error: {error}"); + } + } + Err(error) => { + eprintln!( + "control server unavailable at {}: {error}", + socket_path.display() + ); + } + } + }); + }); + + note +} + fn launch_liveview_server(core: SharedCore) -> Result { let listener = TcpListener::bind("127.0.0.1:0").context("failed to bind loopback port")?; listener From 9da2888ad6d8690d52e29053c89f0422a31714eb Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 15:36:42 +0100 Subject: [PATCH 23/63] feat: project greenfield workspace strip into shared shell --- greenfield/crates/taskers-core/src/lib.rs | 568 ++++++++++++++++++- greenfield/crates/taskers-shell/src/lib.rs | 113 +++- greenfield/crates/taskers-shell/src/theme.rs | 93 +++ 3 files changed, 765 insertions(+), 9 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index b785f83..b2980fd 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -9,14 +9,16 @@ use taskers_app_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ ActivityItem, AppModel, PaneKind, PaneMetadata, PaneMetadataPatch, - SplitAxis as DomainSplitAxis, SurfaceRecord, Workspace, + SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, + Workspace, DEFAULT_WORKSPACE_WINDOW_GAP, MIN_WORKSPACE_WINDOW_HEIGHT, + MIN_WORKSPACE_WINDOW_WIDTH, WorkspaceSummary as DomainWorkspaceSummary, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; use tokio::sync::watch; -pub use taskers_domain::{PaneId, SurfaceId, WorkspaceId}; +pub use taskers_domain::{PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ActivityId { @@ -50,6 +52,25 @@ impl SplitAxis { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceDirection { + Left, + Right, + Up, + Down, +} + +impl WorkspaceDirection { + fn to_domain(self) -> taskers_domain::Direction { + match self { + Self::Left => taskers_domain::Direction::Left, + Self::Right => taskers_domain::Direction::Right, + Self::Up => taskers_domain::Direction::Up, + Self::Down => taskers_domain::Direction::Down, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SurfaceKind { Terminal, @@ -388,7 +409,40 @@ pub struct WorkspaceViewSnapshot { pub attention: AttentionState, pub pane_count: usize, pub surface_count: usize, + pub active_window_id: WorkspaceWindowId, + pub viewport_origin_x: i32, + pub viewport_origin_y: i32, + pub active_pane: PaneId, + pub viewport_x: i32, + pub viewport_y: i32, + pub overview_scale: f32, + pub canvas_width: i32, + pub canvas_height: i32, + pub canvas_offset_x: i32, + pub canvas_offset_y: i32, + pub columns: Vec, + pub layout: LayoutNodeSnapshot, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WorkspaceColumnSnapshot { + pub id: WorkspaceColumnId, + pub active: bool, + pub width: i32, + pub windows: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WorkspaceWindowSnapshot { + pub id: WorkspaceWindowId, + pub column_id: WorkspaceColumnId, + pub active: bool, + pub attention: AttentionState, + pub title: String, + pub pane_count: usize, + pub surface_count: usize, pub active_pane: PaneId, + pub frame: Frame, pub layout: LayoutNodeSnapshot, } @@ -467,6 +521,9 @@ pub enum ShellAction { ToggleOverview, FocusWorkspace { workspace_id: WorkspaceId }, CreateWorkspace, + CreateWorkspaceWindow { direction: WorkspaceDirection }, + FocusWorkspaceWindow { window_id: WorkspaceWindowId }, + ScrollViewport { dx: i32, dy: i32 }, SplitBrowser { pane_id: Option }, SplitTerminal { pane_id: Option }, AddBrowserSurface { pane_id: Option }, @@ -488,6 +545,29 @@ struct UiState { window_size: PixelSize, } +#[derive(Clone, Copy)] +struct WorkspaceWindowPlacement { + window_id: WorkspaceWindowId, + column_id: WorkspaceColumnId, + frame: WindowFrame, +} + +#[derive(Clone, Copy)] +struct CanvasMetrics { + offset_x: i32, + offset_y: i32, + width: i32, + height: i32, +} + +#[derive(Clone, Copy)] +struct WorkspaceRenderContext { + overview_mode: bool, + overview_scale: f64, + viewport_width: i32, + viewport_height: i32, +} + #[derive(Clone)] struct TaskersCore { app_state: AppState, @@ -534,7 +614,34 @@ impl TaskersCore { let active_window = workspace .active_window_record() .expect("active workspace window should exist"); - let content = self.content_frame(); + let viewport = self.workspace_viewport_frame(); + let render_context = workspace_render_context( + workspace, + self.ui.overview_mode, + viewport.width, + viewport.height, + ); + let placements = workspace_display_window_placements(workspace, render_context); + let canvas_metrics = workspace_canvas_metrics(&placements); + let window_frames = placements + .iter() + .map(|placement| { + ( + placement.window_id, + ( + placement.column_id, + display_window_frame( + placement.frame, + canvas_metrics, + viewport, + workspace.viewport.x, + workspace.viewport.y, + render_context.overview_mode, + ), + ), + ) + }) + .collect::>(); ShellSnapshot { revision: self.revision, @@ -551,7 +658,18 @@ impl TaskersCore { .values() .map(|pane| pane.surfaces.len()) .sum(), + active_window_id: workspace.active_window, + viewport_origin_x: viewport.x, + viewport_origin_y: viewport.y, active_pane: workspace.active_pane, + viewport_x: workspace.viewport.x, + viewport_y: workspace.viewport.y, + overview_scale: render_context.overview_scale as f32, + canvas_width: canvas_metrics.width, + canvas_height: canvas_metrics.height, + canvas_offset_x: canvas_metrics.offset_x, + canvas_offset_y: canvas_metrics.offset_y, + columns: self.workspace_columns_snapshot(workspace, &window_frames), layout: self.snapshot_layout(workspace, &active_window.layout), }, activity: self.activity_snapshot(&model), @@ -562,9 +680,9 @@ impl TaskersCore { self.ui.window_size.width, self.ui.window_size.height, ), - content, + content: viewport, panes: if matches!(self.ui.section, ShellSection::Workspace) { - self.collect_surface_plans(workspace_id, workspace, &active_window.layout, content) + self.collect_workspace_surface_plans(workspace_id, workspace, &window_frames) } else { Vec::new() }, @@ -575,12 +693,18 @@ impl TaskersCore { } } - fn content_frame(&self) -> Frame { + fn workspace_viewport_frame(&self) -> Frame { let metrics = self.metrics; let width = (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width) .max(640); let height = self.ui.window_size.height.max(320); - Frame::new(metrics.sidebar_width, 0, width, height) + let inset = metrics.workspace_padding; + Frame::new( + metrics.sidebar_width + inset, + metrics.toolbar_height + inset, + (width - inset * 2).max(320), + (height - metrics.toolbar_height - inset * 2).max(220), + ) } fn settings_snapshot(&self) -> SettingsSnapshot { @@ -648,6 +772,67 @@ impl TaskersCore { .collect() } + fn workspace_columns_snapshot( + &self, + workspace: &Workspace, + window_frames: &BTreeMap, + ) -> Vec { + let active_column_id = workspace.active_column_id(); + workspace + .columns + .values() + .map(|column| WorkspaceColumnSnapshot { + id: column.id, + active: active_column_id == Some(column.id), + width: column.width, + windows: column + .window_order + .iter() + .filter_map(|window_id| { + let window = workspace.windows.get(window_id)?; + let (_, frame) = window_frames.get(window_id)?; + Some(self.workspace_window_snapshot( + workspace, + column.id, + window, + *frame, + )) + }) + .collect(), + }) + .collect() + } + + fn workspace_window_snapshot( + &self, + workspace: &Workspace, + column_id: WorkspaceColumnId, + window: &taskers_domain::WorkspaceWindowRecord, + frame: Frame, + ) -> WorkspaceWindowSnapshot { + let pane_ids = window.layout.leaves(); + let pane_count = pane_ids.len(); + let surface_count = pane_ids + .iter() + .filter_map(|pane_id| workspace.panes.get(pane_id)) + .map(|pane| pane.surfaces.len()) + .sum(); + let title = window_primary_title(workspace, window); + + WorkspaceWindowSnapshot { + id: window.id, + column_id, + active: workspace.active_window == window.id, + attention: workspace_window_attention(workspace, window), + title, + pane_count, + surface_count, + active_pane: window.active_pane, + frame, + layout: self.snapshot_layout(workspace, &window.layout), + } + } + fn snapshot_layout( &self, workspace: &Workspace, @@ -698,6 +883,28 @@ impl TaskersCore { } } + fn collect_workspace_surface_plans( + &self, + workspace_id: WorkspaceId, + workspace: &Workspace, + window_frames: &BTreeMap, + ) -> Vec { + workspace + .windows + .values() + .filter_map(|window| { + let (_, frame) = window_frames.get(&window.id)?; + Some(self.collect_surface_plans( + workspace_id, + workspace, + &window.layout, + *frame, + )) + }) + .flatten() + .collect() + } + fn collect_surface_plans( &self, workspace_id: WorkspaceId, @@ -811,6 +1018,11 @@ impl TaskersCore { } ShellAction::FocusWorkspace { workspace_id } => self.focus_workspace(workspace_id), ShellAction::CreateWorkspace => self.create_workspace(), + ShellAction::CreateWorkspaceWindow { direction } => { + self.create_workspace_window(direction) + } + ShellAction::FocusWorkspaceWindow { window_id } => self.focus_workspace_window(window_id), + ShellAction::ScrollViewport { dx, dy } => self.scroll_viewport_by(dx, dy), ShellAction::SplitBrowser { pane_id } => self.split_with_kind(pane_id, PaneKind::Browser), ShellAction::SplitTerminal { pane_id } => self.split_with_kind(pane_id, PaneKind::Terminal), ShellAction::AddBrowserSurface { pane_id } => { @@ -870,6 +1082,45 @@ impl TaskersCore { self.dispatch_control(ControlCommand::CreateWorkspace { label }) } + fn create_workspace_window(&mut self, direction: WorkspaceDirection) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::CreateWorkspaceWindow { + workspace_id, + direction: direction.to_domain(), + }) + } + + fn focus_workspace_window(&mut self, window_id: WorkspaceWindowId) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::FocusWorkspaceWindow { + workspace_id, + workspace_window_id: window_id, + }) + } + + fn scroll_viewport_by(&mut self, dx: i32, dy: i32) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let Some(workspace) = model.workspaces.get(&workspace_id) else { + return false; + }; + self.dispatch_control(ControlCommand::SetWorkspaceViewport { + workspace_id, + viewport: taskers_domain::WorkspaceViewport { + x: workspace.viewport.x.saturating_add(dx), + y: workspace.viewport.y.saturating_add(dy), + }, + }) + } + fn split_with_kind(&mut self, pane_id: Option, kind: PaneKind) -> bool { let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { return false; @@ -1209,6 +1460,282 @@ fn shortcut_bindings(preset: ShortcutPreset) -> Vec { .collect() } +fn display_window_frame( + frame: WindowFrame, + metrics: CanvasMetrics, + viewport: Frame, + viewport_x: i32, + viewport_y: i32, + overview_mode: bool, +) -> Frame { + let translated_x = viewport.x + metrics.offset_x + frame.x; + let translated_y = viewport.y + metrics.offset_y + frame.y; + let shift_x = if overview_mode { 0 } else { viewport_x }; + let shift_y = if overview_mode { 0 } else { viewport_y }; + Frame::new( + translated_x - shift_x, + translated_y - shift_y, + frame.width, + frame.height, + ) +} + +fn workspace_render_context( + workspace: &Workspace, + overview_mode: bool, + viewport_width: i32, + viewport_height: i32, +) -> WorkspaceRenderContext { + if !overview_mode { + return WorkspaceRenderContext { + overview_mode: false, + overview_scale: 1.0, + viewport_width, + viewport_height, + }; + } + + let base_frames = workspace_window_placements(workspace, viewport_width, viewport_height) + .into_iter() + .map(|placement| placement.frame) + .collect::>(); + let base_metrics = canvas_metrics_from_frames(&base_frames); + let content_width = (base_metrics.width - 4).max(1); + let content_height = (base_metrics.height - 4).max(1); + let overview_scale = (f64::from(viewport_width.max(1)) / f64::from(content_width)) + .min(f64::from(viewport_height.max(1)) / f64::from(content_height)) + .clamp(0.05, 1.0); + + WorkspaceRenderContext { + overview_mode: true, + overview_scale, + viewport_width, + viewport_height, + } +} + +fn workspace_display_window_placements( + workspace: &Workspace, + render_context: WorkspaceRenderContext, +) -> Vec { + workspace_window_placements( + workspace, + render_context.viewport_width, + render_context.viewport_height, + ) + .into_iter() + .map(|mut placement| { + if render_context.overview_mode { + placement.frame = scale_window_frame(placement.frame, render_context.overview_scale); + } + placement + }) + .collect() +} + +fn workspace_window_placements( + workspace: &Workspace, + viewport_width: i32, + viewport_height: i32, +) -> Vec { + let ordered_columns = workspace.columns.values().collect::>(); + if ordered_columns.is_empty() { + return Vec::new(); + } + + let horizontal_gap_total = + DEFAULT_WORKSPACE_WINDOW_GAP * ordered_columns.len().saturating_sub(1) as i32; + let available_width = (viewport_width - horizontal_gap_total).max(0); + let preferred_column_widths = ordered_columns + .iter() + .map(|column| column.width.max(1)) + .collect::>(); + let column_widths = fit_track_extents( + &preferred_column_widths, + available_width, + MIN_WORKSPACE_WINDOW_WIDTH, + ); + + let mut placements = Vec::new(); + let mut x = 0; + for (column_index, column) in ordered_columns.into_iter().enumerate() { + let column_width = column_widths + .get(column_index) + .copied() + .unwrap_or(MIN_WORKSPACE_WINDOW_WIDTH); + let vertical_gap_total = + DEFAULT_WORKSPACE_WINDOW_GAP * column.window_order.len().saturating_sub(1) as i32; + let available_height = (viewport_height - vertical_gap_total).max(0); + let preferred_window_heights = column + .window_order + .iter() + .filter_map(|window_id| workspace.windows.get(window_id).map(|window| window.height)) + .collect::>(); + let window_heights = fit_track_extents( + &preferred_window_heights, + available_height, + MIN_WORKSPACE_WINDOW_HEIGHT, + ); + + let mut y = 0; + for (window_index, window_id) in column.window_order.iter().enumerate() { + if !workspace.windows.contains_key(window_id) { + continue; + } + let window_height = window_heights + .get(window_index) + .copied() + .unwrap_or(MIN_WORKSPACE_WINDOW_HEIGHT); + placements.push(WorkspaceWindowPlacement { + window_id: *window_id, + column_id: column.id, + frame: WindowFrame { + x, + y, + width: column_width, + height: window_height, + }, + }); + y += window_height + DEFAULT_WORKSPACE_WINDOW_GAP; + } + + x += column_width + DEFAULT_WORKSPACE_WINDOW_GAP; + } + + placements +} + +fn workspace_canvas_metrics(placements: &[WorkspaceWindowPlacement]) -> CanvasMetrics { + let frames = placements.iter().map(|placement| placement.frame).collect::>(); + canvas_metrics_from_frames(&frames) +} + +fn canvas_metrics_from_frames(frames: &[WindowFrame]) -> CanvasMetrics { + let min_x = frames.iter().map(|frame| frame.x).min().unwrap_or(0); + let min_y = frames.iter().map(|frame| frame.y).min().unwrap_or(0); + let offset_x = 2 - min_x; + let offset_y = 2 - min_y; + let width = frames + .iter() + .map(|frame| frame.right() + offset_x + 2) + .max() + .unwrap_or(4); + let height = frames + .iter() + .map(|frame| frame.bottom() + offset_y + 2) + .max() + .unwrap_or(4); + + CanvasMetrics { + offset_x, + offset_y, + width, + height, + } +} + +fn scale_window_frame(frame: WindowFrame, scale: f64) -> WindowFrame { + WindowFrame { + x: (f64::from(frame.x) * scale).round() as i32, + y: (f64::from(frame.y) * scale).round() as i32, + width: (f64::from(frame.width) * scale).round() as i32, + height: (f64::from(frame.height) * scale).round() as i32, + } +} + +fn fit_track_extents(preferred_extents: &[i32], available_total: i32, min_extent: i32) -> Vec { + if preferred_extents.is_empty() { + return Vec::new(); + } + + let count = preferred_extents.len() as i32; + let min_total = min_extent.saturating_mul(count); + if available_total <= min_total { + return vec![min_extent; preferred_extents.len()]; + } + + let preferred_extents = preferred_extents + .iter() + .map(|extent| (*extent).max(1)) + .collect::>(); + let mut result = vec![0; preferred_extents.len()]; + let mut active = (0..preferred_extents.len()).collect::>(); + let mut remaining_total = available_total; + + loop { + if active.is_empty() { + break; + } + + let remaining_weight = active + .iter() + .map(|index| i64::from(preferred_extents[*index])) + .sum::() + .max(1); + let below_minimum = active + .iter() + .copied() + .filter(|index| { + (f64::from(remaining_total) * f64::from(preferred_extents[*index])) + / (remaining_weight as f64) + < f64::from(min_extent) + }) + .collect::>(); + + if below_minimum.is_empty() { + let distributed = distribute_weighted_total( + &active + .iter() + .map(|index| preferred_extents[*index]) + .collect::>(), + remaining_total, + ); + for (slot, index) in active.iter().enumerate() { + result[*index] = distributed[slot]; + } + break; + } + + for index in below_minimum { + result[index] = min_extent; + remaining_total -= min_extent; + active.retain(|candidate| *candidate != index); + } + } + + result +} + +fn distribute_weighted_total(weights: &[i32], total: i32) -> Vec { + if weights.is_empty() { + return Vec::new(); + } + let weight_sum = weights.iter().map(|weight| i64::from(*weight)).sum::().max(1); + let mut distributed = Vec::with_capacity(weights.len()); + let mut allocated = 0; + let mut remainders = Vec::with_capacity(weights.len()); + + for (index, weight) in weights.iter().copied().enumerate() { + let scaled = i64::from(total) * i64::from(weight); + let base = (scaled / weight_sum) as i32; + distributed.push(base); + allocated += base; + remainders.push((index, scaled % weight_sum)); + } + + let mut remaining = total - allocated; + remainders.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + for (index, _) in remainders.into_iter().take(remaining.max(0) as usize) { + distributed[index] += 1; + remaining -= 1; + if remaining <= 0 { + break; + } + } + + distributed +} + fn default_preview_app_state() -> AppState { let mut model = AppModel::new("Main"); let workspace_id = model.active_workspace_id().expect("workspace"); @@ -1337,6 +1864,33 @@ fn workspace_attention(workspace: &Workspace) -> AttentionState { .into() } +fn workspace_window_attention( + workspace: &Workspace, + window: &taskers_domain::WorkspaceWindowRecord, +) -> AttentionState { + window + .layout + .leaves() + .into_iter() + .filter_map(|pane_id| workspace.panes.get(&pane_id)) + .map(|pane| pane.highest_attention()) + .max_by_key(|attention| attention.rank()) + .unwrap_or(taskers_domain::AttentionState::Normal) + .into() +} + +fn window_primary_title( + workspace: &Workspace, + window: &taskers_domain::WorkspaceWindowRecord, +) -> String { + workspace + .panes + .get(&window.active_pane) + .and_then(|pane| pane.active_surface()) + .map(display_surface_title) + .unwrap_or_else(|| "Workspace window".into()) +} + fn display_surface_title(surface: &SurfaceRecord) -> String { if let Some(title) = surface .metadata diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 6c7de90..e2536fe 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -4,7 +4,8 @@ use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AttentionState, LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, - ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceSummary, + ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceDirection, + WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, }; fn app_css(snapshot: &ShellSnapshot) -> String { @@ -74,6 +75,30 @@ pub fn TaskersShell(core: SharedCore) -> Element { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::ToggleOverview) }; + let scroll_left = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ScrollViewport { dx: -360, dy: 0 }) + }; + let scroll_right = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::ScrollViewport { dx: 360, dy: 0 }) + }; + let create_window_right = { + let core = core.clone(); + move |_| { + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { + direction: WorkspaceDirection::Right, + }) + } + }; + let create_window_down = { + let core = core.clone(); + move |_| { + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { + direction: WorkspaceDirection::Down, + }) + } + }; let main_class = match snapshot.section { ShellSection::Workspace => { @@ -147,6 +172,10 @@ pub fn TaskersShell(core: SharedCore) -> Element { onclick: toggle_overview, "Overview" } + button { class: "workspace-header-action", onclick: scroll_left, "←" } + button { class: "workspace-header-action", onclick: scroll_right, "→" } + button { class: "workspace-header-action", onclick: create_window_right, "+ column" } + button { class: "workspace-header-action", onclick: create_window_down, "+ stack" } button { class: "workspace-header-action", onclick: split_terminal, @@ -169,7 +198,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { if matches!(snapshot.section, ShellSection::Workspace) { div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, - {render_layout(&snapshot.current_workspace.layout, core.clone(), &snapshot.runtime_status)} + {render_workspace_strip(&snapshot.current_workspace, core.clone(), &snapshot.runtime_status)} } } else { div { class: "settings-canvas", @@ -296,6 +325,86 @@ fn render_layout( } } +fn render_workspace_strip( + workspace: &WorkspaceViewSnapshot, + core: SharedCore, + runtime_status: &RuntimeStatus, +) -> Element { + let translate_x = if workspace.overview_scale < 1.0 { + 0 + } else { + -workspace.viewport_x + }; + let translate_y = if workspace.overview_scale < 1.0 { + 0 + } else { + -workspace.viewport_y + }; + let canvas_style = format!( + "width:{}px;height:{}px;transform:translate({}px, {}px);", + workspace.canvas_width, workspace.canvas_height, translate_x, translate_y + ); + + rsx! { + div { class: "workspace-viewport", + div { class: "workspace-strip-canvas", style: "{canvas_style}", + for column in &workspace.columns { + for window in &column.windows { + {render_workspace_window(window, workspace, core.clone(), runtime_status)} + } + } + } + } + } +} + +fn render_workspace_window( + window: &WorkspaceWindowSnapshot, + workspace: &WorkspaceViewSnapshot, + core: SharedCore, + runtime_status: &RuntimeStatus, +) -> Element { + let local_x = window.frame.x - workspace.viewport_origin_x; + let local_y = window.frame.y - workspace.viewport_origin_y; + let style = format!( + "left:{}px;top:{}px;width:{}px;height:{}px;", + local_x, local_y, window.frame.width, window.frame.height + ); + let window_class = if window.active { + format!( + "workspace-window-shell workspace-window-shell-active workspace-window-shell-state-{}", + window.attention.slug() + ) + } else { + format!( + "workspace-window-shell workspace-window-shell-state-{}", + window.attention.slug() + ) + }; + let window_id = window.id; + let focus_core = core.clone(); + let focus_window = move |_| { + focus_core.dispatch_shell_action(ShellAction::FocusWorkspaceWindow { window_id }); + }; + + rsx! { + section { class: "{window_class}", style: "{style}", + div { class: "workspace-window-toolbar", + button { class: "workspace-window-title", onclick: focus_window, + span { class: "workspace-label", "{window.title}" } + span { class: "workspace-meta", "{window.pane_count} panes · {window.surface_count} surfaces" } + } + div { class: "workspace-window-flags", + span { class: format!("status-pill status-pill-inline status-pill-{}", window.attention.slug()), "{window.attention.label()}" } + } + } + div { class: "workspace-window-body", + {render_layout(&window.layout, core.clone(), runtime_status)} + } + } + } +} + fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeStatus) -> Element { let pane_class = if pane.active { format!("pane-card pane-card-active pane-card-state-{}", pane.attention.slug()) diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 6f64f6a..1f9c3b2 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -568,9 +568,102 @@ button {{ .settings-canvas {{ flex: 1; min-height: 0; +}} + +.workspace-canvas {{ + position: relative; + overflow: hidden; +}} + +.settings-canvas {{ padding: 16px; }} +.workspace-viewport {{ + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +}} + +.workspace-strip-canvas {{ + position: absolute; + inset: 0 auto auto 0; + transform-origin: top left; +}} + +.workspace-window-shell {{ + position: absolute; + display: flex; + flex-direction: column; + border-radius: 10px; + background: {elevated}; + border: 1px solid {border_08}; + overflow: hidden; + box-shadow: 0 18px 42px {overlay_16}; +}} + +.workspace-window-shell-active {{ + border-color: {accent_24}; + box-shadow: 0 18px 42px {overlay_16}, 0 0 0 1px {accent_24}; +}} + +.workspace-window-shell-state-busy {{ + box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {busy_10}; +}} + +.workspace-window-shell-state-completed {{ + box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {completed_10}; +}} + +.workspace-window-shell-state-waiting {{ + box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {waiting_10}; +}} + +.workspace-window-shell-state-error {{ + box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {error_10}; +}} + +.workspace-window-toolbar {{ + min-height: 38px; + border-bottom: 1px solid {border_07}; + background: {surface}; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +}} + +.workspace-window-title {{ + background: transparent; + border: 0; + color: inherit; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + text-align: left; +}} + +.workspace-window-title:hover {{ + color: {text_bright}; +}} + +.workspace-window-flags {{ + display: flex; + align-items: center; + gap: 6px; +}} + +.workspace-window-body {{ + flex: 1; + min-height: 0; + padding: 10px; + background: {border_02}; +}} + .split-container {{ width: 100%; height: 100%; From b9fc47950a33578ae6da5e97fa43cbd034314c72 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 15:45:08 +0100 Subject: [PATCH 24/63] feat: add cmux-style agent center and operator cli --- .../assets/taskers-codex-notify.sh | 2 +- crates/taskers-cli/src/main.rs | 515 +++++++++++++++++- crates/taskers-core/src/app_state.rs | 5 + .../assets/shell/taskers-hooks.bash | 62 ++- .../assets/shell/taskers-hooks.zsh | 50 +- greenfield/crates/taskers-core/src/lib.rs | 147 ++++- greenfield/crates/taskers-shell/src/lib.rs | 84 ++- greenfield/crates/taskers-shell/src/theme.rs | 7 + 8 files changed, 801 insertions(+), 71 deletions(-) diff --git a/crates/taskers-cli/assets/taskers-codex-notify.sh b/crates/taskers-cli/assets/taskers-codex-notify.sh index 87a8432..2dd382a 100644 --- a/crates/taskers-cli/assets/taskers-codex-notify.sh +++ b/crates/taskers-cli/assets/taskers-codex-notify.sh @@ -19,5 +19,5 @@ if [ -z "$message" ]; then fi if command -v taskersctl >/dev/null 2>&1; then - taskersctl notify --title Codex --body "$message" --agent codex >/dev/null 2>&1 || true + taskersctl agent-hook notification --agent codex --title Codex --message "$message" >/dev/null 2>&1 || true fi diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 79bacb3..9ddc5e4 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -84,6 +84,14 @@ enum Command { #[command(subcommand)] command: WorkspaceCommand, }, + AgentHook { + #[command(subcommand)] + command: AgentHookCommand, + }, + Browser { + #[command(subcommand)] + command: BrowserCommand, + }, Pane { #[command(subcommand)] command: PaneCommand, @@ -100,10 +108,26 @@ enum QueryCommand { #[arg(long)] socket: Option, }, + Agents { + #[arg(long)] + socket: Option, + }, + Notifications { + #[arg(long)] + socket: Option, + }, + Tree { + #[arg(long)] + socket: Option, + }, } #[derive(Debug, Subcommand)] enum WorkspaceCommand { + List { + #[arg(long)] + socket: Option, + }, New { #[arg(long)] socket: Option, @@ -132,6 +156,132 @@ enum WorkspaceCommand { }, } +#[derive(Debug, Subcommand)] +enum AgentHookCommand { + SessionStart { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, + Active { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, + Progress { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, + Waiting { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, + Notification { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, + Stop { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long)] + agent: Option, + #[arg(long)] + title: Option, + #[arg(long)] + message: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum BrowserCommand { + Open { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + url: Option, + }, + Navigate { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: SurfaceId, + #[arg(long)] + url: String, + }, +} + #[derive(Debug, Subcommand)] enum PaneCommand { NewWindow { @@ -357,15 +507,69 @@ async fn main() -> anyhow::Result<()> { serve(listener, controller, pending()).await?; } Command::Query { - query: QueryCommand::Status { socket }, - } => { - let client = ControlClient::new(resolve_socket_path(socket)); - let response = client - .send(ControlCommand::QueryStatus { - query: ControlQuery::All, - }) - .await?; - println!("{}", serde_json::to_string_pretty(&response)?); + query, + } => match query { + QueryCommand::Status { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = client + .send(ControlCommand::QueryStatus { + query: ControlQuery::All, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + QueryCommand::Agents { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let payload = model + .workspace_summaries(model.active_window)? + .into_iter() + .flat_map(|workspace| { + let workspace_id = workspace.workspace_id; + let workspace_label = workspace.label.clone(); + workspace.agent_summaries.into_iter().map(move |agent| { + serde_json::json!({ + "workspace_id": workspace_id, + "workspace_label": workspace_label, + "workspace_window_id": agent.workspace_window_id, + "pane_id": agent.pane_id, + "surface_id": agent.surface_id, + "agent_kind": agent.agent_kind, + "title": agent.title, + "state": format!("{:?}", agent.state).to_lowercase(), + "last_signal_at": agent.last_signal_at, + }) + }) + }) + .collect::>(); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + QueryCommand::Notifications { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let payload = model + .activity_items() + .into_iter() + .map(|item| { + serde_json::json!({ + "workspace_id": item.workspace_id, + "workspace_window_id": item.workspace_window_id, + "pane_id": item.pane_id, + "surface_id": item.surface_id, + "kind": format!("{:?}", item.kind).to_lowercase(), + "state": format!("{:?}", item.state).to_lowercase(), + "message": item.message, + "created_at": item.created_at, + }) + }) + .collect::>(); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + QueryCommand::Tree { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + println!("{}", serde_json::to_string_pretty(&model)?); + } } Command::Signal { socket, @@ -476,6 +680,29 @@ async fn main() -> anyhow::Result<()> { println!("{}", serde_json::to_string_pretty(&response)?); } Command::Workspace { command } => match command { + WorkspaceCommand::List { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let active_workspace = model.active_workspace_id(); + let payload = model + .workspace_summaries(model.active_window)? + .into_iter() + .map(|workspace| { + serde_json::json!({ + "workspace_id": workspace.workspace_id, + "label": workspace.label, + "active": active_workspace == Some(workspace.workspace_id), + "unread_count": workspace.unread_count, + "highest_attention": format!("{:?}", workspace.highest_attention).to_lowercase(), + "display_attention": format!("{:?}", workspace.display_attention).to_lowercase(), + "agent_count": workspace.agent_summaries.len(), + "repo_hint": workspace.repo_hint, + "latest_notification": workspace.latest_notification, + }) + }) + .collect::>(); + println!("{}", serde_json::to_string_pretty(&payload)?); + } WorkspaceCommand::New { socket, label } => { let client = ControlClient::new(resolve_socket_path(socket)); let response = client @@ -517,6 +744,219 @@ async fn main() -> anyhow::Result<()> { println!("{}", serde_json::to_string_pretty(&response)?); } }, + Command::AgentHook { command } => match command { + AgentHookCommand::SessionStart { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } => { + emit_agent_hook( + socket, + workspace, + pane, + surface, + agent, + title, + message, + CliSignalKind::Started, + ) + .await?; + } + AgentHookCommand::Active { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } + | AgentHookCommand::Progress { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } => { + emit_agent_hook( + socket, + workspace, + pane, + surface, + agent, + title, + message, + CliSignalKind::Progress, + ) + .await?; + } + AgentHookCommand::Waiting { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } => { + emit_agent_hook( + socket, + workspace, + pane, + surface, + agent, + title, + message, + CliSignalKind::WaitingInput, + ) + .await?; + } + AgentHookCommand::Notification { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } => { + emit_agent_hook( + socket, + workspace, + pane, + surface, + agent, + title, + message, + CliSignalKind::Notification, + ) + .await?; + } + AgentHookCommand::Stop { + socket, + workspace, + pane, + surface, + agent, + title, + message, + } => { + emit_agent_hook( + socket, + workspace, + pane, + surface, + agent, + title, + message, + CliSignalKind::Completed, + ) + .await?; + } + }, + Command::Browser { command } => match command { + BrowserCommand::Open { + socket, + workspace, + pane, + url, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = workspace + .or_else(env_workspace_id) + .or_else(|| model.active_workspace_id()) + .context("missing workspace id; pass --workspace or run from inside Taskers")?; + let target_pane = pane + .or_else(env_pane_id) + .or_else(|| { + model.workspaces + .get(&workspace_id) + .map(|workspace| workspace.active_pane) + }); + let response = send_control_command( + &client, + ControlCommand::SplitPane { + workspace_id, + pane_id: target_pane, + axis: SplitAxis::Horizontal, + }, + ) + .await?; + let pane_id = match response { + ControlResponse::PaneSplit { pane_id } => pane_id, + other => bail!("unexpected browser open response: {other:?}"), + }; + let placeholder_surface_id = + active_surface_for_pane(&query_model(&client).await?, workspace_id, pane_id)?; + let surface_id = + create_surface(&client, workspace_id, pane_id, PaneKind::Browser, url.clone()) + .await?; + send_control_command( + &client, + ControlCommand::CloseSurface { + workspace_id, + pane_id, + surface_id: placeholder_surface_id, + }, + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "browser_opened", + "workspace_id": workspace_id, + "pane_id": pane_id, + "surface_id": surface_id, + "url": url, + }))? + ); + } + BrowserCommand::Navigate { + socket, + workspace, + pane, + surface, + url, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + if let Some(pane_id) = pane { + let workspace_id = workspace + .or_else(env_workspace_id) + .context("missing workspace id; pass --workspace or run from inside Taskers")?; + let _ = send_control_command( + &client, + ControlCommand::FocusSurface { + workspace_id, + pane_id, + surface_id: surface, + }, + ) + .await; + } + let response = client + .send(ControlCommand::UpdateSurfaceMetadata { + surface_id: surface, + patch: PaneMetadataPatch { + title: None, + cwd: None, + url: Some(url), + repo_name: None, + git_branch: None, + ports: None, + agent_kind: None, + }, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, Command::Pane { command } => match command { PaneCommand::NewWindow { socket, @@ -890,6 +1330,63 @@ async fn create_surface( Ok(surface_id) } +async fn emit_agent_hook( + socket: Option, + workspace: Option, + pane: Option, + surface: Option, + agent: Option, + title: Option, + message: Option, + kind: CliSignalKind, +) -> anyhow::Result<()> { + let workspace_id = workspace + .or_else(env_workspace_id) + .context("missing workspace id; pass --workspace or run from inside Taskers")?; + let pane_id = pane + .or_else(env_pane_id) + .context("missing pane id; pass --pane or run from inside Taskers")?; + let surface_id = surface.or_else(env_surface_id); + let client = ControlClient::new(resolve_socket_path(socket)); + + let normalized_agent = agent + .or_else(|| title.as_deref().and_then(infer_agent_kind)) + .unwrap_or_else(|| "shell".into()); + let normalized_title = title.unwrap_or_else(|| normalized_agent.clone()); + let metadata = Some(taskers_domain::SignalPaneMetadata { + title: Some(normalized_title.clone()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some(normalized_agent.clone()), + agent_active: Some(matches!( + kind, + CliSignalKind::Started + | CliSignalKind::Progress + | CliSignalKind::WaitingInput + | CliSignalKind::Notification + )), + }); + + let response = client + .send(ControlCommand::EmitSignal { + workspace_id, + pane_id, + surface_id, + event: SignalEvent { + source: format!("agent-hook:{normalized_agent}"), + kind: kind.into(), + message, + metadata, + timestamp: OffsetDateTime::now_utc(), + }, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) +} + fn infer_agent_kind(value: &str) -> Option { let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index c36f7c0..74631c0 100644 --- a/crates/taskers-core/src/app_state.rs +++ b/crates/taskers-core/src/app_state.rs @@ -122,6 +122,10 @@ impl AppState { env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); + env.insert( + "TASKERS_AGENT_SESSION_ID".into(), + surface.session_id.to_string(), + ); env } PaneKind::Browser => BTreeMap::new(), @@ -188,6 +192,7 @@ mod tests { Some(&pane.to_string()) ); assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID")); + assert!(descriptor.env.contains_key("TASKERS_AGENT_SESSION_ID")); } #[test] diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.bash b/crates/taskers-runtime/assets/shell/taskers-hooks.bash index 6b12ad3..a366fd0 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.bash +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.bash @@ -120,25 +120,49 @@ taskers__emit_with_metadata() { [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 0 [ -n "${TASKERS_PANE_ID:-}" ] || return 0 - argv=( - "$TASKERS_CTL_PATH" - signal - --source shell - --kind "$kind" - --title "$TASKERS_META_TITLE" - --cwd "$TASKERS_META_CWD" - --agent "$TASKERS_META_AGENT" - --agent-active "$agent_active" - ) - - if [ -n "$TASKERS_META_REPO_NAME" ]; then - argv+=(--repo "$TASKERS_META_REPO_NAME") - fi - if [ -n "$TASKERS_META_BRANCH" ]; then - argv+=(--branch "$TASKERS_META_BRANCH") - fi - if [ -n "$message" ]; then - argv+=(--message "$message") + if [ "$kind" = "metadata" ]; then + argv=( + "$TASKERS_CTL_PATH" + signal + --source shell + --kind "$kind" + --title "$TASKERS_META_TITLE" + --cwd "$TASKERS_META_CWD" + --agent "$TASKERS_META_AGENT" + --agent-active "$agent_active" + ) + + if [ -n "$TASKERS_META_REPO_NAME" ]; then + argv+=(--repo "$TASKERS_META_REPO_NAME") + fi + if [ -n "$TASKERS_META_BRANCH" ]; then + argv+=(--branch "$TASKERS_META_BRANCH") + fi + if [ -n "$message" ]; then + argv+=(--message "$message") + fi + else + local subcommand + case "$kind" in + started) subcommand=session-start ;; + progress) subcommand=progress ;; + waiting_input) subcommand=waiting ;; + notification) subcommand=notification ;; + completed|error) subcommand=stop ;; + *) subcommand=active ;; + esac + + argv=( + "$TASKERS_CTL_PATH" + agent-hook + "$subcommand" + --agent "$TASKERS_META_AGENT" + --title "$TASKERS_META_TITLE" + ) + + if [ -n "$message" ]; then + argv+=(--message "$message") + fi fi ( diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh index 677707d..307587c 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh @@ -114,20 +114,42 @@ taskers__emit_with_metadata() { [[ -n "${TASKERS_WORKSPACE_ID:-}" ]] || return 0 [[ -n "${TASKERS_PANE_ID:-}" ]] || return 0 - argv=( - "$TASKERS_CTL_PATH" - signal - --source shell - --kind "$kind" - --title "$TASKERS_META_TITLE" - --cwd "$TASKERS_META_CWD" - --agent "$TASKERS_META_AGENT" - --agent-active "$agent_active" - ) - - [[ -n "$TASKERS_META_REPO_NAME" ]] && argv+=(--repo "$TASKERS_META_REPO_NAME") - [[ -n "$TASKERS_META_BRANCH" ]] && argv+=(--branch "$TASKERS_META_BRANCH") - [[ -n "$message" ]] && argv+=(--message "$message") + if [[ "$kind" = "metadata" ]]; then + argv=( + "$TASKERS_CTL_PATH" + signal + --source shell + --kind "$kind" + --title "$TASKERS_META_TITLE" + --cwd "$TASKERS_META_CWD" + --agent "$TASKERS_META_AGENT" + --agent-active "$agent_active" + ) + + [[ -n "$TASKERS_META_REPO_NAME" ]] && argv+=(--repo "$TASKERS_META_REPO_NAME") + [[ -n "$TASKERS_META_BRANCH" ]] && argv+=(--branch "$TASKERS_META_BRANCH") + [[ -n "$message" ]] && argv+=(--message "$message") + else + local subcommand + case "$kind" in + started) subcommand=session-start ;; + progress) subcommand=progress ;; + waiting_input) subcommand=waiting ;; + notification) subcommand=notification ;; + completed|error) subcommand=stop ;; + *) subcommand=active ;; + esac + + argv=( + "$TASKERS_CTL_PATH" + agent-hook + "$subcommand" + --agent "$TASKERS_META_AGENT" + --title "$TASKERS_META_TITLE" + ) + + [[ -n "$message" ]] && argv+=(--message "$message") + fi { exec &'static str { + match self { + Self::Working => "Working", + Self::Waiting => "Waiting", + Self::Inactive => "Inactive", + } + } + + pub fn slug(self) -> &'static str { + match self { + Self::Working => "busy", + Self::Waiting => "waiting", + Self::Inactive => "completed", + } + } +} + +impl From for AgentStateSnapshot { + fn from(value: taskers_domain::WorkspaceAgentState) -> Self { + match value { + taskers_domain::WorkspaceAgentState::Working => Self::Working, + taskers_domain::WorkspaceAgentState::Waiting => Self::Waiting, + taskers_domain::WorkspaceAgentState::Inactive => Self::Inactive, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentSessionSnapshot { + pub workspace_id: WorkspaceId, + pub workspace_title: String, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub agent_kind: String, + pub title: String, + pub state: AgentStateSnapshot, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThemeOptionSnapshot { pub id: String, @@ -499,7 +547,9 @@ pub struct ShellSnapshot { pub overview_mode: bool, pub workspaces: Vec, pub current_workspace: WorkspaceViewSnapshot, + pub agents: Vec, pub activity: Vec, + pub done_activity: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, @@ -672,7 +722,9 @@ impl TaskersCore { columns: self.workspace_columns_snapshot(workspace, &window_frames), layout: self.snapshot_layout(workspace, &active_window.layout), }, + agents: self.agent_sessions_snapshot(&model), activity: self.activity_snapshot(&model), + done_activity: self.done_activity_snapshot(&model), portal: SurfacePortalPlan { window: Frame::new( 0, @@ -745,30 +797,75 @@ impl TaskersCore { .get(&summary.workspace_id) .map(workspace_surface_count) .unwrap_or_default(), + agent_count: summary.agent_summaries.len(), + waiting_agent_count: summary + .agent_summaries + .iter() + .filter(|agent| { + matches!(agent.state, taskers_domain::WorkspaceAgentState::Waiting) + }) + .count(), unread_activity: summary.unread_count, attention: summary.display_attention.into(), }) .collect() } + fn agent_sessions_snapshot(&self, model: &AppModel) -> Vec { + let active_window = model.active_window; + model + .workspace_summaries(active_window) + .unwrap_or_default() + .into_iter() + .flat_map(|summary| { + summary.agent_summaries.into_iter().map(move |agent| AgentSessionSnapshot { + workspace_id: summary.workspace_id, + workspace_title: summary.label.clone(), + pane_id: agent.pane_id, + surface_id: agent.surface_id, + agent_kind: agent.agent_kind.clone(), + title: agent + .title + .clone() + .unwrap_or_else(|| format!("{} {}", agent.agent_kind, agent.state.label())), + state: agent.state.into(), + }) + }) + .collect() + } + fn activity_snapshot(&self, model: &AppModel) -> Vec { model.activity_items() .into_iter() - .map(|item| ActivityItemSnapshot { - id: ActivityId { - workspace_id: item.workspace_id, - pane_id: item.pane_id, - surface_id: item.surface_id, - }, - title: activity_title(model, &item), - preview: compact_preview(&item.message), - meta: activity_context_line(model, &item), - attention: item.state.into(), - workspace_id: item.workspace_id, - pane_id: Some(item.pane_id), - surface_id: Some(item.surface_id), - unread: true, + .map(|item| activity_item_snapshot(model, &item, true)) + .collect() + } + + fn done_activity_snapshot(&self, model: &AppModel) -> Vec { + let mut items = model + .workspaces + .values() + .flat_map(|workspace| { + workspace + .notifications + .iter() + .filter(|notification| notification.cleared_at.is_some()) + .map(move |notification| ActivityItem { + workspace_id: workspace.id, + workspace_window_id: workspace.window_for_pane(notification.pane_id), + pane_id: notification.pane_id, + surface_id: notification.surface_id, + kind: notification.kind.clone(), + state: notification.state, + message: notification.message.clone(), + created_at: notification.created_at, + }) }) + .collect::>(); + items.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + items + .into_iter() + .map(|item| activity_item_snapshot(model, &item, false)) .collect() } @@ -1959,6 +2056,28 @@ fn activity_title(model: &AppModel, item: &ActivityItem) -> String { .unwrap_or_else(|| "Terminal pane".into()) } +fn activity_item_snapshot( + model: &AppModel, + item: &ActivityItem, + unread: bool, +) -> ActivityItemSnapshot { + ActivityItemSnapshot { + id: ActivityId { + workspace_id: item.workspace_id, + pane_id: item.pane_id, + surface_id: item.surface_id, + }, + title: activity_title(model, item), + preview: compact_preview(&item.message), + meta: activity_context_line(model, item), + attention: item.state.into(), + workspace_id: item.workspace_id, + pane_id: Some(item.pane_id), + surface_id: Some(item.surface_id), + unread, + } +} + fn activity_context_line(model: &AppModel, item: &ActivityItem) -> String { let workspace_label = model .workspaces diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index e2536fe..a82e88d 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -2,10 +2,10 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ - ActivityItemSnapshot, AttentionState, LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, - RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, - ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceDirection, - WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, + ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, LayoutNodeSnapshot, PaneSnapshot, + RuntimeCapability, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, + ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, + WorkspaceDirection, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, }; fn app_css(snapshot: &ShellSnapshot) -> String { @@ -208,11 +208,22 @@ pub fn TaskersShell(core: SharedCore) -> Element { } aside { class: "attention-panel", - div { class: "sidebar-heading", "Attention" } + div { class: "sidebar-heading", "Agents" } div { class: "attention-summary", - div { class: "workspace-label", "{snapshot.activity.len()} unread items" } - div { class: "workspace-meta", "Focus stays in the shared shell while native hosts report metadata and lifecycle changes back into core." } + div { class: "workspace-label", "{snapshot.agents.len()} live agents" } + div { class: "workspace-meta", "{snapshot.activity.len()} unread · {snapshot.done_activity.len()} done" } } + if snapshot.agents.is_empty() { + div { class: "empty-state", "No live agents." } + } else { + div { class: "sidebar-heading", "Live sessions" } + div { class: "activity-list", + for agent in &snapshot.agents { + {render_agent_item(agent, core.clone(), &snapshot.current_workspace)} + } + } + } + div { class: "sidebar-heading", "Inbox" } if snapshot.activity.is_empty() { div { class: "empty-state", "No unread items." } } else { @@ -222,6 +233,14 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } } + if !snapshot.done_activity.is_empty() { + div { class: "sidebar-heading", "Done" } + div { class: "activity-list", + for item in snapshot.done_activity.iter().take(6) { + {render_activity_item(item, core.clone(), &snapshot.current_workspace)} + } + } + } } } } @@ -242,6 +261,13 @@ fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Elem workspace.attention.slug() ) }; + let badge_text = if workspace.unread_activity > 0 { + workspace.unread_activity.to_string() + } else if workspace.waiting_agent_count > 0 { + workspace.waiting_agent_count.to_string() + } else { + workspace.attention.label().to_string() + }; let workspace_id = workspace.id; let focus_workspace = move |_| { core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); @@ -254,15 +280,11 @@ fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Elem div { class: "workspace-label", "{workspace.title}" } div { class: "workspace-preview", "{workspace.preview}" } div { class: "workspace-meta", - "{workspace.pane_count} panes · {workspace.surface_count} surfaces" + "{workspace.pane_count} panes · {workspace.surface_count} surfaces · {workspace.agent_count} agents" } } div { class: "{badge_class}", - if workspace.unread_activity > 0 { - "{workspace.unread_activity}" - } else { - "{workspace.attention.label()}" - } + "{badge_text}" } } } @@ -571,6 +593,36 @@ fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeSt } } +fn render_agent_item( + agent: &AgentSessionSnapshot, + core: SharedCore, + current_workspace: &taskers_core::WorkspaceViewSnapshot, +) -> Element { + let row_class = format!("activity-item activity-item-state-{}", agent.state.slug()); + let workspace_id = agent.workspace_id; + let pane_id = agent.pane_id; + let surface_id = agent.surface_id; + let current_workspace_id = current_workspace.id; + let focus_target = move |_| { + if workspace_id != current_workspace_id { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + } + core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + }; + + rsx! { + button { class: "activity-item-button", onclick: focus_target, + div { class: "{row_class}", + div { class: "activity-header", + div { class: "workspace-label", "{agent.title}" } + div { class: "activity-time", "{agent.state.label()}" } + } + div { class: "activity-meta", "{agent.workspace_title} · {agent.agent_kind}" } + } + } + } +} + fn render_activity_item( item: &ActivityItemSnapshot, core: SharedCore, @@ -613,7 +665,11 @@ fn render_activity_item( div { class: "activity-preview", "{item.preview}" } } } - button { class: "activity-action", onclick: dismiss, "Done" } + if item.unread { + button { class: "activity-action", onclick: dismiss, "Done" } + } else { + div { class: "activity-action activity-action-passive", "Seen" } + } } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 1f9c3b2..3c23996 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -549,6 +549,13 @@ button {{ color: {text_bright}; }} +.activity-action-passive {{ + display: inline-flex; + align-items: center; + color: {text_dim}; + background: {border_04}; +}} + .workspace-header-action-active {{ background: {accent_14}; color: {text_bright}; From 541b6f472594e05740d454bbca6407d6260ad4f8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 15:58:11 +0100 Subject: [PATCH 25/63] feat: add shared shell browser chrome parity --- greenfield/crates/taskers-core/src/lib.rs | 127 ++++++++++++++++++- greenfield/crates/taskers-host/src/lib.rs | 92 +++++++++++++- greenfield/crates/taskers-shell/src/lib.rs | 66 ++++++++++ greenfield/crates/taskers-shell/src/theme.rs | 49 +++++++ greenfield/crates/taskers/src/main.rs | 14 ++ 5 files changed, 339 insertions(+), 9 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index bba1b2f..a4124ab 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -1,6 +1,6 @@ use parking_lot::Mutex; use std::{ - collections::BTreeMap, + collections::{BTreeMap, VecDeque}, fmt, path::PathBuf, sync::Arc, @@ -461,6 +461,14 @@ pub struct ActivityItemSnapshot { pub unread: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserChromeSnapshot { + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub title: String, + pub url: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentStateSnapshot { Working, @@ -547,6 +555,7 @@ pub struct ShellSnapshot { pub overview_mode: bool, pub workspaces: Vec, pub current_workspace: WorkspaceViewSnapshot, + pub browser_chrome: Option, pub agents: Vec, pub activity: Vec, pub done_activity: Vec, @@ -565,6 +574,14 @@ pub enum HostEvent { SurfaceCwdChanged { surface_id: SurfaceId, cwd: String }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostCommand { + BrowserBack { surface_id: SurfaceId }, + BrowserForward { surface_id: SurfaceId }, + BrowserReload { surface_id: SurfaceId }, + BrowserToggleDevtools { surface_id: SurfaceId }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ShellAction { ShowSection { section: ShellSection }, @@ -580,6 +597,11 @@ pub enum ShellAction { AddTerminalSurface { pane_id: Option }, FocusPane { pane_id: PaneId }, FocusSurface { pane_id: PaneId, surface_id: SurfaceId }, + NavigateBrowser { surface_id: SurfaceId, url: String }, + BrowserBack { surface_id: SurfaceId }, + BrowserForward { surface_id: SurfaceId }, + BrowserReload { surface_id: SurfaceId }, + ToggleBrowserDevtools { surface_id: SurfaceId }, CloseSurface { pane_id: PaneId, surface_id: SurfaceId }, DismissActivity { activity_id: ActivityId }, SelectTheme { theme_id: String }, @@ -626,6 +648,7 @@ struct TaskersCore { metrics: LayoutMetrics, runtime_status: RuntimeStatus, ui: UiState, + host_commands: VecDeque, } impl TaskersCore { @@ -645,6 +668,7 @@ impl TaskersCore { selected_shortcut_preset: bootstrap.selected_shortcut_preset, window_size: PixelSize::new(1440, 900), }, + host_commands: VecDeque::new(), } } @@ -722,6 +746,7 @@ impl TaskersCore { columns: self.workspace_columns_snapshot(workspace, &window_frames), layout: self.snapshot_layout(workspace, &active_window.layout), }, + browser_chrome: self.browser_chrome_snapshot(workspace), agents: self.agent_sessions_snapshot(&model), activity: self.activity_snapshot(&model), done_activity: self.done_activity_snapshot(&model), @@ -980,6 +1005,21 @@ impl TaskersCore { } } + fn browser_chrome_snapshot(&self, workspace: &Workspace) -> Option { + let pane = workspace.panes.get(&workspace.active_pane)?; + let surface = pane.active_surface()?; + if surface.kind != PaneKind::Browser { + return None; + } + + Some(BrowserChromeSnapshot { + pane_id: pane.id, + surface_id: surface.id, + title: display_surface_title(surface), + url: normalized_surface_url(surface).unwrap_or_else(|| "about:blank".into()), + }) + } + fn collect_workspace_surface_plans( &self, workspace_id: WorkspaceId, @@ -1019,7 +1059,7 @@ impl TaskersCore { pane_id: pane.id, surface_id: active_surface.id, active: workspace.active_pane == pane.id, - frame: pane_body_frame(frame, self.metrics), + frame: pane_body_frame(frame, self.metrics, &active_surface.kind), mount: self.mount_spec_for_active_surface(workspace_id, pane, active_surface), }) }) @@ -1132,6 +1172,21 @@ impl TaskersCore { ShellAction::FocusSurface { pane_id, surface_id } => { self.focus_surface_by_id(pane_id, surface_id) } + ShellAction::NavigateBrowser { surface_id, url } => { + self.navigate_browser_surface(surface_id, &url) + } + ShellAction::BrowserBack { surface_id } => { + self.queue_host_command(HostCommand::BrowserBack { surface_id }) + } + ShellAction::BrowserForward { surface_id } => { + self.queue_host_command(HostCommand::BrowserForward { surface_id }) + } + ShellAction::BrowserReload { surface_id } => { + self.queue_host_command(HostCommand::BrowserReload { surface_id }) + } + ShellAction::ToggleBrowserDevtools { surface_id } => { + self.queue_host_command(HostCommand::BrowserToggleDevtools { surface_id }) + } ShellAction::CloseSurface { pane_id, surface_id } => { self.close_surface_by_id(pane_id, surface_id) } @@ -1323,6 +1378,22 @@ impl TaskersCore { }) } + fn navigate_browser_surface(&mut self, surface_id: SurfaceId, raw_url: &str) -> bool { + let normalized = resolved_browser_uri(raw_url); + self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { + surface_id, + patch: PaneMetadataPatch { + url: Some(normalized), + ..PaneMetadataPatch::default() + }, + }) + } + + fn queue_host_command(&mut self, command: HostCommand) -> bool { + self.host_commands.push_back(command); + true + } + fn dismiss_activity(&mut self, activity_id: ActivityId) -> bool { self.dispatch_control(ControlCommand::MarkSurfaceCompleted { workspace_id: activity_id.workspace_id, @@ -1406,6 +1477,10 @@ impl TaskersCore { fn bump_local_revision(&mut self) { self.revision = self.revision.max(self.observed_app_revision).saturating_add(1); } + + fn drain_host_commands(&mut self) -> Vec { + self.host_commands.drain(..).collect() + } } #[derive(Clone)] @@ -1473,6 +1548,10 @@ impl SharedCore { } } + pub fn drain_host_commands(&self) -> Vec { + self.inner.lock().drain_host_commands() + } + pub fn split_with_browser(&self) { self.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); } @@ -1923,8 +2002,14 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio: u16, gap: i32) -> (Frame, F } } -fn pane_body_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { - frame.inset_top(metrics.pane_header_height + metrics.surface_tab_height) +fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Frame { + let browser_toolbar_height = match kind { + PaneKind::Terminal => 0, + PaneKind::Browser => 42, + }; + frame.inset_top( + metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height, + ) } fn workspace_preview(summary: &DomainWorkspaceSummary) -> String { @@ -2192,8 +2277,9 @@ mod tests { use taskers_control::ControlCommand; use super::{ - BootstrapModel, BrowserMountSpec, HostEvent, RuntimeCapability, RuntimeStatus, SharedCore, - ShellAction, ShellSection, SurfaceMountSpec, default_preview_app_state, + BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, RuntimeCapability, + RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, + default_preview_app_state, }; fn bootstrap() -> BootstrapModel { @@ -2296,6 +2382,35 @@ mod tests { assert_eq!(surface.url.as_deref(), Some("https://example.com/docs")); } + #[test] + fn browser_snapshot_and_host_commands_follow_active_browser_surface() { + let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); + + let snapshot = core.snapshot(); + let browser = snapshot.browser_chrome.expect("active browser chrome"); + assert!(!browser.url.trim().is_empty()); + + core.dispatch_shell_action(ShellAction::BrowserReload { + surface_id: browser.surface_id, + }); + core.dispatch_shell_action(ShellAction::BrowserBack { + surface_id: browser.surface_id, + }); + + assert_eq!( + core.drain_host_commands(), + vec![ + HostCommand::BrowserReload { + surface_id: browser.surface_id + }, + HostCommand::BrowserBack { + surface_id: browser.surface_id + }, + ] + ); + } + #[test] fn local_shell_state_revisions_advance_without_app_mutation() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 25642eb..cfbd89b 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -10,8 +10,8 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; use taskers_core::{ - BrowserMountSpec, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, SurfaceMountSpec, - SurfacePortalPlan, TerminalMountSpec, + BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, + SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, }; use taskers_domain::PaneKind; use taskers_ghostty::{GhosttyHost, SurfaceDescriptor}; @@ -155,6 +155,51 @@ impl TaskersHost { } } + pub fn handle_command(&mut self, command: HostCommand) -> Result<()> { + match command { + HostCommand::BrowserBack { surface_id } => { + self.with_browser_surface(surface_id, "browser back", |surface| surface.go_back()) + } + HostCommand::BrowserForward { surface_id } => self.with_browser_surface( + surface_id, + "browser forward", + |surface| surface.go_forward(), + ), + HostCommand::BrowserReload { surface_id } => self.with_browser_surface( + surface_id, + "browser reload", + |surface| surface.reload(), + ), + HostCommand::BrowserToggleDevtools { surface_id } => self.with_browser_surface( + surface_id, + "browser devtools toggle", + |surface| surface.toggle_devtools(), + ), + } + } + + fn with_browser_surface( + &mut self, + surface_id: SurfaceId, + action: &'static str, + callback: impl FnOnce(&mut BrowserSurface), + ) -> Result<()> { + let Some(surface) = self.browser_surfaces.get_mut(&surface_id) else { + return Ok(()); + }; + callback(surface); + emit_diagnostic( + self.diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + format!("{action} command handled"), + ) + .with_surface(surface_id), + ); + Ok(()) + } + fn sync_browser_surfaces( &mut self, portal: &SurfacePortalPlan, @@ -277,6 +322,7 @@ impl TaskersHost { struct BrowserSurface { webview: WebView, url: String, + devtools_open: bool, } impl BrowserSurface { @@ -403,7 +449,11 @@ impl BrowserSurface { .with_surface(plan.surface_id), ); - Ok(Self { webview, url }) + Ok(Self { + webview, + url, + devtools_open: false, + }) } fn sync( @@ -437,6 +487,42 @@ impl BrowserSurface { Ok(()) } + + fn go_back(&mut self) { + if self.webview.can_go_back() { + self.webview.go_back(); + } + self.webview.grab_focus(); + } + + fn go_forward(&mut self) { + if self.webview.can_go_forward() { + self.webview.go_forward(); + } + self.webview.grab_focus(); + } + + fn reload(&mut self) { + self.webview.reload(); + self.webview.grab_focus(); + } + + fn toggle_devtools(&mut self) { + let Some(inspector) = self.webview.inspector() else { + return; + }; + if self.devtools_open { + inspector.close(); + self.devtools_open = false; + } else { + if inspector.can_attach() { + inspector.attach(); + } + inspector.show(); + self.devtools_open = true; + } + self.webview.grab_focus(); + } } struct TerminalSurface { diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index a82e88d..c3dd51f 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -511,6 +511,13 @@ fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeSt {render_surface_tab(pane.id, pane.active_surface, surface, core.clone())} } } + if matches!(active_surface.kind, SurfaceKind::Browser) { + BrowserToolbar { + key: "{active_surface.id}", + surface: active_surface.clone(), + core: core.clone(), + } + } div { class: "pane-body", {render_surface_backdrop(active_surface, runtime_status)} } @@ -545,6 +552,65 @@ fn render_surface_tab( } } +#[component] +fn BrowserToolbar(surface: SurfaceSnapshot, core: SharedCore) -> Element { + let initial_url = surface.url.clone().unwrap_or_else(|| "about:blank".into()); + let mut address = use_signal(|| initial_url.clone()); + let surface_id = surface.id; + + let navigate = { + let core = core.clone(); + let address = address.clone(); + move |event: Event| { + event.prevent_default(); + let target = address.read().trim().to_string(); + if target.is_empty() { + return; + } + core.dispatch_shell_action(ShellAction::NavigateBrowser { + surface_id, + url: target, + }); + } + }; + let go_back = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::BrowserBack { surface_id }) + }; + let go_forward = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::BrowserForward { surface_id }) + }; + let reload = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::BrowserReload { surface_id }) + }; + let toggle_devtools = move |_| { + core.dispatch_shell_action(ShellAction::ToggleBrowserDevtools { surface_id }) + }; + + rsx! { + form { class: "browser-toolbar", onsubmit: navigate, + button { r#type: "button", class: "browser-toolbar-button", onclick: go_back, "←" } + button { r#type: "button", class: "browser-toolbar-button", onclick: go_forward, "→" } + button { r#type: "button", class: "browser-toolbar-button", onclick: reload, "↻" } + input { + class: "browser-address", + r#type: "text", + value: "{address}", + oninput: move |event| address.set(event.value()), + } + button { r#type: "submit", class: "browser-toolbar-button browser-toolbar-button-primary", "Go" } + button { + r#type: "button", + class: "browser-toolbar-button", + onclick: toggle_devtools, + "Devtools" + } + } + } +} + fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { let badge_class = format!("status-pill status-pill-inline status-pill-{}", surface.attention.slug()); match surface.kind { diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 3c23996..16e7f51 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -822,6 +822,55 @@ button {{ box-shadow: inset 0 0 0 1px {error_10}; }} +.browser-toolbar {{ + min-height: 42px; + border-bottom: 1px solid {border_06}; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: {overlay_05}; +}} + +.browser-toolbar-button {{ + min-width: 34px; + height: 28px; + border-radius: 8px; + border: 1px solid {border_10}; + background: {overlay_05}; + color: {text_subtle}; + font-size: 11px; + font-weight: 600; +}} + +.browser-toolbar-button:hover {{ + background: {overlay_16}; + color: {text_bright}; +}} + +.browser-toolbar-button-primary {{ + border-color: {accent_24}; + color: {text_bright}; +}} + +.browser-address {{ + flex: 1; + min-width: 0; + height: 28px; + border-radius: 8px; + border: 1px solid {border_10}; + padding: 0 10px; + background: {overlay_05}; + color: {text_bright}; + font-size: 12px; +}} + +.browser-address:focus {{ + outline: none; + border-color: {accent_24}; + box-shadow: 0 0 0 1px {accent_20}; +}} + .pane-body {{ flex: 1; min-height: 0; diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 6450be0..b1f9807 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -586,6 +586,20 @@ fn sync_window( ) { core.sync_external_changes(); + for command in core.drain_host_commands() { + if let Err(error) = host.borrow_mut().handle_command(command) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + Some(core.revision()), + format!("host command failed: {error}"), + ), + ); + eprintln!("taskers host command failed: {error}"); + } + } + let size = PixelSize::new(window.width().max(1), window.height().max(1)); if last_size.get() != (size.width, size.height) { core.set_window_size(size); From 264671ba3c05aff7ba141466ecd6bafb9f2412a4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 16:05:21 +0100 Subject: [PATCH 26/63] feat: add niri-style viewport scrolling --- greenfield/crates/taskers-shell/src/lib.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index c3dd51f..a48b528 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -352,6 +352,23 @@ fn render_workspace_strip( core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { + let scroll_viewport = { + let core = core.clone(); + let overview_scale = workspace.overview_scale; + move |event: Event| { + if overview_scale < 1.0 { + return; + } + event.prevent_default(); + let delta = event.delta().strip_units(); + let dx = delta.x.round() as i32; + let dy = delta.y.round() as i32; + if dx == 0 && dy == 0 { + return; + } + core.dispatch_shell_action(ShellAction::ScrollViewport { dx, dy }); + } + }; let translate_x = if workspace.overview_scale < 1.0 { 0 } else { @@ -368,7 +385,7 @@ fn render_workspace_strip( ); rsx! { - div { class: "workspace-viewport", + div { class: "workspace-viewport", onwheel: scroll_viewport, div { class: "workspace-strip-canvas", style: "{canvas_style}", for column in &workspace.columns { for window in &column.windows { From 3284fada955576e37b3805aefb82fc63e0f9c9ed Mon Sep 17 00:00:00 2001 From: OneNoted Date: Thu, 19 Mar 2026 16:06:59 +0100 Subject: [PATCH 27/63] fix: restore shared shell hit-testing in linux host --- greenfield/crates/taskers-host/src/lib.rs | 6 ++++++ greenfield/crates/taskers/src/main.rs | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index cfbd89b..a82498c 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -113,6 +113,10 @@ impl TaskersHost { let surface_layer = Fixed::new(); surface_layer.set_hexpand(true); surface_layer.set_vexpand(true); + // The surface layer spans the full window, but only mounted native pane + // bodies should intercept pointer events. Leaving the layer targetable + // blocks the shared shell webview underneath. + surface_layer.set_can_target(false); root.add_overlay(&surface_layer); emit_diagnostic( @@ -345,6 +349,7 @@ impl BrowserSurface { .focusable(true) .settings(&settings) .build(); + webview.set_can_target(true); webview.load_uri(&url); (event_sink)(HostEvent::SurfaceUrlChanged { surface_id: plan.surface_id, @@ -546,6 +551,7 @@ impl TerminalSurface { widget.set_hexpand(true); widget.set_vexpand(true); widget.set_focusable(true); + widget.set_can_target(true); position_widget(fixed, &widget, plan.frame); connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index b1f9807..324027f 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -149,6 +149,7 @@ fn build_ui_result( .focusable(true) .settings(&settings) .build(); + shell_view.set_can_target(true); shell_view.load_uri(&shell_url); let core = bootstrap.core.clone(); @@ -184,6 +185,15 @@ fn build_ui_result( ); eprintln!("{note}"); } + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Startup, + Some(core.revision()), + format!("shared shell listening on {shell_url}"), + ), + ); + eprintln!("shared shell listening on {shell_url}"); let smoke_script = cli.smoke_script; let quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); @@ -414,6 +424,7 @@ fn run_internal_surface_probe( .focusable(true) .settings(&settings) .build(); + shell_view.set_can_target(true); shell_view.load_html( "", Some("http://127.0.0.1/"), From 992232c53edb6600922cb177e7cad4dc9ce1f488 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 10:53:17 +0100 Subject: [PATCH 28/63] feat: add browser navigation state parity --- greenfield/crates/taskers-core/src/lib.rs | 79 ++++++++++++++ greenfield/crates/taskers-host/src/lib.rs | 117 +++++++++++++++++++-- greenfield/crates/taskers-shell/src/lib.rs | 105 +++++++++++++++--- 3 files changed, 276 insertions(+), 25 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index a4124ab..2ffec60 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -467,6 +467,9 @@ pub struct BrowserChromeSnapshot { pub surface_id: SurfaceId, pub title: String, pub url: String, + pub can_go_back: bool, + pub can_go_forward: bool, + pub devtools_open: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -572,6 +575,12 @@ pub enum HostEvent { SurfaceTitleChanged { surface_id: SurfaceId, title: String }, SurfaceUrlChanged { surface_id: SurfaceId, url: String }, SurfaceCwdChanged { surface_id: SurfaceId, cwd: String }, + BrowserNavigationStateChanged { + surface_id: SurfaceId, + can_go_back: bool, + can_go_forward: bool, + devtools_open: bool, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -617,6 +626,13 @@ struct UiState { window_size: PixelSize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct BrowserNavigationState { + can_go_back: bool, + can_go_forward: bool, + devtools_open: bool, +} + #[derive(Clone, Copy)] struct WorkspaceWindowPlacement { window_id: WorkspaceWindowId, @@ -649,6 +665,7 @@ struct TaskersCore { runtime_status: RuntimeStatus, ui: UiState, host_commands: VecDeque, + browser_navigation: BTreeMap, } impl TaskersCore { @@ -669,6 +686,7 @@ impl TaskersCore { window_size: PixelSize::new(1440, 900), }, host_commands: VecDeque::new(), + browser_navigation: BTreeMap::new(), } } @@ -1017,6 +1035,21 @@ impl TaskersCore { surface_id: surface.id, title: display_surface_title(surface), url: normalized_surface_url(surface).unwrap_or_else(|| "about:blank".into()), + can_go_back: self + .browser_navigation + .get(&surface.id) + .map(|state| state.can_go_back) + .unwrap_or(false), + can_go_forward: self + .browser_navigation + .get(&surface.id) + .map(|state| state.can_go_forward) + .unwrap_or(false), + devtools_open: self + .browser_navigation + .get(&surface.id) + .map(|state| state.devtools_open) + .unwrap_or(false), }) } @@ -1112,6 +1145,7 @@ impl TaskersCore { match event { HostEvent::PaneFocused { pane_id } => self.focus_pane_by_id(pane_id), HostEvent::SurfaceClosed { pane_id, surface_id } => { + self.browser_navigation.remove(&surface_id); self.close_surface_by_id(pane_id, surface_id) } HostEvent::SurfaceTitleChanged { surface_id, title } => self.update_surface_metadata( @@ -1135,6 +1169,24 @@ impl TaskersCore { ..PaneMetadataPatch::default() }, ), + HostEvent::BrowserNavigationStateChanged { + surface_id, + can_go_back, + can_go_forward, + devtools_open, + } => { + let next = BrowserNavigationState { + can_go_back, + can_go_forward, + devtools_open, + }; + if self.browser_navigation.get(&surface_id) == Some(&next) { + return false; + } + self.browser_navigation.insert(surface_id, next); + self.bump_local_revision(); + true + } } } @@ -2411,6 +2463,33 @@ mod tests { ); } + #[test] + fn browser_navigation_host_events_update_browser_chrome_snapshot() { + let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); + + let snapshot = core.snapshot(); + let browser = snapshot.browser_chrome.expect("active browser chrome"); + assert!(!browser.can_go_back); + assert!(!browser.can_go_forward); + assert!(!browser.devtools_open); + + core.apply_host_event(HostEvent::BrowserNavigationStateChanged { + surface_id: browser.surface_id, + can_go_back: true, + can_go_forward: true, + devtools_open: true, + }); + + let browser = core + .snapshot() + .browser_chrome + .expect("active browser chrome after host event"); + assert!(browser.can_go_back); + assert!(browser.can_go_forward); + assert!(browser.devtools_open); + } + #[test] fn local_shell_state_revisions_advance_without_app_mutation() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index a82498c..440b48e 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -4,6 +4,7 @@ use gtk::{ prelude::*, }; use std::{ + cell::Cell, collections::{HashMap, HashSet}, rc::Rc, sync::Arc, @@ -324,9 +325,12 @@ impl TaskersHost { } struct BrowserSurface { + surface_id: SurfaceId, webview: WebView, url: String, - devtools_open: bool, + devtools_open: Rc>, + event_sink: HostEventSink, + diagnostics: Option, } impl BrowserSurface { @@ -356,6 +360,7 @@ impl BrowserSurface { url: url.clone(), }); position_widget(fixed, webview.upcast_ref(), plan.frame); + let devtools_open = Rc::new(Cell::new(false)); let pane_id = plan.pane_id; let surface_id = plan.surface_id; @@ -419,8 +424,26 @@ impl BrowserSurface { }); let surface_id = plan.surface_id; + let navigation_sink = event_sink.clone(); + let navigation_diagnostics = diagnostics.clone(); + let navigation_devtools = devtools_open.clone(); + webview.connect_load_changed(move |web_view, _| { + emit_browser_navigation_state( + web_view, + surface_id, + navigation_devtools.get(), + &navigation_sink, + navigation_diagnostics.as_ref(), + ); + }); + + let url_surface_id = plan.surface_id; let url_sink = event_sink; + let uri_sink = url_sink.clone(); let url_diagnostics = diagnostics.clone(); + let navigation_sink = url_sink.clone(); + let navigation_diagnostics = diagnostics.clone(); + let navigation_devtools = devtools_open.clone(); webview.connect_uri_notify(move |web_view| { if let Some(url) = web_view.uri() { emit_diagnostic( @@ -430,15 +453,40 @@ impl BrowserSurface { None, format!("browser url observed: {url}"), ) - .with_surface(surface_id), + .with_surface(url_surface_id), ); - (url_sink)(HostEvent::SurfaceUrlChanged { - surface_id, + (uri_sink)(HostEvent::SurfaceUrlChanged { + surface_id: url_surface_id, url: url.to_string(), }); } + emit_browser_navigation_state( + web_view, + url_surface_id, + navigation_devtools.get(), + &navigation_sink, + navigation_diagnostics.as_ref(), + ); }); + if let Some(inspector) = webview.inspector() { + let navigation_webview = webview.clone(); + let navigation_sink = url_sink.clone(); + let navigation_diagnostics = diagnostics.clone(); + let navigation_devtools = devtools_open.clone(); + let inspector_surface_id = plan.surface_id; + inspector.connect_closed(move |_| { + navigation_devtools.set(false); + emit_browser_navigation_state( + &navigation_webview, + inspector_surface_id, + false, + &navigation_sink, + navigation_diagnostics.as_ref(), + ); + }); + } + if plan.active { webview.grab_focus(); } @@ -454,10 +502,21 @@ impl BrowserSurface { .with_surface(plan.surface_id), ); + emit_browser_navigation_state( + &webview, + plan.surface_id, + devtools_open.get(), + &url_sink, + diagnostics.as_ref(), + ); + Ok(Self { + surface_id: plan.surface_id, webview, url, - devtools_open: false, + devtools_open, + event_sink: url_sink, + diagnostics, }) } @@ -498,6 +557,7 @@ impl BrowserSurface { self.webview.go_back(); } self.webview.grab_focus(); + self.emit_navigation_state(); } fn go_forward(&mut self) { @@ -505,28 +565,40 @@ impl BrowserSurface { self.webview.go_forward(); } self.webview.grab_focus(); + self.emit_navigation_state(); } fn reload(&mut self) { self.webview.reload(); self.webview.grab_focus(); + self.emit_navigation_state(); } fn toggle_devtools(&mut self) { let Some(inspector) = self.webview.inspector() else { return; }; - if self.devtools_open { + if self.devtools_open.get() { inspector.close(); - self.devtools_open = false; } else { if inspector.can_attach() { inspector.attach(); } inspector.show(); - self.devtools_open = true; + self.devtools_open.set(true); } self.webview.grab_focus(); + self.emit_navigation_state(); + } + + fn emit_navigation_state(&self) { + emit_browser_navigation_state( + &self.webview, + self.surface_id, + self.devtools_open.get(), + &self.event_sink, + self.diagnostics.as_ref(), + ); } } @@ -714,6 +786,35 @@ fn connect_ghostty_widget( }); } +fn emit_browser_navigation_state( + webview: &WebView, + surface_id: SurfaceId, + devtools_open: bool, + event_sink: &HostEventSink, + diagnostics: Option<&DiagnosticsSink>, +) { + emit_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::BrowserMetadata, + None, + format!( + "browser navigation state updated back={} forward={} devtools={}", + webview.can_go_back(), + webview.can_go_forward(), + devtools_open + ), + ) + .with_surface(surface_id), + ); + (event_sink)(HostEvent::BrowserNavigationStateChanged { + surface_id, + can_go_back: webview.can_go_back(), + can_go_forward: webview.can_go_forward(), + devtools_open, + }); +} + fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { SurfaceDescriptor { cols: spec.cols, diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index a48b528..295b361 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -2,10 +2,11 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ - ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, LayoutNodeSnapshot, PaneSnapshot, - RuntimeCapability, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, - ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, - WorkspaceDirection, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, + ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, + LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, RuntimeStatus, SettingsSnapshot, + SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, + SurfaceKind, SurfaceSnapshot, WorkspaceDirection, WorkspaceSummary, WorkspaceViewSnapshot, + WorkspaceWindowSnapshot, }; fn app_css(snapshot: &ShellSnapshot) -> String { @@ -198,7 +199,12 @@ pub fn TaskersShell(core: SharedCore) -> Element { if matches!(snapshot.section, ShellSection::Workspace) { div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, - {render_workspace_strip(&snapshot.current_workspace, core.clone(), &snapshot.runtime_status)} + {render_workspace_strip( + &snapshot.current_workspace, + snapshot.browser_chrome.as_ref(), + core.clone(), + &snapshot.runtime_status, + )} } } else { div { class: "settings-canvas", @@ -313,6 +319,7 @@ fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability fn render_layout( node: &LayoutNodeSnapshot, + browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { @@ -335,20 +342,21 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, core.clone(), runtime_status)} + {render_layout(first, browser_chrome, core.clone(), runtime_status)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, core.clone(), runtime_status)} + {render_layout(second, browser_chrome, core.clone(), runtime_status)} } } } } - LayoutNodeSnapshot::Pane(pane) => render_pane(pane, core, runtime_status), + LayoutNodeSnapshot::Pane(pane) => render_pane(pane, browser_chrome, core, runtime_status), } } fn render_workspace_strip( workspace: &WorkspaceViewSnapshot, + browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { @@ -389,7 +397,13 @@ fn render_workspace_strip( div { class: "workspace-strip-canvas", style: "{canvas_style}", for column in &workspace.columns { for window in &column.windows { - {render_workspace_window(window, workspace, core.clone(), runtime_status)} + {render_workspace_window( + window, + workspace, + browser_chrome, + core.clone(), + runtime_status, + )} } } } @@ -400,6 +414,7 @@ fn render_workspace_strip( fn render_workspace_window( window: &WorkspaceWindowSnapshot, workspace: &WorkspaceViewSnapshot, + browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { @@ -438,13 +453,18 @@ fn render_workspace_window( } } div { class: "workspace-window-body", - {render_layout(&window.layout, core.clone(), runtime_status)} + {render_layout(&window.layout, browser_chrome, core.clone(), runtime_status)} } } } } -fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeStatus) -> Element { +fn render_pane( + pane: &PaneSnapshot, + browser_chrome: Option<&BrowserChromeSnapshot>, + core: SharedCore, + runtime_status: &RuntimeStatus, +) -> Element { let pane_class = if pane.active { format!("pane-card pane-card-active pane-card-state-{}", pane.attention.slug()) } else { @@ -468,6 +488,19 @@ fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeSt let status_class = format!("status-dot status-dot-{}", active_surface.attention.slug()); let pane_id = pane.id; let active_surface_id = active_surface.id; + let active_browser_chrome = browser_chrome + .filter(|chrome| chrome.surface_id == active_surface.id) + .cloned(); + let toolbar_key = active_browser_chrome + .as_ref() + .map(|chrome| format!("{}-{}", active_surface.id, chrome.url)) + .or_else(|| { + active_surface + .url + .as_ref() + .map(|url| format!("{}-{}", active_surface.id, url)) + }) + .unwrap_or_else(|| active_surface.id.to_string()); let focus_pane = { let core = core.clone(); @@ -530,8 +563,9 @@ fn render_pane(pane: &PaneSnapshot, core: SharedCore, runtime_status: &RuntimeSt } if matches!(active_surface.kind, SurfaceKind::Browser) { BrowserToolbar { - key: "{active_surface.id}", + key: "{toolbar_key}", surface: active_surface.clone(), + chrome: active_browser_chrome, core: core.clone(), } } @@ -570,10 +604,35 @@ fn render_surface_tab( } #[component] -fn BrowserToolbar(surface: SurfaceSnapshot, core: SharedCore) -> Element { - let initial_url = surface.url.clone().unwrap_or_else(|| "about:blank".into()); +fn BrowserToolbar( + surface: SurfaceSnapshot, + chrome: Option, + core: SharedCore, +) -> Element { + let initial_url = chrome + .as_ref() + .map(|chrome| chrome.url.clone()) + .or_else(|| surface.url.clone()) + .unwrap_or_else(|| "about:blank".into()); let mut address = use_signal(|| initial_url.clone()); let surface_id = surface.id; + let can_go_back = chrome + .as_ref() + .map(|chrome| chrome.can_go_back) + .unwrap_or(false); + let can_go_forward = chrome + .as_ref() + .map(|chrome| chrome.can_go_forward) + .unwrap_or(false); + let devtools_open = chrome + .as_ref() + .map(|chrome| chrome.devtools_open) + .unwrap_or(false); + let devtools_label = if devtools_open { + "Hide tools" + } else { + "Devtools" + }; let navigate = { let core = core.clone(); @@ -608,8 +667,20 @@ fn BrowserToolbar(surface: SurfaceSnapshot, core: SharedCore) -> Element { rsx! { form { class: "browser-toolbar", onsubmit: navigate, - button { r#type: "button", class: "browser-toolbar-button", onclick: go_back, "←" } - button { r#type: "button", class: "browser-toolbar-button", onclick: go_forward, "→" } + button { + r#type: "button", + class: "browser-toolbar-button", + disabled: !can_go_back, + onclick: go_back, + "←" + } + button { + r#type: "button", + class: "browser-toolbar-button", + disabled: !can_go_forward, + onclick: go_forward, + "→" + } button { r#type: "button", class: "browser-toolbar-button", onclick: reload, "↻" } input { class: "browser-address", @@ -622,7 +693,7 @@ fn BrowserToolbar(surface: SurfaceSnapshot, core: SharedCore) -> Element { r#type: "button", class: "browser-toolbar-button", onclick: toggle_devtools, - "Devtools" + "{devtools_label}" } } } From 3d9afc394699c4cc38a447637186ca9c2fb683dd Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:10:24 +0100 Subject: [PATCH 29/63] feat: separate agent titles from surface metadata --- crates/taskers-cli/src/main.rs | 8 +++-- crates/taskers-domain/src/model.rs | 36 +++++++++++++++++++---- crates/taskers-domain/src/signal.rs | 2 ++ crates/taskers-runtime/src/signals.rs | 1 + greenfield/crates/taskers-core/src/lib.rs | 10 +++++++ 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 9ddc5e4..17d40bb 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -558,6 +558,7 @@ async fn main() -> anyhow::Result<()> { "surface_id": item.surface_id, "kind": format!("{:?}", item.kind).to_lowercase(), "state": format!("{:?}", item.state).to_lowercase(), + "title": item.title, "message": item.message, "created_at": item.created_at, }) @@ -603,6 +604,7 @@ async fn main() -> anyhow::Result<()> { { Some(taskers_domain::SignalPaneMetadata { title, + agent_title: None, cwd, repo_name: repo, git_branch: branch, @@ -655,7 +657,8 @@ async fn main() -> anyhow::Result<()> { let message = normalized_body.unwrap_or_else(|| normalized_title.to_string()); let inferred_agent = agent.or_else(|| infer_agent_kind(normalized_title)); let metadata = Some(taskers_domain::SignalPaneMetadata { - title: Some(normalized_title.to_string()), + title: None, + agent_title: Some(normalized_title.to_string()), cwd: None, repo_name: None, git_branch: None, @@ -1354,7 +1357,8 @@ async fn emit_agent_hook( .unwrap_or_else(|| "shell".into()); let normalized_title = title.unwrap_or_else(|| normalized_agent.clone()); let metadata = Some(taskers_domain::SignalPaneMetadata { - title: Some(normalized_title.clone()), + title: None, + agent_title: Some(normalized_title.clone()), cwd: None, repo_name: None, git_branch: None, diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index f54de5e..ff7e7fa 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -66,6 +66,8 @@ pub enum PaneKind { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneMetadata { pub title: Option, + #[serde(default)] + pub agent_title: Option, pub cwd: Option, pub url: Option, pub repo_name: Option, @@ -233,6 +235,8 @@ pub struct NotificationItem { #[serde(default = "default_notification_kind")] pub kind: SignalKind, pub state: AttentionState, + #[serde(default)] + pub title: Option, pub message: String, pub created_at: OffsetDateTime, pub cleared_at: Option, @@ -246,6 +250,7 @@ pub struct ActivityItem { pub surface_id: SurfaceId, pub kind: SignalKind, pub state: AttentionState, + pub title: Option, pub message: String, pub created_at: OffsetDateTime, } @@ -810,8 +815,9 @@ impl Workspace { agent_kind, title: surface .metadata - .title + .agent_title .as_deref() + .or(surface.metadata.title.as_deref()) .map(str::trim) .filter(|title| !title.is_empty()) .map(str::to_owned), @@ -1522,6 +1528,15 @@ impl AppModel { surface_id, })?; + let notification_title = event + .metadata + .as_ref() + .and_then(|metadata| { + metadata + .agent_title + .clone() + .or_else(|| metadata.title.clone()) + }); let metadata_reported_inactive = event .metadata .as_ref() @@ -1531,6 +1546,9 @@ impl AppModel { let mut acknowledged_inactive_resolution = false; if let Some(metadata) = event.metadata { surface.metadata.title = metadata.title; + if metadata.agent_title.is_some() { + surface.metadata.agent_title = metadata.agent_title; + } surface.metadata.cwd = metadata.cwd; surface.metadata.repo_name = metadata.repo_name; surface.metadata.git_branch = metadata.git_branch; @@ -1572,6 +1590,7 @@ impl AppModel { surface_id, kind: event.kind, state: surface_attention, + title: notification_title, message, created_at: event.timestamp, cleared_at: None, @@ -1887,6 +1906,7 @@ impl AppModel { surface_id: notification.surface_id, kind: notification.kind.clone(), state: notification.state, + title: notification.title.clone(), message: notification.message.clone(), created_at: notification.created_at, }) @@ -2612,7 +2632,8 @@ mod tests { kind: SignalKind::Completed, message: Some("Done".into()), metadata: Some(SignalPaneMetadata { - title: Some("Codex".into()), + title: None, + agent_title: Some("Codex".into()), cwd: None, repo_name: None, git_branch: None, @@ -2635,6 +2656,7 @@ mod tests { None, Some(SignalPaneMetadata { title: Some("codex :: taskers".into()), + agent_title: None, cwd: Some("/tmp".into()), repo_name: Some("taskers".into()), git_branch: Some("main".into()), @@ -2685,7 +2707,8 @@ mod tests { SignalKind::WaitingInput, Some("Need review".into()), Some(SignalPaneMetadata { - title: Some("Codex".into()), + title: None, + agent_title: Some("Codex".into()), cwd: None, repo_name: None, git_branch: None, @@ -2738,7 +2761,8 @@ mod tests { SignalKind::WaitingInput, Some("Need input".into()), Some(SignalPaneMetadata { - title: Some("Codex".into()), + title: None, + agent_title: Some("Codex".into()), cwd: None, repo_name: None, git_branch: None, @@ -2760,6 +2784,7 @@ mod tests { None, Some(SignalPaneMetadata { title: Some("codex :: taskers".into()), + agent_title: None, cwd: Some("/tmp".into()), repo_name: Some("taskers".into()), git_branch: Some("main".into()), @@ -2819,7 +2844,8 @@ mod tests { SignalKind::WaitingInput, Some("Need review".into()), Some(SignalPaneMetadata { - title: Some("Codex".into()), + title: None, + agent_title: Some("Codex".into()), cwd: None, repo_name: None, git_branch: None, diff --git a/crates/taskers-domain/src/signal.rs b/crates/taskers-domain/src/signal.rs index d8b3cb4..7b90b43 100644 --- a/crates/taskers-domain/src/signal.rs +++ b/crates/taskers-domain/src/signal.rs @@ -4,6 +4,8 @@ use time::OffsetDateTime; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SignalPaneMetadata { pub title: Option, + #[serde(default)] + pub agent_title: Option, pub cwd: Option, pub repo_name: Option, pub git_branch: Option, diff --git a/crates/taskers-runtime/src/signals.rs b/crates/taskers-runtime/src/signals.rs index 1b706a5..14f3d22 100644 --- a/crates/taskers-runtime/src/signals.rs +++ b/crates/taskers-runtime/src/signals.rs @@ -120,6 +120,7 @@ fn parse_frame(frame: &str) -> Option { { Some(SignalPaneMetadata { title, + agent_title: None, cwd, repo_name, git_branch, diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 2ffec60..0185b56 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -900,6 +900,7 @@ impl TaskersCore { surface_id: notification.surface_id, kind: notification.kind.clone(), state: notification.state, + title: notification.title.clone(), message: notification.message.clone(), created_at: notification.created_at, }) @@ -2181,6 +2182,15 @@ fn compact_preview(message: &str) -> String { } fn activity_title(model: &AppModel, item: &ActivityItem) -> String { + if let Some(title) = item + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + return title.to_string(); + } + model.workspaces .get(&item.workspace_id) .and_then(|workspace| workspace.panes.get(&item.pane_id)) From 2c17a2d34745faf8edf1a7ec35270ee2d92c0ebc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:19:20 +0100 Subject: [PATCH 30/63] feat: enrich workspace summary snapshots with domain metadata --- greenfield/Cargo.lock | 1 + greenfield/Cargo.toml | 1 + greenfield/crates/taskers-core/Cargo.toml | 1 + greenfield/crates/taskers-core/src/lib.rs | 117 +++++++++++++++++----- 4 files changed, 94 insertions(+), 26 deletions(-) diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock index 740479c..de94552 100644 --- a/greenfield/Cargo.lock +++ b/greenfield/Cargo.lock @@ -2428,6 +2428,7 @@ dependencies = [ "taskers-domain", "taskers-ghostty", "taskers-runtime", + "time", "tokio", ] diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml index d629040..757d5de 100644 --- a/greenfield/Cargo.toml +++ b/greenfield/Cargo.toml @@ -23,6 +23,7 @@ dioxus-liveview = { version = "0.7.3", features = ["axum"] } gtk = { package = "gtk4", version = "0.11.0" } indexmap = "2" parking_lot = "0.12" +time = { version = "0.3", features = ["formatting", "macros"] } tokio = { version = "1.50.0", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } webkit6 = { version = "0.6.1", features = ["v2_50"] } taskers-app-core = { package = "taskers-core", path = "../crates/taskers-core" } diff --git a/greenfield/crates/taskers-core/Cargo.toml b/greenfield/crates/taskers-core/Cargo.toml index bffaf3a..36b27bc 100644 --- a/greenfield/crates/taskers-core/Cargo.toml +++ b/greenfield/crates/taskers-core/Cargo.toml @@ -12,4 +12,5 @@ taskers-control.workspace = true taskers-domain.workspace = true taskers-ghostty.workspace = true taskers-runtime.workspace = true +time.workspace = true tokio.workspace = true diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 0185b56..e49da1a 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -5,6 +5,7 @@ use std::{ path::PathBuf, sync::Arc, }; +use time::OffsetDateTime; use taskers_app_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ @@ -402,6 +403,10 @@ pub struct WorkspaceSummary { pub waiting_agent_count: usize, pub unread_activity: usize, pub attention: AttentionState, + pub notification_text: Option, + pub git_branch: Option, + pub working_directory: Option, + pub listening_ports: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -459,6 +464,9 @@ pub struct ActivityItemSnapshot { pub pane_id: Option, pub surface_id: Option, pub unread: bool, + pub timestamp: String, + pub body: Option, + pub source_workspace_title: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -825,31 +833,50 @@ impl TaskersCore { .workspace_summaries(active_window) .unwrap_or_default() .into_iter() - .map(|summary| WorkspaceSummary { - id: summary.workspace_id, - title: summary.label.clone(), - preview: workspace_preview(&summary), - active: model.active_workspace_id() == Some(summary.workspace_id), - pane_count: model - .workspaces - .get(&summary.workspace_id) - .map(|workspace| workspace.panes.len()) - .unwrap_or_default(), - surface_count: model - .workspaces - .get(&summary.workspace_id) - .map(workspace_surface_count) - .unwrap_or_default(), - agent_count: summary.agent_summaries.len(), - waiting_agent_count: summary - .agent_summaries - .iter() - .filter(|agent| { - matches!(agent.state, taskers_domain::WorkspaceAgentState::Waiting) - }) - .count(), - unread_activity: summary.unread_count, - attention: summary.display_attention.into(), + .map(|summary| { + let workspace = model.workspaces.get(&summary.workspace_id); + let active_pane_surface = workspace + .and_then(|ws| ws.panes.get(&summary.active_pane)) + .and_then(|pane| pane.active_surface()); + let git_branch = active_pane_surface + .and_then(|surface| surface.metadata.git_branch.clone()); + let working_directory = active_pane_surface + .and_then(|surface| surface.metadata.cwd.clone()); + let mut listening_ports: Vec = workspace + .into_iter() + .flat_map(|ws| ws.panes.values()) + .flat_map(|pane| pane.surfaces.values()) + .flat_map(|surface| surface.metadata.ports.iter().copied()) + .collect(); + listening_ports.sort_unstable(); + listening_ports.dedup(); + + WorkspaceSummary { + id: summary.workspace_id, + title: summary.label.clone(), + preview: workspace_preview(&summary), + active: model.active_workspace_id() == Some(summary.workspace_id), + pane_count: workspace + .map(|ws| ws.panes.len()) + .unwrap_or_default(), + surface_count: workspace + .map(workspace_surface_count) + .unwrap_or_default(), + agent_count: summary.agent_summaries.len(), + waiting_agent_count: summary + .agent_summaries + .iter() + .filter(|agent| { + matches!(agent.state, taskers_domain::WorkspaceAgentState::Waiting) + }) + .count(), + unread_activity: summary.unread_count, + attention: summary.display_attention.into(), + notification_text: summary.latest_notification, + git_branch, + working_directory, + listening_ports, + } }) .collect() } @@ -2173,6 +2200,31 @@ fn normalized_cwd(metadata: &PaneMetadata) -> Option { .map(str::to_string) } +fn format_relative_time(timestamp: OffsetDateTime) -> String { + let now = OffsetDateTime::now_utc(); + let delta = now - timestamp; + let seconds = delta.whole_seconds(); + if seconds < 0 { + return "just now".into(); + } + if seconds < 60 { + return "just now".into(); + } + let minutes = delta.whole_minutes(); + if minutes < 60 { + return format!("{minutes}m ago"); + } + let hours = delta.whole_hours(); + if hours < 24 { + return format!("{hours}h ago"); + } + let days = delta.whole_days(); + if days < 7 { + return format!("{days}d ago"); + } + format!("{days}d ago") +} + fn compact_preview(message: &str) -> String { let trimmed = message.split_whitespace().collect::>().join(" "); if trimmed.len() <= 140 { @@ -2208,13 +2260,23 @@ fn activity_item_snapshot( item: &ActivityItem, unread: bool, ) -> ActivityItemSnapshot { + let title = activity_title(model, item); + let body = if item.message.trim() != title.trim() && !item.message.is_empty() { + Some(item.message.clone()) + } else { + None + }; + let source_workspace_title = model + .workspaces + .get(&item.workspace_id) + .map(|workspace| workspace.label.clone()); ActivityItemSnapshot { id: ActivityId { workspace_id: item.workspace_id, pane_id: item.pane_id, surface_id: item.surface_id, }, - title: activity_title(model, item), + title, preview: compact_preview(&item.message), meta: activity_context_line(model, item), attention: item.state.into(), @@ -2222,6 +2284,9 @@ fn activity_item_snapshot( pane_id: Some(item.pane_id), surface_id: Some(item.surface_id), unread, + timestamp: format_relative_time(item.created_at), + body, + source_workspace_title, } } From 7632d61f5ab9948c78e94239a8f48fb30c9c91c0 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:42:24 +0100 Subject: [PATCH 31/63] feat: rewrite sidebar workspace tabs to CMUX design language --- greenfield/crates/taskers-core/src/lib.rs | 4 + greenfield/crates/taskers-shell/src/lib.rs | 92 ++++++--- greenfield/crates/taskers-shell/src/theme.rs | 207 ++++++++++++++----- 3 files changed, 227 insertions(+), 76 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index e49da1a..61fbb1a 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -604,6 +604,7 @@ pub enum ShellAction { ShowSection { section: ShellSection }, ToggleOverview, FocusWorkspace { workspace_id: WorkspaceId }, + CloseWorkspace { workspace_id: WorkspaceId }, CreateWorkspace, CreateWorkspaceWindow { direction: WorkspaceDirection }, FocusWorkspaceWindow { window_id: WorkspaceWindowId }, @@ -1234,6 +1235,9 @@ impl TaskersCore { true } ShellAction::FocusWorkspace { workspace_id } => self.focus_workspace(workspace_id), + ShellAction::CloseWorkspace { workspace_id } => { + self.dispatch_control(ControlCommand::CloseWorkspace { workspace_id }) + } ShellAction::CreateWorkspace => self.create_workspace(), ShellAction::CreateWorkspaceWindow { direction } => { self.create_workspace_window(direction) diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 295b361..a493c82 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -253,44 +253,90 @@ pub fn TaskersShell(core: SharedCore) -> Element { } fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Element { - let attention_class = format!("workspace-item-state-{}", workspace.attention.slug()); - let item_class = if workspace.active { - format!("workspace-item workspace-item-active {attention_class}") - } else { - format!("workspace-item {attention_class}") - }; - let badge_class = if workspace.attention == AttentionState::Normal { - "workspace-status-badge".to_string() - } else { + let tab_class = if workspace.active { format!( - "workspace-status-badge workspace-status-badge-state-{}", + "workspace-tab workspace-tab-active workspace-tab-state-{}", workspace.attention.slug() ) + } else { + format!("workspace-tab workspace-tab-state-{}", workspace.attention.slug()) }; + let has_badge = workspace.unread_activity > 0 || workspace.waiting_agent_count > 0; let badge_text = if workspace.unread_activity > 0 { workspace.unread_activity.to_string() - } else if workspace.waiting_agent_count > 0 { + } else { workspace.waiting_agent_count.to_string() + }; + let badge_state_class = if workspace.attention == AttentionState::Normal { + "workspace-unread-badge" } else { - workspace.attention.label().to_string() + match workspace.attention { + AttentionState::Error => "workspace-unread-badge workspace-unread-badge-error", + AttentionState::WaitingInput => "workspace-unread-badge workspace-unread-badge-waiting", + AttentionState::Completed => "workspace-unread-badge workspace-unread-badge-completed", + _ => "workspace-unread-badge", + } }; let workspace_id = workspace.id; - let focus_workspace = move |_| { - core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + let focus_workspace = { + let core = core.clone(); + move |_| { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + } + }; + let close_workspace = move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::CloseWorkspace { workspace_id }); + }; + + let branch_row = match (&workspace.git_branch, &workspace.working_directory) { + (Some(branch), Some(dir)) => Some(format!("{branch} · {dir}")), + (Some(branch), None) => Some(branch.clone()), + (None, Some(dir)) => Some(dir.clone()), + (None, None) => None, + }; + let ports_row = if workspace.listening_ports.is_empty() { + None + } else { + Some( + workspace + .listening_ports + .iter() + .map(|port| format!(":{port}")) + .collect::>() + .join(", "), + ) }; rsx! { button { class: "workspace-button", onclick: focus_workspace, - div { class: "{item_class}", - div { - div { class: "workspace-label", "{workspace.title}" } - div { class: "workspace-preview", "{workspace.preview}" } - div { class: "workspace-meta", - "{workspace.pane_count} panes · {workspace.surface_count} surfaces · {workspace.agent_count} agents" + div { class: "{tab_class}", + if workspace.active { + div { class: "workspace-tab-rail" } + } + div { class: "workspace-tab-content", + div { class: "workspace-tab-header", + div { class: "workspace-tab-title", "{workspace.title}" } + div { class: "workspace-tab-trailing", + if has_badge { + span { class: "{badge_state_class}", "{badge_text}" } + } + button { + class: "workspace-tab-close", + onclick: close_workspace, + "×" + } + } + } + if let Some(notification) = &workspace.notification_text { + div { class: "workspace-notification", "{notification}" } + } + if let Some(branch) = &branch_row { + div { class: "workspace-branch-row", "{branch}" } + } + if let Some(ports) = &ports_row { + div { class: "workspace-ports-row", "{ports}" } } - } - div { class: "{badge_class}", - "{badge_text}" } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 16e7f51..c2ab947 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -243,13 +243,21 @@ button {{ }} .sidebar-nav, -.workspace-list, .activity-list {{ display: flex; flex-direction: column; gap: 6px; }} +.workspace-list {{ + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1; + min-height: 0; +}} + .sidebar-nav-button, .workspace-button, .theme-card, @@ -298,92 +306,182 @@ button {{ border-color: {waiting_25}; }} -.workspace-item {{ - padding: 8px 10px; - border-radius: 8px; +.workspace-tab {{ + position: relative; + padding: 8px 10px 8px 14px; + border-radius: 6px; border: 1px solid transparent; display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - transition: background 140ms ease, border-color 140ms ease; + align-items: stretch; + gap: 0; + transition: background 0.14s ease-in-out, border-color 0.14s ease-in-out; }} -.workspace-button:hover .workspace-item {{ +.workspace-button:hover .workspace-tab {{ background: {border_04}; - border-color: {border_10}; + border-color: {border_08}; }} -.workspace-item-active {{ - background: {border_06}; - border-color: {border_12}; +.workspace-tab-active {{ + background: {accent_08}; }} -.workspace-item-state-busy {{ - border-color: {busy_18}; +.workspace-button:hover .workspace-tab-active {{ + background: {accent_12}; }} -.workspace-item-state-completed {{ - border-color: {completed_18}; +.workspace-tab-state-busy {{ + border-color: {busy_12}; }} -.workspace-item-state-waiting {{ - border-color: {waiting_20}; +.workspace-tab-state-completed {{ + border-color: {completed_12}; }} -.workspace-item-state-error {{ - border-color: {error_18}; +.workspace-tab-state-waiting {{ + border-color: {waiting_14}; }} -.workspace-label {{ +.workspace-tab-state-error {{ + border-color: {error_12}; +}} + +.workspace-tab-rail {{ + position: absolute; + left: 0; + top: 5px; + bottom: 5px; + width: 3px; + border-radius: 1.5px; + background: var(--workspace-accent, {accent}); +}} + +.workspace-tab-content {{ + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +}} + +.workspace-tab-header {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +}} + +.workspace-tab-title {{ font-weight: 600; - font-size: 13px; + font-size: 12.5px; color: {text_bright}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; }} -.workspace-preview {{ - color: {text_subtle}; - font-size: 12px; - line-height: 1.35; +.workspace-tab-trailing {{ + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4px; }} -.workspace-meta, -.activity-meta, -.activity-time {{ +.workspace-tab-close {{ + width: 16px; + height: 16px; + border: 0; + border-radius: 4px; + background: transparent; color: {text_dim}; - font-size: 11px; + font-size: 13px; + line-height: 1; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + transition: background 0.14s ease-in-out, color 0.14s ease-in-out; +}} + +.workspace-button:hover .workspace-tab-close {{ + visibility: visible; +}} + +.workspace-tab-close:hover {{ + background: {error_16}; + color: {error}; }} -.workspace-status-badge {{ +.workspace-unread-badge {{ flex: 0 0 auto; + width: 16px; + height: 16px; border-radius: 999px; - padding: 3px 7px; - min-width: 22px; - text-align: center; - font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; font-weight: 700; - background: {accent_14}; - color: {busy_text}; + background: var(--workspace-accent, {accent}); + color: {base}; }} -.workspace-status-badge-state-busy {{ - background: {busy_16}; - color: {busy_text}; +.workspace-unread-badge-error {{ + background: {error}; }} -.workspace-status-badge-state-completed {{ - background: {completed_16}; - color: {completed_text}; +.workspace-unread-badge-waiting {{ + background: {waiting}; }} -.workspace-status-badge-state-waiting {{ - background: {waiting_18}; - color: {waiting_text}; +.workspace-unread-badge-completed {{ + background: {completed}; }} -.workspace-status-badge-state-error {{ - background: {error_16}; - color: {error_text}; +.workspace-notification {{ + color: {text_subtle}; + font-size: 10px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +}} + +.workspace-branch-row {{ + color: {text_muted}; + font-size: 10px; + font-family: "IBM Plex Mono", ui-monospace, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +}} + +.workspace-ports-row {{ + color: {text_dim}; + font-size: 10px; + font-family: "IBM Plex Mono", ui-monospace, monospace; +}} + +.workspace-label {{ + font-weight: 600; + font-size: 12.5px; + color: {text_bright}; +}} + +.workspace-preview {{ + color: {text_subtle}; + font-size: 12px; + line-height: 1.35; +}} + +.workspace-meta, +.activity-meta, +.activity-time {{ + color: {text_dim}; + font-size: 11px; }} .runtime-card, @@ -1123,6 +1221,8 @@ button {{ border_08 = rgba(p.border, 0.08), border_10 = rgba(p.border, 0.10), border_12 = rgba(p.border, 0.12), + accent = p.accent.to_hex(), + accent_08 = rgba(p.accent, 0.08), accent_12 = rgba(p.accent, 0.12), accent_14 = rgba(p.accent, 0.14), accent_20 = rgba(p.accent, 0.20), @@ -1130,26 +1230,27 @@ button {{ accent_24 = rgba(p.accent, 0.24), busy = p.busy.to_hex(), busy_10 = rgba(p.busy, 0.10), + busy_12 = rgba(p.busy, 0.12), busy_16 = rgba(p.busy, 0.16), - busy_18 = rgba(p.busy, 0.18), busy_55 = rgba(p.busy, 0.55), busy_text = p.busy_text.to_hex(), completed = p.completed.to_hex(), completed_10 = rgba(p.completed, 0.10), + completed_12 = rgba(p.completed, 0.12), completed_16 = rgba(p.completed, 0.16), - completed_18 = rgba(p.completed, 0.18), completed_55 = rgba(p.completed, 0.55), completed_text = p.completed_text.to_hex(), waiting = p.waiting.to_hex(), waiting_10 = rgba(p.waiting, 0.10), waiting_12 = rgba(p.waiting, 0.12), + waiting_14 = rgba(p.waiting, 0.14), waiting_18 = rgba(p.waiting, 0.18), - waiting_20 = rgba(p.waiting, 0.20), waiting_25 = rgba(p.waiting, 0.25), waiting_70 = rgba(p.waiting, 0.70), waiting_text = p.waiting_text.to_hex(), error = p.error.to_hex(), error_10 = rgba(p.error, 0.10), + error_12 = rgba(p.error, 0.12), error_16 = rgba(p.error, 0.16), error_18 = rgba(p.error, 0.18), error_65 = rgba(p.error, 0.65), From 0b6344e45939bf1853bb76fb9ce9d56c0fe749f0 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:45:26 +0100 Subject: [PATCH 32/63] feat: rewrite attention panel as CMUX-style notification timeline --- greenfield/crates/taskers-shell/src/lib.rs | 98 +++++---- greenfield/crates/taskers-shell/src/theme.rs | 212 ++++++++++++++++--- 2 files changed, 243 insertions(+), 67 deletions(-) diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index a493c82..6e98c0a 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -214,36 +214,41 @@ pub fn TaskersShell(core: SharedCore) -> Element { } aside { class: "attention-panel", - div { class: "sidebar-heading", "Agents" } - div { class: "attention-summary", - div { class: "workspace-label", "{snapshot.agents.len()} live agents" } - div { class: "workspace-meta", "{snapshot.activity.len()} unread · {snapshot.done_activity.len()} done" } + div { class: "notification-header", + div { class: "sidebar-heading", "Notifications" } + div { class: "notification-counts", + if !snapshot.agents.is_empty() { + span { class: "notification-count-pill notification-count-agents", + "{snapshot.agents.len()} agents" + } + } + if snapshot.activity.len() > 0 { + span { class: "notification-count-pill notification-count-unread", + "{snapshot.activity.len()} unread" + } + } + } } - if snapshot.agents.is_empty() { - div { class: "empty-state", "No live agents." } - } else { - div { class: "sidebar-heading", "Live sessions" } - div { class: "activity-list", + if !snapshot.agents.is_empty() { + div { class: "agent-session-list", for agent in &snapshot.agents { {render_agent_item(agent, core.clone(), &snapshot.current_workspace)} } } } - div { class: "sidebar-heading", "Inbox" } - if snapshot.activity.is_empty() { - div { class: "empty-state", "No unread items." } - } else { - div { class: "activity-list", + div { class: "notification-timeline", + if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { + div { class: "notification-empty", + div { class: "notification-empty-icon", "◎" } + div { class: "notification-empty-title", "No notifications" } + div { class: "notification-empty-subtitle", "Activity from agents and surfaces appears here." } + } + } else { for item in &snapshot.activity { - {render_activity_item(item, core.clone(), &snapshot.current_workspace)} + {render_notification_row(item, core.clone(), &snapshot.current_workspace)} } - } - } - if !snapshot.done_activity.is_empty() { - div { class: "sidebar-heading", "Done" } - div { class: "activity-list", - for item in snapshot.done_activity.iter().take(6) { - {render_activity_item(item, core.clone(), &snapshot.current_workspace)} + for item in snapshot.done_activity.iter().take(8) { + {render_notification_row(item, core.clone(), &snapshot.current_workspace)} } } } @@ -823,16 +828,24 @@ fn render_agent_item( } } -fn render_activity_item( + +fn render_notification_row( item: &ActivityItemSnapshot, core: SharedCore, current_workspace: &taskers_core::WorkspaceViewSnapshot, ) -> Element { - let row_class = format!("activity-item activity-item-state-{}", item.attention.slug()); + let dot_class = if item.unread { + "notification-dot notification-dot-unread" + } else { + "notification-dot notification-dot-read" + }; let activity_id = item.id; let dismiss = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::DismissActivity { activity_id }) + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::DismissActivity { activity_id }); + } }; let focus_target = { let core = core.clone(); @@ -854,22 +867,31 @@ fn render_activity_item( }; rsx! { - div { class: "activity-item-shell", - button { class: "activity-item-button", onclick: focus_target, - div { class: "{row_class}", - div { class: "activity-header", - div { class: "workspace-label", "{item.title}" } - div { class: "activity-time", "{item.attention.label()}" } + button { class: "notification-row-button", onclick: focus_target, + div { class: "notification-row", + div { class: "{dot_class}" } + div { class: "notification-row-content", + div { class: "notification-row-header", + div { class: "notification-title", "{item.title}" } + div { class: "notification-timestamp", "{item.timestamp}" } + } + if let Some(body) = &item.body { + div { class: "notification-body", "{body}" } + } + div { class: "notification-row-footer", + if let Some(source) = &item.source_workspace_title { + div { class: "notification-source", "{source}" } + } + if item.unread { + button { + class: "notification-clear", + onclick: dismiss, + "×" + } + } } - div { class: "activity-meta", "{item.meta}" } - div { class: "activity-preview", "{item.preview}" } } } - if item.unread { - button { class: "activity-action", onclick: dismiss, "Done" } - } else { - div { class: "activity-action activity-action-passive", "Seen" } - } } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index c2ab947..79d8372 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -303,7 +303,7 @@ button {{ .workspace-add:hover {{ background: {waiting_10}; color: {waiting_text}; - border-color: {waiting_25}; + border-color: {waiting_18}; }} .workspace-tab {{ @@ -1066,57 +1066,208 @@ button {{ font-size: 12px; }} -.activity-item-shell {{ - align-items: stretch; +.activity-item {{ + border-radius: 8px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 3px; }} .activity-item-button {{ + width: 100%; + border: 0; + padding: 0; + background: transparent; + text-align: left; +}} + +.activity-item-button:hover .activity-item {{ + background: {border_04}; +}} + +.notification-header {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +}} + +.notification-counts {{ + display: flex; + gap: 6px; +}} + +.notification-count-pill {{ + font-size: 10px; + font-weight: 600; + color: {text_dim}; + padding: 2px 6px; + border-radius: 999px; + background: {border_06}; +}} + +.notification-count-unread {{ + background: {accent_14}; + color: {text_bright}; +}} + +.agent-session-list {{ + display: flex; + flex-direction: column; + gap: 4px; +}} + +.notification-timeline {{ flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +}} + +.notification-row-button {{ + width: 100%; border: 0; padding: 0; background: transparent; text-align: left; }} -.activity-item {{ - border-left: 2px solid transparent; - border-radius: 8px; +.notification-row {{ + display: flex; + align-items: flex-start; + gap: 10px; padding: 10px; + border-radius: 8px; + background: {border_03}; + transition: background 0.14s ease-in-out; +}} + +.notification-row-button:hover .notification-row {{ + background: {border_06}; +}} + +.notification-dot {{ + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 999px; + margin-top: 4px; +}} + +.notification-dot-unread {{ + background: {accent}; +}} + +.notification-dot-read {{ + background: transparent; + border: 1px solid {accent_20}; +}} + +.notification-row-content {{ + flex: 1; + min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; }} -.activity-item-button:hover .activity-item {{ - background: {border_04}; +.notification-row-header {{ + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; }} -.activity-item-state-busy {{ - border-left-color: {busy_55}; +.notification-title {{ + font-weight: 600; + font-size: 12.5px; + color: {text_bright}; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; }} -.activity-item-state-completed {{ - border-left-color: {completed_55}; +.notification-body {{ + color: {text_subtle}; + font-size: 11px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; }} -.activity-item-state-waiting {{ - border-left-color: {waiting_70}; +.notification-timestamp {{ + flex: 0 0 auto; + font-size: 10px; + color: {text_dim}; + white-space: nowrap; }} -.activity-item-state-error {{ - border-left-color: {error_65}; +.notification-row-footer {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; }} -.activity-action {{ - align-self: center; +.notification-source {{ + font-size: 10px; color: {text_dim}; - padding: 0 10px; }} -.activity-action:hover {{ - background: {waiting_10}; - color: {waiting_text}; - border-color: {waiting_25}; +.notification-clear {{ + width: 16px; + height: 16px; + border: 0; + border-radius: 4px; + background: transparent; + color: {text_dim}; + font-size: 13px; + line-height: 1; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.14s ease-in-out, color 0.14s ease-in-out; +}} + +.notification-clear:hover {{ + background: {error_16}; + color: {error}; +}} + +.notification-empty {{ + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 16px; +}} + +.notification-empty-icon {{ + font-size: 28px; + color: {text_dim}; + opacity: 0.5; +}} + +.notification-empty-title {{ + font-weight: 600; + font-size: 13px; + color: {text_muted}; +}} + +.notification-empty-subtitle {{ + font-size: 11px; + color: {text_dim}; + text-align: center; + line-height: 1.4; }} .settings-grid {{ @@ -1232,28 +1383,31 @@ button {{ busy_10 = rgba(p.busy, 0.10), busy_12 = rgba(p.busy, 0.12), busy_16 = rgba(p.busy, 0.16), - busy_55 = rgba(p.busy, 0.55), + + busy_text = p.busy_text.to_hex(), completed = p.completed.to_hex(), completed_10 = rgba(p.completed, 0.10), completed_12 = rgba(p.completed, 0.12), completed_16 = rgba(p.completed, 0.16), - completed_55 = rgba(p.completed, 0.55), + + completed_text = p.completed_text.to_hex(), waiting = p.waiting.to_hex(), waiting_10 = rgba(p.waiting, 0.10), waiting_12 = rgba(p.waiting, 0.12), waiting_14 = rgba(p.waiting, 0.14), waiting_18 = rgba(p.waiting, 0.18), - waiting_25 = rgba(p.waiting, 0.25), - waiting_70 = rgba(p.waiting, 0.70), + + waiting_text = p.waiting_text.to_hex(), error = p.error.to_hex(), error_10 = rgba(p.error, 0.10), error_12 = rgba(p.error, 0.12), error_16 = rgba(p.error, 0.16), error_18 = rgba(p.error, 0.18), - error_65 = rgba(p.error, 0.65), + + error_text = p.error_text.to_hex(), action_window_22 = rgba(p.action_window, 0.22), action_split_22 = rgba(p.action_split, 0.22), From cce1c54152cc6b42a49e821dffd000d7469850ec Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:48:03 +0100 Subject: [PATCH 33/63] feat: add focus flash and workspace header chrome cleanup --- greenfield/crates/taskers-core/src/lib.rs | 11 +++- greenfield/crates/taskers-shell/src/lib.rs | 53 +++++++++------- greenfield/crates/taskers-shell/src/theme.rs | 63 +++++++++++++++++--- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 61fbb1a..7f5e0e5 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -332,6 +332,7 @@ pub struct PaneSnapshot { pub attention: AttentionState, pub active_surface: SurfaceId, pub surfaces: Vec, + pub focus_flash_token: u64, } #[derive(Debug, Clone, PartialEq)] @@ -1032,9 +1033,16 @@ impl TaskersCore { } fn pane_snapshot(&self, workspace: &Workspace, pane: &taskers_domain::PaneRecord) -> PaneSnapshot { + let is_active = workspace.active_pane == pane.id; + let has_unread = pane.highest_attention() != taskers_domain::AttentionState::Normal; + let flash_token = if is_active && has_unread { + self.revision + } else { + 0 + }; PaneSnapshot { id: pane.id, - active: workspace.active_pane == pane.id, + active: is_active, attention: pane.highest_attention().into(), active_surface: pane.active_surface, surfaces: pane @@ -1049,6 +1057,7 @@ impl TaskersCore { attention: surface.attention.into(), }) .collect(), + focus_flash_token: flash_token, } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 6e98c0a..754be25 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -158,34 +158,35 @@ pub fn TaskersShell(core: SharedCore) -> Element { onclick: show_workspace_header, span { class: "workspace-header-label", "{snapshot.current_workspace.title}" } span { class: "workspace-header-meta", - "{snapshot.current_workspace.pane_count} panes · {snapshot.current_workspace.surface_count} surfaces · revision {snapshot.revision}" + "{snapshot.current_workspace.pane_count} panes · {snapshot.current_workspace.surface_count} surfaces" } } } div { class: "workspace-header-actions", if matches!(snapshot.section, ShellSection::Workspace) { - button { - class: if snapshot.overview_mode { - "workspace-header-action workspace-header-action-active" - } else { - "workspace-header-action" - }, - onclick: toggle_overview, - "Overview" - } - button { class: "workspace-header-action", onclick: scroll_left, "←" } - button { class: "workspace-header-action", onclick: scroll_right, "→" } - button { class: "workspace-header-action", onclick: create_window_right, "+ column" } - button { class: "workspace-header-action", onclick: create_window_down, "+ stack" } - button { - class: "workspace-header-action", - onclick: split_terminal, - "+ split" + div { class: "workspace-header-group", + button { + class: if snapshot.overview_mode { + "workspace-header-action workspace-header-action-active" + } else { + "workspace-header-action" + }, + onclick: toggle_overview, + "◫" + } + button { class: "workspace-header-action", onclick: scroll_left, "◀" } + button { class: "workspace-header-action", onclick: scroll_right, "▶" } } - button { - class: "workspace-header-action workspace-header-action-primary", - onclick: split_browser, - "+ browser" + div { class: "workspace-header-divider" } + div { class: "workspace-header-group", + button { class: "workspace-header-action", onclick: create_window_right, "+ col" } + button { class: "workspace-header-action", onclick: create_window_down, "+ stack" } + button { class: "workspace-header-action", onclick: split_terminal, "+ split" } + button { + class: "workspace-header-action workspace-header-action-primary", + onclick: split_browser, + "+ browser" + } } } else { button { @@ -589,6 +590,13 @@ fn render_pane( }) }; + let flash_key = pane.focus_flash_token; + let flash_class = if flash_key > 0 { + "pane-flash-ring pane-flash-ring-active" + } else { + "pane-flash-ring" + }; + rsx! { section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-header", @@ -623,6 +631,7 @@ fn render_pane( div { class: "pane-body", {render_surface_backdrop(active_surface, runtime_status)} } + div { key: "{flash_key}", class: "{flash_class}" } } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 79d8372..3eefbf5 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -202,7 +202,9 @@ button {{ .workspace-sidebar, .attention-panel {{ - background: {surface}; + background: {surface_85}; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); display: flex; flex-direction: column; min-height: 0; @@ -211,13 +213,13 @@ button {{ .workspace-sidebar {{ border-right: 1px solid {border_04}; padding: 8px; - gap: 12px; + gap: 10px; }} .attention-panel {{ border-left: 1px solid {border_04}; padding: 10px 12px; - gap: 10px; + gap: 8px; }} .sidebar-brand {{ @@ -569,17 +571,39 @@ button {{ }} .workspace-header {{ - height: 56px; - min-height: 56px; + height: 48px; + min-height: 48px; border-bottom: 1px solid {border_07}; - padding: 0 14px; + padding: 0 12px; display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 10px; background: {base}; }} +.workspace-header-group {{ + display: flex; + align-items: center; + gap: 2px; + background: {border_04}; + border-radius: 8px; + padding: 2px; +}} + +.workspace-header-group .workspace-header-action {{ + border: 0; + min-height: 26px; + padding: 0 8px; +}} + +.workspace-header-divider {{ + width: 1px; + height: 16px; + background: {border_10}; + margin: 0 4px; +}} + .workspace-header-main, .workspace-header-actions, .pane-header-main, @@ -784,6 +808,7 @@ button {{ }} .pane-card {{ + position: relative; width: 100%; height: 100%; min-width: 0; @@ -816,6 +841,29 @@ button {{ box-shadow: inset 0 0 0 1px {error_10}; }} +@keyframes focus-flash {{ + 0% {{ opacity: 0; }} + 25% {{ opacity: 1; }} + 50% {{ opacity: 0; }} + 75% {{ opacity: 1; }} + 100% {{ opacity: 0; }} +}} + +.pane-flash-ring {{ + position: absolute; + inset: 6px; + border-radius: 10px; + border: 3px solid {accent}; + box-shadow: 0 0 12px {accent_20}; + pointer-events: none; + opacity: 0; + z-index: 10; +}} + +.pane-flash-ring-active {{ + animation: focus-flash 0.9s ease-in-out; +}} + .pane-header {{ min-height: 38px; border-bottom: 1px solid {border_07}; @@ -1355,6 +1403,7 @@ button {{ "#, base = p.base.to_hex(), surface = p.surface.to_hex(), + surface_85 = rgba(p.surface, 0.85), elevated = p.elevated.to_hex(), overlay_03 = rgba(p.overlay, 0.03), overlay_05 = rgba(p.overlay, 0.05), From 2a97d4121ae5816ab73ba693d7c1458016a7ee17 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:50:19 +0100 Subject: [PATCH 34/63] feat: add per-workspace custom accent colors --- crates/taskers-control/src/controller.rs | 12 ++ crates/taskers-control/src/protocol.rs | 4 + crates/taskers-domain/src/model.rs | 25 ++++ greenfield/crates/taskers-core/src/lib.rs | 15 +++ greenfield/crates/taskers-shell/src/lib.rs | 114 +++++++++++++++++-- greenfield/crates/taskers-shell/src/theme.rs | 8 ++ 6 files changed, 166 insertions(+), 12 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 52cfaa7..fff2f02 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -316,6 +316,18 @@ impl InMemoryController { true, ) } + ControlCommand::ReorderWorkspaces { + window_id, + workspace_ids, + } => { + model.reorder_workspaces(window_id, workspace_ids)?; + ( + ControlResponse::Ack { + message: "workspaces reordered".into(), + }, + true, + ) + } ControlCommand::EmitSignal { workspace_id, pane_id, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 54ed2dc..8bba508 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -113,6 +113,10 @@ pub enum ControlCommand { CloseWorkspace { workspace_id: WorkspaceId, }, + ReorderWorkspaces { + window_id: WindowId, + workspace_ids: Vec, + }, EmitSignal { workspace_id: WorkspaceId, pane_id: PaneId, diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index ff7e7fa..1170e51 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -443,6 +443,8 @@ pub struct Workspace { #[serde(default)] pub viewport: WorkspaceViewport, pub notifications: Vec, + #[serde(default)] + pub custom_color: Option, } impl<'de> Deserialize<'de> for Workspace { @@ -478,6 +480,7 @@ impl Workspace { active_pane, viewport: WorkspaceViewport::default(), notifications: Vec::new(), + custom_color: None, } } @@ -1034,6 +1037,25 @@ impl AppModel { Ok(()) } + pub fn reorder_workspaces( + &mut self, + window_id: WindowId, + new_order: Vec, + ) -> Result<(), DomainError> { + let window = self + .windows + .get_mut(&window_id) + .ok_or(DomainError::MissingWindow(window_id))?; + let existing: std::collections::HashSet<_> = + window.workspace_order.iter().copied().collect(); + let proposed: std::collections::HashSet<_> = new_order.iter().copied().collect(); + if existing != proposed { + return Ok(()); + } + window.workspace_order = new_order; + Ok(()) + } + pub fn switch_workspace( &mut self, window_id: WindowId, @@ -1939,6 +1961,8 @@ struct CurrentWorkspaceSerde { viewport: WorkspaceViewport, #[serde(default)] notifications: Vec, + #[serde(default)] + custom_color: Option, } impl CurrentWorkspaceSerde { @@ -1953,6 +1977,7 @@ impl CurrentWorkspaceSerde { active_pane: self.active_pane, viewport: self.viewport, notifications: self.notifications, + custom_color: self.custom_color, }; workspace.normalize(); workspace diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 7f5e0e5..59b7adb 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -408,6 +408,7 @@ pub struct WorkspaceSummary { pub git_branch: Option, pub working_directory: Option, pub listening_ports: Vec, + pub custom_color: Option, } #[derive(Debug, Clone, PartialEq)] @@ -606,6 +607,7 @@ pub enum ShellAction { ToggleOverview, FocusWorkspace { workspace_id: WorkspaceId }, CloseWorkspace { workspace_id: WorkspaceId }, + ReorderWorkspaces { workspace_ids: Vec }, CreateWorkspace, CreateWorkspaceWindow { direction: WorkspaceDirection }, FocusWorkspaceWindow { window_id: WorkspaceWindowId }, @@ -878,6 +880,8 @@ impl TaskersCore { git_branch, working_directory, listening_ports, + custom_color: workspace + .and_then(|ws| ws.custom_color.clone()), } }) .collect() @@ -1247,6 +1251,13 @@ impl TaskersCore { ShellAction::CloseWorkspace { workspace_id } => { self.dispatch_control(ControlCommand::CloseWorkspace { workspace_id }) } + ShellAction::ReorderWorkspaces { workspace_ids } => { + let window_id = self.app_state.snapshot_model().active_window; + self.dispatch_control(ControlCommand::ReorderWorkspaces { + window_id, + workspace_ids, + }) + } ShellAction::CreateWorkspace => self.create_workspace(), ShellAction::CreateWorkspaceWindow { direction } => { self.create_workspace_window(direction) @@ -2042,6 +2053,10 @@ fn default_preview_app_state() -> AppState { let _ = model.close_surface(workspace_id, browser_pane_id, placeholder_surface_id); } + if let Some(workspace) = model.workspaces.get_mut(&workspace_id) { + workspace.custom_color = Some("#bb9af7".into()); + } + AppState::new( model, default_session_path_for_preview("greenfield-preview-bootstrap"), diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 754be25..cb7ae2e 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -5,8 +5,8 @@ use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, - SurfaceKind, SurfaceSnapshot, WorkspaceDirection, WorkspaceSummary, WorkspaceViewSnapshot, - WorkspaceWindowSnapshot, + SurfaceKind, SurfaceSnapshot, WorkspaceDirection, WorkspaceId, WorkspaceSummary, + WorkspaceViewSnapshot, WorkspaceWindowSnapshot, }; fn app_css(snapshot: &ShellSnapshot) -> String { @@ -137,10 +137,9 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "sidebar-heading", "Workspaces" } button { class: "workspace-add", onclick: create_workspace, "+" } } - div { class: "workspace-list", - for workspace in &snapshot.workspaces { - {render_workspace_item(workspace, core.clone())} - } + WorkspaceList { + workspaces: snapshot.workspaces.clone(), + core: core.clone(), } div { class: "runtime-card", div { class: "sidebar-heading", "Runtime status" } @@ -258,7 +257,35 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } -fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Element { +#[component] +fn WorkspaceList(workspaces: Vec, core: SharedCore) -> Element { + let drag_source = use_signal(|| None::); + let drag_target = use_signal(|| None::); + + let workspace_ids: Vec = workspaces.iter().map(|ws| ws.id).collect(); + + rsx! { + div { class: "workspace-list", + for workspace in &workspaces { + {render_workspace_item( + workspace, + core.clone(), + drag_source, + drag_target, + &workspace_ids, + )} + } + } + } +} + +fn render_workspace_item( + workspace: &WorkspaceSummary, + core: SharedCore, + mut drag_source: Signal>, + mut drag_target: Signal>, + all_ids: &[WorkspaceId], +) -> Element { let tab_class = if workspace.active { format!( "workspace-tab workspace-tab-active workspace-tab-state-{}", @@ -290,9 +317,12 @@ fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Elem core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); } }; - let close_workspace = move |event: Event| { - event.stop_propagation(); - core.dispatch_shell_action(ShellAction::CloseWorkspace { workspace_id }); + let close_workspace = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::CloseWorkspace { workspace_id }); + } }; let branch_row = match (&workspace.git_branch, &workspace.working_directory) { @@ -314,9 +344,69 @@ fn render_workspace_item(workspace: &WorkspaceSummary, core: SharedCore) -> Elem ) }; + let tab_style = workspace + .custom_color + .as_ref() + .map(|color| format!("--workspace-accent: {color};")) + .unwrap_or_default(); + + let is_drag_target = *drag_target.read() == Some(workspace_id); + let outer_class = if is_drag_target { + "workspace-button workspace-button-drag-over" + } else { + "workspace-button" + }; + + let all_ids = all_ids.to_vec(); + let on_dragstart = move |_: Event| { + drag_source.set(Some(workspace_id)); + }; + let on_dragover = move |event: Event| { + event.prevent_default(); + drag_target.set(Some(workspace_id)); + }; + let on_dragleave = move |_: Event| { + if *drag_target.read() == Some(workspace_id) { + drag_target.set(None); + } + }; + let on_drop = { + let core = core.clone(); + let all_ids = all_ids.clone(); + move |event: Event| { + event.prevent_default(); + let source = *drag_source.read(); + drag_source.set(None); + drag_target.set(None); + if let Some(source_id) = source { + if source_id != workspace_id { + let mut new_order = all_ids.clone(); + if let Some(src_pos) = new_order.iter().position(|id| *id == source_id) { + new_order.remove(src_pos); + let dst_pos = new_order + .iter() + .position(|id| *id == workspace_id) + .unwrap_or(new_order.len()); + new_order.insert(dst_pos, source_id); + core.dispatch_shell_action(ShellAction::ReorderWorkspaces { + workspace_ids: new_order, + }); + } + } + } + } + }; + rsx! { - button { class: "workspace-button", onclick: focus_workspace, - div { class: "{tab_class}", + button { + class: "{outer_class}", + draggable: "true", + onclick: focus_workspace, + ondragstart: on_dragstart, + ondragover: on_dragover, + ondragleave: on_dragleave, + ondrop: on_drop, + div { class: "{tab_class}", style: "{tab_style}", if workspace.active { div { class: "workspace-tab-rail" } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 3eefbf5..6ee721b 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -308,6 +308,14 @@ button {{ border-color: {waiting_18}; }} +.workspace-button[draggable] {{ + cursor: grab; +}} + +.workspace-button-drag-over .workspace-tab {{ + border-top: 2px solid var(--workspace-accent, {accent}); +}} + .workspace-tab {{ position: relative; padding: 8px 10px 8px 14px; From 0351e1d444a7db246216769af954cde531bcc0c7 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 11:55:05 +0100 Subject: [PATCH 35/63] feat: add progress bar and pull request status to workspace tabs --- crates/taskers-domain/src/lib.rs | 5 +- crates/taskers-domain/src/model.rs | 28 +++++++ greenfield/crates/taskers-core/src/lib.rs | 52 ++++++++++++- greenfield/crates/taskers-host/src/lib.rs | 32 +++++++- greenfield/crates/taskers-shell/src/lib.rs | 82 +++++++++++++------- greenfield/crates/taskers-shell/src/theme.rs | 66 ++++++++++++++++ 6 files changed, 230 insertions(+), 35 deletions(-) diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index 0a5a78f..c52e698 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -13,8 +13,9 @@ pub use model::{ ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, DEFAULT_WORKSPACE_WINDOW_HEIGHT, DEFAULT_WORKSPACE_WINDOW_WIDTH, DomainError, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, - PaneRecord, PersistedSession, SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, - Workspace, WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceSummary, + PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, + SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, + WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceSummary, WorkspaceViewport, WorkspaceWindowRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 1170e51..1f219d9 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -63,6 +63,30 @@ pub enum PaneKind { Browser, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProgressState { + /// Progress as permille (0–1000). + pub value: u16, + pub label: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrStatus { + Open, + Draft, + Merged, + Closed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestState { + pub number: u32, + pub title: String, + pub status: PrStatus, + pub url: String, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneMetadata { pub title: Option, @@ -77,6 +101,10 @@ pub struct PaneMetadata { #[serde(default)] pub agent_active: bool, pub last_signal_at: Option, + #[serde(default)] + pub progress: Option, + #[serde(default)] + pub pull_requests: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 59b7adb..751d969 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -306,7 +306,7 @@ impl Default for LayoutMetrics { Self { sidebar_width: 248, activity_width: 312, - toolbar_height: 56, + toolbar_height: 48, workspace_padding: 16, split_gap: 12, pane_header_height: 38, @@ -392,7 +392,7 @@ pub struct SurfacePortalPlan { pub panes: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct WorkspaceSummary { pub id: WorkspaceId, pub title: String, @@ -409,6 +409,23 @@ pub struct WorkspaceSummary { pub working_directory: Option, pub listening_ports: Vec, pub custom_color: Option, + pub progress: Option, + pub pull_requests: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProgressSnapshot { + pub fraction: f32, + pub label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullRequestSnapshot { + pub number: u32, + pub title: String, + pub status: String, + pub status_icon: &'static str, + pub url: String, } #[derive(Debug, Clone, PartialEq)] @@ -882,6 +899,37 @@ impl TaskersCore { listening_ports, custom_color: workspace .and_then(|ws| ws.custom_color.clone()), + progress: workspace + .into_iter() + .flat_map(|ws| ws.panes.values()) + .flat_map(|pane| pane.surfaces.values()) + .find_map(|surface| { + surface.metadata.progress.as_ref().map(|p| ProgressSnapshot { + fraction: f32::from(p.value.min(1000)) / 1000.0, + label: p.label.clone(), + }) + }), + pull_requests: workspace + .into_iter() + .flat_map(|ws| ws.panes.values()) + .flat_map(|pane| pane.surfaces.values()) + .flat_map(|surface| surface.metadata.pull_requests.iter()) + .map(|pr| { + let (status, status_icon) = match pr.status { + taskers_domain::PrStatus::Open => ("Open", "●"), + taskers_domain::PrStatus::Draft => ("Draft", "◐"), + taskers_domain::PrStatus::Merged => ("Merged", "✓"), + taskers_domain::PrStatus::Closed => ("Closed", "✕"), + }; + PullRequestSnapshot { + number: pr.number, + title: pr.title.clone(), + status: status.into(), + status_icon, + url: pr.url.clone(), + } + }) + .collect(), } }) .collect() diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 440b48e..edb47ed 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -865,7 +865,7 @@ pub fn browser_plans(portal: &SurfacePortalPlan) -> Vec { .panes .iter() .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) - .cloned() + .filter_map(|plan| clip_to_content(plan, &portal.content)) .collect() } @@ -874,10 +874,38 @@ pub fn terminal_plans(portal: &SurfacePortalPlan) -> Vec { .panes .iter() .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) - .cloned() + .filter_map(|plan| clip_to_content(plan, &portal.content)) .collect() } +fn clip_to_content( + plan: &PortalSurfacePlan, + content: &taskers_core::Frame, +) -> Option { + let f = &plan.frame; + let cx = content.x; + let cy = content.y; + let cr = content.x + content.width; + let cb = content.y + content.height; + + let clipped_x = f.x.max(cx); + let clipped_y = f.y.max(cy); + let clipped_r = (f.x + f.width).min(cr); + let clipped_b = (f.y + f.height).min(cb); + + let clipped_w = clipped_r - clipped_x; + let clipped_h = clipped_b - clipped_y; + + if clipped_w <= 0 || clipped_h <= 0 { + return None; + } + + Some(PortalSurfacePlan { + frame: taskers_core::Frame::new(clipped_x, clipped_y, clipped_w, clipped_h), + ..plan.clone() + }) +} + fn emit_diagnostic(sink: Option<&DiagnosticsSink>, record: DiagnosticRecord) { if let Some(sink) = sink { sink(record); diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index cb7ae2e..59e1717 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -3,10 +3,10 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, - LayoutNodeSnapshot, PaneSnapshot, RuntimeCapability, RuntimeStatus, SettingsSnapshot, - SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, - SurfaceKind, SurfaceSnapshot, WorkspaceDirection, WorkspaceId, WorkspaceSummary, - WorkspaceViewSnapshot, WorkspaceWindowSnapshot, + LayoutNodeSnapshot, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeCapability, + RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, + ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceDirection, + WorkspaceId, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, }; fn app_css(snapshot: &ShellSnapshot) -> String { @@ -101,6 +101,10 @@ pub fn TaskersShell(core: SharedCore) -> Element { } }; + let drag_source = use_signal(|| None::); + let drag_target = use_signal(|| None::); + let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); + let main_class = match snapshot.section { ShellSection::Workspace => { if snapshot.overview_mode { @@ -137,9 +141,16 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "sidebar-heading", "Workspaces" } button { class: "workspace-add", onclick: create_workspace, "+" } } - WorkspaceList { - workspaces: snapshot.workspaces.clone(), - core: core.clone(), + div { class: "workspace-list", + for workspace in &snapshot.workspaces { + {render_workspace_item( + workspace, + core.clone(), + drag_source, + drag_target, + &workspace_ids, + )} + } } div { class: "runtime-card", div { class: "sidebar-heading", "Runtime status" } @@ -257,28 +268,6 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } -#[component] -fn WorkspaceList(workspaces: Vec, core: SharedCore) -> Element { - let drag_source = use_signal(|| None::); - let drag_target = use_signal(|| None::); - - let workspace_ids: Vec = workspaces.iter().map(|ws| ws.id).collect(); - - rsx! { - div { class: "workspace-list", - for workspace in &workspaces { - {render_workspace_item( - workspace, - core.clone(), - drag_source, - drag_target, - &workspace_ids, - )} - } - } - } -} - fn render_workspace_item( workspace: &WorkspaceSummary, core: SharedCore, @@ -433,12 +422,47 @@ fn render_workspace_item( if let Some(ports) = &ports_row { div { class: "workspace-ports-row", "{ports}" } } + {render_workspace_progress(&workspace.progress)} + {render_workspace_pull_requests(&workspace.pull_requests)} } } } } } +fn render_workspace_progress(progress: &Option) -> Element { + let Some(progress) = progress else { + return rsx! {}; + }; + let pct = (progress.fraction * 100.0).clamp(0.0, 100.0); + let fill_style = format!("width: {pct:.1}%;"); + rsx! { + div { class: "workspace-progress", + div { class: "workspace-progress-track", + div { class: "workspace-progress-fill", style: "{fill_style}" } + } + if let Some(label) = &progress.label { + span { class: "workspace-progress-label", "{label}" } + } + } + } +} + +fn render_workspace_pull_requests(pull_requests: &[PullRequestSnapshot]) -> Element { + if pull_requests.is_empty() { + return rsx! {}; + } + rsx! { + for pr in pull_requests { + div { class: "workspace-pr-row", + span { class: "workspace-pr-icon workspace-pr-status-{pr.status}", "{pr.status_icon}" } + span { class: "workspace-pr-number", "#{pr.number}" } + span { class: "workspace-pr-title", "{pr.title}" } + } + } + } +} + fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability) -> Element { let class = match capability { RuntimeCapability::Ready => "status-pill status-pill-ready", diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 6ee721b..40310a2 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -475,6 +475,72 @@ button {{ font-family: "IBM Plex Mono", ui-monospace, monospace; }} +.workspace-progress {{ + display: flex; + align-items: center; + gap: 6px; +}} + +.workspace-progress-track {{ + flex: 1; + height: 3px; + border-radius: 1.5px; + background: {border_08}; +}} + +.workspace-progress-fill {{ + height: 100%; + border-radius: 1.5px; + background: var(--workspace-accent, {accent}); + transition: width 0.3s ease; +}} + +.workspace-progress-label {{ + font-size: 10px; + color: {text_dim}; + white-space: nowrap; +}} + +.workspace-pr-row {{ + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; +}} + +.workspace-pr-icon {{ + font-size: 10px; +}} + +.workspace-pr-status-Open {{ + color: {completed}; +}} + +.workspace-pr-status-Draft {{ + color: {waiting}; +}} + +.workspace-pr-status-Merged {{ + color: {accent}; +}} + +.workspace-pr-status-Closed {{ + color: {error}; +}} + +.workspace-pr-number {{ + color: {text_muted}; + font-family: "IBM Plex Mono", ui-monospace, monospace; +}} + +.workspace-pr-title {{ + color: {text_dim}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +}} + .workspace-label {{ font-weight: 600; font-size: 12.5px; From f552683f8b9b31f55fa8f6920b7d46e6bee80936 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 13:25:45 +0100 Subject: [PATCH 36/63] fix: restore greenfield workspace navigation --- greenfield/crates/taskers-core/src/lib.rs | 618 ++++++++++++++----- greenfield/crates/taskers-host/src/lib.rs | 110 ++-- greenfield/crates/taskers-shell/src/lib.rs | 162 +++-- greenfield/crates/taskers-shell/src/theme.rs | 90 ++- 4 files changed, 707 insertions(+), 273 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 751d969..e86dd7f 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -5,18 +5,17 @@ use std::{ path::PathBuf, sync::Arc, }; -use time::OffsetDateTime; use taskers_app_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ - ActivityItem, AppModel, PaneKind, PaneMetadata, PaneMetadataPatch, - SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, - Workspace, DEFAULT_WORKSPACE_WINDOW_GAP, MIN_WORKSPACE_WINDOW_HEIGHT, - MIN_WORKSPACE_WINDOW_WIDTH, + ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, MIN_WORKSPACE_WINDOW_HEIGHT, + MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, PaneMetadataPatch, + SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, Workspace, WorkspaceSummary as DomainWorkspaceSummary, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; +use time::OffsetDateTime; use tokio::sync::watch; pub use taskers_domain::{PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId}; @@ -597,11 +596,29 @@ pub struct ShellSnapshot { #[derive(Debug, Clone, PartialEq, Eq)] pub enum HostEvent { - PaneFocused { pane_id: PaneId }, - SurfaceClosed { pane_id: PaneId, surface_id: SurfaceId }, - SurfaceTitleChanged { surface_id: SurfaceId, title: String }, - SurfaceUrlChanged { surface_id: SurfaceId, url: String }, - SurfaceCwdChanged { surface_id: SurfaceId, cwd: String }, + PaneFocused { + pane_id: PaneId, + }, + ViewportScrolled { + dx: i32, + dy: i32, + }, + SurfaceClosed { + pane_id: PaneId, + surface_id: SurfaceId, + }, + SurfaceTitleChanged { + surface_id: SurfaceId, + title: String, + }, + SurfaceUrlChanged { + surface_id: SurfaceId, + url: String, + }, + SurfaceCwdChanged { + surface_id: SurfaceId, + cwd: String, + }, BrowserNavigationStateChanged { surface_id: SurfaceId, can_go_back: bool, @@ -620,30 +637,78 @@ pub enum HostCommand { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ShellAction { - ShowSection { section: ShellSection }, + ShowSection { + section: ShellSection, + }, ToggleOverview, - FocusWorkspace { workspace_id: WorkspaceId }, - CloseWorkspace { workspace_id: WorkspaceId }, - ReorderWorkspaces { workspace_ids: Vec }, + FocusWorkspace { + workspace_id: WorkspaceId, + }, + CloseWorkspace { + workspace_id: WorkspaceId, + }, + ReorderWorkspaces { + workspace_ids: Vec, + }, CreateWorkspace, - CreateWorkspaceWindow { direction: WorkspaceDirection }, - FocusWorkspaceWindow { window_id: WorkspaceWindowId }, - ScrollViewport { dx: i32, dy: i32 }, - SplitBrowser { pane_id: Option }, - SplitTerminal { pane_id: Option }, - AddBrowserSurface { pane_id: Option }, - AddTerminalSurface { pane_id: Option }, - FocusPane { pane_id: PaneId }, - FocusSurface { pane_id: PaneId, surface_id: SurfaceId }, - NavigateBrowser { surface_id: SurfaceId, url: String }, - BrowserBack { surface_id: SurfaceId }, - BrowserForward { surface_id: SurfaceId }, - BrowserReload { surface_id: SurfaceId }, - ToggleBrowserDevtools { surface_id: SurfaceId }, - CloseSurface { pane_id: PaneId, surface_id: SurfaceId }, - DismissActivity { activity_id: ActivityId }, - SelectTheme { theme_id: String }, - SelectShortcutPreset { preset_id: String }, + CreateWorkspaceWindow { + direction: WorkspaceDirection, + }, + FocusWorkspaceWindow { + window_id: WorkspaceWindowId, + }, + ScrollViewport { + dx: i32, + dy: i32, + }, + SplitBrowser { + pane_id: Option, + }, + SplitTerminal { + pane_id: Option, + }, + AddBrowserSurface { + pane_id: Option, + }, + AddTerminalSurface { + pane_id: Option, + }, + FocusPane { + pane_id: PaneId, + }, + FocusSurface { + pane_id: PaneId, + surface_id: SurfaceId, + }, + NavigateBrowser { + surface_id: SurfaceId, + url: String, + }, + BrowserBack { + surface_id: SurfaceId, + }, + BrowserForward { + surface_id: SurfaceId, + }, + BrowserReload { + surface_id: SurfaceId, + }, + ToggleBrowserDevtools { + surface_id: SurfaceId, + }, + CloseSurface { + pane_id: PaneId, + surface_id: SurfaceId, + }, + DismissActivity { + activity_id: ActivityId, + }, + SelectTheme { + theme_id: String, + }, + SelectShortcutPreset { + preset_id: String, + }, } #[derive(Debug, Clone)] @@ -798,14 +863,11 @@ impl TaskersCore { activity: self.activity_snapshot(&model), done_activity: self.done_activity_snapshot(&model), portal: SurfacePortalPlan { - window: Frame::new( - 0, - 0, - self.ui.window_size.width, - self.ui.window_size.height, - ), + window: Frame::new(0, 0, self.ui.window_size.width, self.ui.window_size.height), content: viewport, - panes: if matches!(self.ui.section, ShellSection::Workspace) { + panes: if matches!(self.ui.section, ShellSection::Workspace) + && !self.ui.overview_mode + { self.collect_workspace_surface_plans(workspace_id, workspace, &window_frames) } else { Vec::new() @@ -819,8 +881,8 @@ impl TaskersCore { fn workspace_viewport_frame(&self) -> Frame { let metrics = self.metrics; - let width = (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width) - .max(640); + let width = + (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width).max(640); let height = self.ui.window_size.height.max(320); let inset = metrics.workspace_padding; Frame::new( @@ -859,10 +921,10 @@ impl TaskersCore { let active_pane_surface = workspace .and_then(|ws| ws.panes.get(&summary.active_pane)) .and_then(|pane| pane.active_surface()); - let git_branch = active_pane_surface - .and_then(|surface| surface.metadata.git_branch.clone()); - let working_directory = active_pane_surface - .and_then(|surface| surface.metadata.cwd.clone()); + let git_branch = + active_pane_surface.and_then(|surface| surface.metadata.git_branch.clone()); + let working_directory = + active_pane_surface.and_then(|surface| surface.metadata.cwd.clone()); let mut listening_ports: Vec = workspace .into_iter() .flat_map(|ws| ws.panes.values()) @@ -877,12 +939,8 @@ impl TaskersCore { title: summary.label.clone(), preview: workspace_preview(&summary), active: model.active_workspace_id() == Some(summary.workspace_id), - pane_count: workspace - .map(|ws| ws.panes.len()) - .unwrap_or_default(), - surface_count: workspace - .map(workspace_surface_count) - .unwrap_or_default(), + pane_count: workspace.map(|ws| ws.panes.len()).unwrap_or_default(), + surface_count: workspace.map(workspace_surface_count).unwrap_or_default(), agent_count: summary.agent_summaries.len(), waiting_agent_count: summary .agent_summaries @@ -897,17 +955,20 @@ impl TaskersCore { git_branch, working_directory, listening_ports, - custom_color: workspace - .and_then(|ws| ws.custom_color.clone()), + custom_color: workspace.and_then(|ws| ws.custom_color.clone()), progress: workspace .into_iter() .flat_map(|ws| ws.panes.values()) .flat_map(|pane| pane.surfaces.values()) .find_map(|surface| { - surface.metadata.progress.as_ref().map(|p| ProgressSnapshot { - fraction: f32::from(p.value.min(1000)) / 1000.0, - label: p.label.clone(), - }) + surface + .metadata + .progress + .as_ref() + .map(|p| ProgressSnapshot { + fraction: f32::from(p.value.min(1000)) / 1000.0, + label: p.label.clone(), + }) }), pull_requests: workspace .into_iter() @@ -942,24 +1003,27 @@ impl TaskersCore { .unwrap_or_default() .into_iter() .flat_map(|summary| { - summary.agent_summaries.into_iter().map(move |agent| AgentSessionSnapshot { - workspace_id: summary.workspace_id, - workspace_title: summary.label.clone(), - pane_id: agent.pane_id, - surface_id: agent.surface_id, - agent_kind: agent.agent_kind.clone(), - title: agent - .title - .clone() - .unwrap_or_else(|| format!("{} {}", agent.agent_kind, agent.state.label())), - state: agent.state.into(), - }) + summary + .agent_summaries + .into_iter() + .map(move |agent| AgentSessionSnapshot { + workspace_id: summary.workspace_id, + workspace_title: summary.label.clone(), + pane_id: agent.pane_id, + surface_id: agent.surface_id, + agent_kind: agent.agent_kind.clone(), + title: agent.title.clone().unwrap_or_else(|| { + format!("{} {}", agent.agent_kind, agent.state.label()) + }), + state: agent.state.into(), + }) }) .collect() } fn activity_snapshot(&self, model: &AppModel) -> Vec { - model.activity_items() + model + .activity_items() .into_iter() .map(|item| activity_item_snapshot(model, &item, true)) .collect() @@ -1013,12 +1077,7 @@ impl TaskersCore { .filter_map(|window_id| { let window = workspace.windows.get(window_id)?; let (_, frame) = window_frames.get(window_id)?; - Some(self.workspace_window_snapshot( - workspace, - column.id, - window, - *frame, - )) + Some(self.workspace_window_snapshot(workspace, column.id, window, *frame)) }) .collect(), }) @@ -1084,7 +1143,11 @@ impl TaskersCore { } } - fn pane_snapshot(&self, workspace: &Workspace, pane: &taskers_domain::PaneRecord) -> PaneSnapshot { + fn pane_snapshot( + &self, + workspace: &Workspace, + pane: &taskers_domain::PaneRecord, + ) -> PaneSnapshot { let is_active = workspace.active_pane == pane.id; let has_unread = pane.highest_attention() != taskers_domain::AttentionState::Normal; let flash_token = if is_active && has_unread { @@ -1154,12 +1217,7 @@ impl TaskersCore { .values() .filter_map(|window| { let (_, frame) = window_frames.get(&window.id)?; - Some(self.collect_surface_plans( - workspace_id, - workspace, - &window.layout, - *frame, - )) + Some(self.collect_surface_plans(workspace_id, workspace, &window.layout, *frame)) }) .flatten() .collect() @@ -1183,7 +1241,11 @@ impl TaskersCore { surface_id: active_surface.id, active: workspace.active_pane == pane.id, frame: pane_body_frame(frame, self.metrics, &active_surface.kind), - mount: self.mount_spec_for_active_surface(workspace_id, pane, active_surface), + mount: self.mount_spec_for_active_surface( + workspace_id, + pane, + active_surface, + ), }) }) .into_iter() @@ -1194,8 +1256,12 @@ impl TaskersCore { first, second, } => { - let (first_frame, second_frame) = - split_frame(frame, SplitAxis::from_domain(*axis), *ratio, self.metrics.split_gap); + let (first_frame, second_frame) = split_frame( + frame, + SplitAxis::from_domain(*axis), + *ratio, + self.metrics.split_gap, + ); let mut plans = self.collect_surface_plans(workspace_id, workspace, first, first_frame); plans.extend(self.collect_surface_plans( @@ -1234,7 +1300,15 @@ impl TaskersCore { fn apply_host_event(&mut self, event: HostEvent) -> bool { match event { HostEvent::PaneFocused { pane_id } => self.focus_pane_by_id(pane_id), - HostEvent::SurfaceClosed { pane_id, surface_id } => { + HostEvent::ViewportScrolled { dx, dy } => { + matches!(self.ui.section, ShellSection::Workspace) + && !self.ui.overview_mode + && self.scroll_viewport_by(dx, dy) + } + HostEvent::SurfaceClosed { + pane_id, + surface_id, + } => { self.browser_navigation.remove(&surface_id); self.close_surface_by_id(pane_id, surface_id) } @@ -1310,10 +1384,16 @@ impl TaskersCore { ShellAction::CreateWorkspaceWindow { direction } => { self.create_workspace_window(direction) } - ShellAction::FocusWorkspaceWindow { window_id } => self.focus_workspace_window(window_id), + ShellAction::FocusWorkspaceWindow { window_id } => { + self.focus_workspace_window(window_id) + } ShellAction::ScrollViewport { dx, dy } => self.scroll_viewport_by(dx, dy), - ShellAction::SplitBrowser { pane_id } => self.split_with_kind(pane_id, PaneKind::Browser), - ShellAction::SplitTerminal { pane_id } => self.split_with_kind(pane_id, PaneKind::Terminal), + ShellAction::SplitBrowser { pane_id } => { + self.split_with_kind(pane_id, PaneKind::Browser) + } + ShellAction::SplitTerminal { pane_id } => { + self.split_with_kind(pane_id, PaneKind::Terminal) + } ShellAction::AddBrowserSurface { pane_id } => { self.add_surface_to_pane(pane_id, PaneKind::Browser) } @@ -1321,9 +1401,10 @@ impl TaskersCore { self.add_surface_to_pane(pane_id, PaneKind::Terminal) } ShellAction::FocusPane { pane_id } => self.focus_pane_by_id(pane_id), - ShellAction::FocusSurface { pane_id, surface_id } => { - self.focus_surface_by_id(pane_id, surface_id) - } + ShellAction::FocusSurface { + pane_id, + surface_id, + } => self.focus_surface_by_id(pane_id, surface_id), ShellAction::NavigateBrowser { surface_id, url } => { self.navigate_browser_surface(surface_id, &url) } @@ -1339,9 +1420,10 @@ impl TaskersCore { ShellAction::ToggleBrowserDevtools { surface_id } => { self.queue_host_command(HostCommand::BrowserToggleDevtools { surface_id }) } - ShellAction::CloseSurface { pane_id, surface_id } => { - self.close_surface_by_id(pane_id, surface_id) - } + ShellAction::CloseSurface { + pane_id, + surface_id, + } => self.close_surface_by_id(pane_id, surface_id), ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), ShellAction::SelectTheme { theme_id } => { if self.ui.selected_theme_id == theme_id { @@ -1439,7 +1521,10 @@ impl TaskersCore { None => return false, }; - let ControlResponse::PaneSplit { pane_id: new_pane_id } = response else { + let ControlResponse::PaneSplit { + pane_id: new_pane_id, + } = response + else { return false; }; @@ -1486,7 +1571,9 @@ impl TaskersCore { } fn focus_pane_by_id(&mut self, pane_id: PaneId) -> bool { - let Some((workspace_id, _)) = self.resolve_workspace_pane(&self.app_state.snapshot_model(), pane_id) else { + let Some((workspace_id, _)) = + self.resolve_workspace_pane(&self.app_state.snapshot_model(), pane_id) + else { return false; }; if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { @@ -1495,7 +1582,10 @@ impl TaskersCore { workspace_id, }); } - self.dispatch_control(ControlCommand::FocusPane { workspace_id, pane_id }) + self.dispatch_control(ControlCommand::FocusPane { + workspace_id, + pane_id, + }) } fn focus_surface_by_id(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool { @@ -1554,11 +1644,7 @@ impl TaskersCore { }) } - fn update_surface_metadata( - &mut self, - surface_id: SurfaceId, - patch: PaneMetadataPatch, - ) -> bool { + fn update_surface_metadata(&mut self, surface_id: SurfaceId, patch: PaneMetadataPatch) -> bool { self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { surface_id, patch }) } @@ -1577,9 +1663,15 @@ impl TaskersCore { model: &AppModel, pane_id: PaneId, ) -> Option<(WorkspaceId, PaneId)> { - model.workspaces.iter().find_map(|(workspace_id, workspace)| { - workspace.panes.contains_key(&pane_id).then_some((*workspace_id, pane_id)) - }) + model + .workspaces + .iter() + .find_map(|(workspace_id, workspace)| { + workspace + .panes + .contains_key(&pane_id) + .then_some((*workspace_id, pane_id)) + }) } fn resolve_surface_location( @@ -1587,13 +1679,16 @@ impl TaskersCore { model: &AppModel, surface_id: SurfaceId, ) -> Option<(WorkspaceId, PaneId)> { - model.workspaces.iter().find_map(|(workspace_id, workspace)| { - workspace.panes.iter().find_map(|(pane_id, pane)| { - pane.surfaces - .contains_key(&surface_id) - .then_some((*workspace_id, *pane_id)) + model + .workspaces + .iter() + .find_map(|(workspace_id, workspace)| { + workspace.panes.iter().find_map(|(pane_id, pane)| { + pane.surfaces + .contains_key(&surface_id) + .then_some((*workspace_id, *pane_id)) + }) }) - }) } fn dispatch_control(&mut self, command: ControlCommand) -> bool { @@ -1627,7 +1722,10 @@ impl TaskersCore { } fn bump_local_revision(&mut self) { - self.revision = self.revision.max(self.observed_app_revision).saturating_add(1); + self.revision = self + .revision + .max(self.observed_app_revision) + .saturating_add(1); } fn drain_host_commands(&mut self) -> Vec { @@ -1743,30 +1841,198 @@ struct ShortcutBindingSpec { } const SHORTCUT_BINDINGS: &[ShortcutBindingSpec] = &[ - ShortcutBindingSpec { id: "toggle_overview", label: "Toggle overview", detail: "Zoom the current workspace out to fit the full column strip.", category: "General", balanced: &["o"], power_user: &["o"] }, - ShortcutBindingSpec { id: "close_terminal", label: "Close terminal", detail: "Close the active pane or active top-level window.", category: "General", balanced: &["x"], power_user: &["x"] }, - ShortcutBindingSpec { id: "open_browser_split", label: "Open browser in split", detail: "Split the active pane to the right and open a browser surface.", category: "Browser", balanced: &["l"], power_user: &["l"] }, - ShortcutBindingSpec { id: "focus_browser_address", label: "Focus browser address bar", detail: "Focus the address bar for the active browser surface.", category: "Browser", balanced: &["l"], power_user: &["l"] }, - ShortcutBindingSpec { id: "reload_browser_page", label: "Reload browser page", detail: "Reload the active browser surface.", category: "Browser", balanced: &["r"], power_user: &["r"] }, - ShortcutBindingSpec { id: "toggle_browser_devtools", label: "Toggle browser devtools", detail: "Show or hide devtools for the active browser surface.", category: "Browser", balanced: &["i"], power_user: &["i"] }, - ShortcutBindingSpec { id: "focus_left", label: "Focus left", detail: "Move focus to the column on the left, then fall back to pane focus.", category: "Focus", balanced: &["h", "Left"], power_user: &["h", "Left"] }, - ShortcutBindingSpec { id: "focus_right", label: "Focus right", detail: "Move focus to the column on the right, then fall back to pane focus.", category: "Focus", balanced: &["l", "Right"], power_user: &["l", "Right"] }, - ShortcutBindingSpec { id: "focus_up", label: "Focus up", detail: "Move focus to the stacked window above, then fall back to pane focus.", category: "Focus", balanced: &["k", "Up"], power_user: &["k", "Up"] }, - ShortcutBindingSpec { id: "focus_down", label: "Focus down", detail: "Move focus to the stacked window below, then fall back to pane focus.", category: "Focus", balanced: &["j", "Down"], power_user: &["j", "Down"] }, - ShortcutBindingSpec { id: "new_window_left", label: "New window left", detail: "Create a top-level window in a new column on the left.", category: "Top-level windows", balanced: &[], power_user: &["h", "Left"] }, - ShortcutBindingSpec { id: "new_window_right", label: "New window right", detail: "Create a top-level window in a new column on the right.", category: "Top-level windows", balanced: &["t"], power_user: &["t"] }, - ShortcutBindingSpec { id: "new_window_up", label: "New window up", detail: "Create a stacked top-level window above the active window.", category: "Top-level windows", balanced: &[], power_user: &["k", "Up"] }, - ShortcutBindingSpec { id: "new_window_down", label: "New window down", detail: "Create a stacked top-level window below the active window.", category: "Top-level windows", balanced: &["g"], power_user: &["g"] }, - ShortcutBindingSpec { id: "resize_window_left", label: "Make window narrower", detail: "Reduce the active column width.", category: "Advanced resize", balanced: &[], power_user: &["Home"] }, - ShortcutBindingSpec { id: "resize_window_right", label: "Make window wider", detail: "Increase the active column width.", category: "Advanced resize", balanced: &[], power_user: &["End"] }, - ShortcutBindingSpec { id: "resize_window_up", label: "Make window shorter", detail: "Reduce the active top-level window height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Up"] }, - ShortcutBindingSpec { id: "resize_window_down", label: "Make window taller", detail: "Increase the active top-level window height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Down"] }, - ShortcutBindingSpec { id: "resize_split_left", label: "Make split narrower", detail: "Reduce the active split width.", category: "Advanced resize", balanced: &[], power_user: &["Home"] }, - ShortcutBindingSpec { id: "resize_split_right", label: "Make split wider", detail: "Increase the active split width.", category: "Advanced resize", balanced: &[], power_user: &["End"] }, - ShortcutBindingSpec { id: "resize_split_up", label: "Make split shorter", detail: "Reduce the active split height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Up"] }, - ShortcutBindingSpec { id: "resize_split_down", label: "Make split taller", detail: "Increase the active split height.", category: "Advanced resize", balanced: &[], power_user: &["Page_Down"] }, - ShortcutBindingSpec { id: "split_right", label: "Split right", detail: "Split the active pane to the right inside the current window.", category: "Pane splits", balanced: &["t"], power_user: &["t"] }, - ShortcutBindingSpec { id: "split_down", label: "Split down", detail: "Split the active pane downward inside the current window.", category: "Pane splits", balanced: &["g"], power_user: &["g"] }, + ShortcutBindingSpec { + id: "toggle_overview", + label: "Toggle overview", + detail: "Zoom the current workspace out to fit the full column strip.", + category: "General", + balanced: &["o"], + power_user: &["o"], + }, + ShortcutBindingSpec { + id: "close_terminal", + label: "Close terminal", + detail: "Close the active pane or active top-level window.", + category: "General", + balanced: &["x"], + power_user: &["x"], + }, + ShortcutBindingSpec { + id: "open_browser_split", + label: "Open browser in split", + detail: "Split the active pane to the right and open a browser surface.", + category: "Browser", + balanced: &["l"], + power_user: &["l"], + }, + ShortcutBindingSpec { + id: "focus_browser_address", + label: "Focus browser address bar", + detail: "Focus the address bar for the active browser surface.", + category: "Browser", + balanced: &["l"], + power_user: &["l"], + }, + ShortcutBindingSpec { + id: "reload_browser_page", + label: "Reload browser page", + detail: "Reload the active browser surface.", + category: "Browser", + balanced: &["r"], + power_user: &["r"], + }, + ShortcutBindingSpec { + id: "toggle_browser_devtools", + label: "Toggle browser devtools", + detail: "Show or hide devtools for the active browser surface.", + category: "Browser", + balanced: &["i"], + power_user: &["i"], + }, + ShortcutBindingSpec { + id: "focus_left", + label: "Focus left", + detail: "Move focus to the column on the left, then fall back to pane focus.", + category: "Focus", + balanced: &["h", "Left"], + power_user: &["h", "Left"], + }, + ShortcutBindingSpec { + id: "focus_right", + label: "Focus right", + detail: "Move focus to the column on the right, then fall back to pane focus.", + category: "Focus", + balanced: &["l", "Right"], + power_user: &["l", "Right"], + }, + ShortcutBindingSpec { + id: "focus_up", + label: "Focus up", + detail: "Move focus to the stacked window above, then fall back to pane focus.", + category: "Focus", + balanced: &["k", "Up"], + power_user: &["k", "Up"], + }, + ShortcutBindingSpec { + id: "focus_down", + label: "Focus down", + detail: "Move focus to the stacked window below, then fall back to pane focus.", + category: "Focus", + balanced: &["j", "Down"], + power_user: &["j", "Down"], + }, + ShortcutBindingSpec { + id: "new_window_left", + label: "New window left", + detail: "Create a top-level window in a new column on the left.", + category: "Top-level windows", + balanced: &[], + power_user: &["h", "Left"], + }, + ShortcutBindingSpec { + id: "new_window_right", + label: "New window right", + detail: "Create a top-level window in a new column on the right.", + category: "Top-level windows", + balanced: &["t"], + power_user: &["t"], + }, + ShortcutBindingSpec { + id: "new_window_up", + label: "New window up", + detail: "Create a stacked top-level window above the active window.", + category: "Top-level windows", + balanced: &[], + power_user: &["k", "Up"], + }, + ShortcutBindingSpec { + id: "new_window_down", + label: "New window down", + detail: "Create a stacked top-level window below the active window.", + category: "Top-level windows", + balanced: &["g"], + power_user: &["g"], + }, + ShortcutBindingSpec { + id: "resize_window_left", + label: "Make window narrower", + detail: "Reduce the active column width.", + category: "Advanced resize", + balanced: &[], + power_user: &["Home"], + }, + ShortcutBindingSpec { + id: "resize_window_right", + label: "Make window wider", + detail: "Increase the active column width.", + category: "Advanced resize", + balanced: &[], + power_user: &["End"], + }, + ShortcutBindingSpec { + id: "resize_window_up", + label: "Make window shorter", + detail: "Reduce the active top-level window height.", + category: "Advanced resize", + balanced: &[], + power_user: &["Page_Up"], + }, + ShortcutBindingSpec { + id: "resize_window_down", + label: "Make window taller", + detail: "Increase the active top-level window height.", + category: "Advanced resize", + balanced: &[], + power_user: &["Page_Down"], + }, + ShortcutBindingSpec { + id: "resize_split_left", + label: "Make split narrower", + detail: "Reduce the active split width.", + category: "Advanced resize", + balanced: &[], + power_user: &["Home"], + }, + ShortcutBindingSpec { + id: "resize_split_right", + label: "Make split wider", + detail: "Increase the active split width.", + category: "Advanced resize", + balanced: &[], + power_user: &["End"], + }, + ShortcutBindingSpec { + id: "resize_split_up", + label: "Make split shorter", + detail: "Reduce the active split height.", + category: "Advanced resize", + balanced: &[], + power_user: &["Page_Up"], + }, + ShortcutBindingSpec { + id: "resize_split_down", + label: "Make split taller", + detail: "Increase the active split height.", + category: "Advanced resize", + balanced: &[], + power_user: &["Page_Down"], + }, + ShortcutBindingSpec { + id: "split_right", + label: "Split right", + detail: "Split the active pane to the right inside the current window.", + category: "Pane splits", + balanced: &["t"], + power_user: &["t"], + }, + ShortcutBindingSpec { + id: "split_down", + label: "Split down", + detail: "Split the active pane downward inside the current window.", + category: "Pane splits", + balanced: &["g"], + power_user: &["g"], + }, ]; fn shortcut_bindings(preset: ShortcutPreset) -> Vec { @@ -1934,7 +2200,10 @@ fn workspace_window_placements( } fn workspace_canvas_metrics(placements: &[WorkspaceWindowPlacement]) -> CanvasMetrics { - let frames = placements.iter().map(|placement| placement.frame).collect::>(); + let frames = placements + .iter() + .map(|placement| placement.frame) + .collect::>(); canvas_metrics_from_frames(&frames) } @@ -2038,7 +2307,11 @@ fn distribute_weighted_total(weights: &[i32], total: i32) -> Vec { if weights.is_empty() { return Vec::new(); } - let weight_sum = weights.iter().map(|weight| i64::from(*weight)).sum::().max(1); + let weight_sum = weights + .iter() + .map(|weight| i64::from(*weight)) + .sum::() + .max(1); let mut distributed = Vec::with_capacity(weights.len()); let mut allocated = 0; let mut remainders = Vec::with_capacity(weights.len()); @@ -2163,9 +2436,8 @@ fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Fra PaneKind::Terminal => 0, PaneKind::Browser => 42, }; - frame.inset_top( - metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height, - ) + frame + .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) } fn workspace_preview(summary: &DomainWorkspaceSummary) -> String { @@ -2319,7 +2591,8 @@ fn activity_title(model: &AppModel, item: &ActivityItem) -> String { return title.to_string(); } - model.workspaces + model + .workspaces .get(&item.workspace_id) .and_then(|workspace| workspace.panes.get(&item.pane_id)) .and_then(|pane| { @@ -2480,9 +2753,8 @@ mod tests { use taskers_control::ControlCommand; use super::{ - BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, RuntimeCapability, - RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, - default_preview_app_state, + BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, RuntimeCapability, RuntimeStatus, + SharedCore, ShellAction, ShellSection, SurfaceMountSpec, default_preview_app_state, }; fn bootstrap() -> BootstrapModel { @@ -2562,17 +2834,19 @@ mod tests { let snapshot = core.snapshot(); let pane = match &snapshot.current_workspace.layout { - super::LayoutNodeSnapshot::Split { first, second, .. } => [first.as_ref(), second.as_ref()] - .into_iter() - .find_map(|node| match node { - super::LayoutNodeSnapshot::Pane(pane) => pane - .surfaces - .iter() - .any(|surface| surface.id == browser_surface.surface_id) - .then_some(pane), - _ => None, - }) - .expect("browser pane"), + super::LayoutNodeSnapshot::Split { first, second, .. } => { + [first.as_ref(), second.as_ref()] + .into_iter() + .find_map(|node| match node { + super::LayoutNodeSnapshot::Pane(pane) => pane + .surfaces + .iter() + .any(|surface| surface.id == browser_surface.surface_id) + .then_some(pane), + _ => None, + }) + .expect("browser pane") + } super::LayoutNodeSnapshot::Pane(_) => panic!("expected split layout"), }; @@ -2679,4 +2953,32 @@ mod tests { .any(|workspace| workspace.title == "External") ); } + + #[test] + fn overview_mode_hides_live_portal_surfaces() { + let core = SharedCore::bootstrap(bootstrap()); + assert!(!core.snapshot().portal.panes.is_empty()); + + core.dispatch_shell_action(ShellAction::ToggleOverview); + + let snapshot = core.snapshot(); + assert!(snapshot.overview_mode); + assert!(snapshot.portal.panes.is_empty()); + } + + #[test] + fn horizontal_scroll_host_events_pan_workspace_outside_overview() { + let core = SharedCore::bootstrap(bootstrap()); + let before = core.snapshot().current_workspace.viewport_x; + + core.apply_host_event(HostEvent::ViewportScrolled { dx: 180, dy: 0 }); + let after = core.snapshot().current_workspace.viewport_x; + assert_eq!(after, before + 180); + + core.dispatch_shell_action(ShellAction::ToggleOverview); + core.apply_host_event(HostEvent::ViewportScrolled { dx: 180, dy: 0 }); + + let overview_snapshot = core.snapshot(); + assert_eq!(overview_snapshot.current_workspace.viewport_x, after); + } } diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index edb47ed..6b754ba 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow, bail}; use gtk::{ - EventControllerFocus, Fixed, GestureClick, Overlay, Widget, glib, - prelude::*, + EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, Fixed, GestureClick, + Overlay, Widget, glib, prelude::*, }; use std::{ cell::Cell, @@ -120,9 +120,34 @@ impl TaskersHost { surface_layer.set_can_target(false); root.add_overlay(&surface_layer); + let pan_sink = event_sink.clone(); + let pan_diagnostics = diagnostics.clone(); + let workspace_pan = EventControllerScroll::new(EventControllerScrollFlags::BOTH_AXES); + workspace_pan.set_propagation_phase(gtk::PropagationPhase::Capture); + workspace_pan.connect_scroll(move |_, dx, dy| { + let Some((dx, dy)) = workspace_pan_delta(dx, dy) else { + return glib::Propagation::Proceed; + }; + emit_diagnostic( + pan_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + format!("workspace pan gesture dx={dx} dy={dy}"), + ), + ); + (pan_sink)(HostEvent::ViewportScrolled { dx, dy }); + glib::Propagation::Proceed + }); + root.add_controller(workspace_pan); + emit_diagnostic( diagnostics.as_ref(), - DiagnosticRecord::new(DiagnosticCategory::Window, None, "created GTK4 host overlay"), + DiagnosticRecord::new( + DiagnosticCategory::Window, + None, + "created GTK4 host overlay", + ), ); Self { @@ -165,21 +190,19 @@ impl TaskersHost { HostCommand::BrowserBack { surface_id } => { self.with_browser_surface(surface_id, "browser back", |surface| surface.go_back()) } - HostCommand::BrowserForward { surface_id } => self.with_browser_surface( - surface_id, - "browser forward", - |surface| surface.go_forward(), - ), - HostCommand::BrowserReload { surface_id } => self.with_browser_surface( - surface_id, - "browser reload", - |surface| surface.reload(), - ), - HostCommand::BrowserToggleDevtools { surface_id } => self.with_browser_surface( - surface_id, - "browser devtools toggle", - |surface| surface.toggle_devtools(), - ), + HostCommand::BrowserForward { surface_id } => { + self.with_browser_surface(surface_id, "browser forward", |surface| { + surface.go_forward() + }) + } + HostCommand::BrowserReload { surface_id } => { + self.with_browser_surface(surface_id, "browser reload", |surface| surface.reload()) + } + HostCommand::BrowserToggleDevtools { surface_id } => { + self.with_browser_surface(surface_id, "browser devtools toggle", |surface| { + surface.toggle_devtools() + }) + } } } @@ -205,11 +228,7 @@ impl TaskersHost { Ok(()) } - fn sync_browser_surfaces( - &mut self, - portal: &SurfacePortalPlan, - revision: u64, - ) -> Result<()> { + fn sync_browser_surfaces(&mut self, portal: &SurfacePortalPlan, revision: u64) -> Result<()> { let desired = browser_plans(portal); let desired_ids = desired .iter() @@ -240,9 +259,12 @@ impl TaskersHost { for plan in desired { match self.browser_surfaces.get_mut(&plan.surface_id) { - Some(surface) => { - surface.sync(&self.surface_layer, &plan, revision, self.diagnostics.as_ref())? - } + Some(surface) => surface.sync( + &self.surface_layer, + &plan, + revision, + self.diagnostics.as_ref(), + )?, None => { let surface = BrowserSurface::new( &self.surface_layer, @@ -259,11 +281,7 @@ impl TaskersHost { Ok(()) } - fn sync_terminal_surfaces( - &mut self, - portal: &SurfacePortalPlan, - revision: u64, - ) -> Result<()> { + fn sync_terminal_surfaces(&mut self, portal: &SurfacePortalPlan, revision: u64) -> Result<()> { let desired = terminal_plans(portal); let desired_ids = desired .iter() @@ -770,13 +788,9 @@ fn connect_ghostty_widget( if widget.property::("child-exited") { emit_diagnostic( exit_diagnostics.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::HostEvent, - None, - "terminal child exited", - ) - .with_pane(pane_id) - .with_surface(surface_id), + DiagnosticRecord::new(DiagnosticCategory::HostEvent, None, "terminal child exited") + .with_pane(pane_id) + .with_surface(surface_id), ); (exit_sink)(HostEvent::SurfaceClosed { pane_id, @@ -860,6 +874,16 @@ fn detach_from_fixed(fixed: &Fixed, widget: &Widget) { } } +fn workspace_pan_delta(dx: f64, dy: f64) -> Option<(i32, i32)> { + if !dx.is_finite() || !dy.is_finite() { + return None; + } + if dx.abs() < 1.0 || dx.abs() < dy.abs() { + return None; + } + Some((dx.round() as i32, 0)) +} + pub fn browser_plans(portal: &SurfacePortalPlan) -> Vec { portal .panes @@ -921,7 +945,7 @@ fn current_timestamp_ms() -> u128 { #[cfg(test)] mod tests { - use super::{browser_plans, terminal_plans}; + use super::{browser_plans, terminal_plans, workspace_pan_delta}; use taskers_core::{BootstrapModel, SharedCore, SurfaceMountSpec}; #[test] @@ -937,4 +961,12 @@ mod tests { assert!(matches!(browsers[0].mount, SurfaceMountSpec::Browser(_))); assert!(matches!(terminals[0].mount, SurfaceMountSpec::Terminal(_))); } + + #[test] + fn workspace_pan_delta_prefers_deliberate_horizontal_motion() { + assert_eq!(workspace_pan_delta(64.4, 4.0), Some((64, 0))); + assert_eq!(workspace_pan_delta(0.4, 0.0), None); + assert_eq!(workspace_pan_delta(6.0, 18.0), None); + assert_eq!(workspace_pan_delta(f64::NAN, 0.0), None); + } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 59e1717..7faefdf 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -10,7 +10,9 @@ use taskers_core::{ }; fn app_css(snapshot: &ShellSnapshot) -> String { - theme::generate_css(&theme::resolve_palette(&snapshot.settings.selected_theme_id)) + theme::generate_css(&theme::resolve_palette( + &snapshot.settings.selected_theme_id, + )) } #[component] @@ -38,27 +40,35 @@ pub fn TaskersShell(core: SharedCore) -> Element { let stylesheet = app_css(&snapshot); let show_workspace_nav = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Workspace, - }) + move |_| { + core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Workspace, + }) + } }; let show_workspace_header = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Workspace, - }) + move |_| { + core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Workspace, + }) + } }; let show_settings_nav = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Settings, - }) + move |_| { + core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Settings, + }) + } }; let show_settings_header = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Settings, - }) + move |_| { + core.dispatch_shell_action(ShellAction::ShowSection { + section: ShellSection::Settings, + }) + } }; let create_workspace = { let core = core.clone(); @@ -184,8 +194,10 @@ pub fn TaskersShell(core: SharedCore) -> Element { onclick: toggle_overview, "◫" } - button { class: "workspace-header-action", onclick: scroll_left, "◀" } - button { class: "workspace-header-action", onclick: scroll_right, "▶" } + if !snapshot.overview_mode { + button { class: "workspace-header-action", onclick: scroll_left, "◀" } + button { class: "workspace-header-action", onclick: scroll_right, "▶" } + } } div { class: "workspace-header-divider" } div { class: "workspace-header-group", @@ -281,7 +293,10 @@ fn render_workspace_item( workspace.attention.slug() ) } else { - format!("workspace-tab workspace-tab-state-{}", workspace.attention.slug()) + format!( + "workspace-tab workspace-tab-state-{}", + workspace.attention.slug() + ) }; let has_badge = workspace.unread_activity > 0 || workspace.waiting_agent_count > 0; let badge_text = if workspace.unread_activity > 0 { @@ -526,6 +541,11 @@ fn render_workspace_strip( core: SharedCore, runtime_status: &RuntimeStatus, ) -> Element { + let viewport_class = if workspace.overview_scale < 1.0 { + "workspace-viewport workspace-viewport-overview" + } else { + "workspace-viewport" + }; let scroll_viewport = { let core = core.clone(); let overview_scale = workspace.overview_scale; @@ -543,23 +563,13 @@ fn render_workspace_strip( core.dispatch_shell_action(ShellAction::ScrollViewport { dx, dy }); } }; - let translate_x = if workspace.overview_scale < 1.0 { - 0 - } else { - -workspace.viewport_x - }; - let translate_y = if workspace.overview_scale < 1.0 { - 0 - } else { - -workspace.viewport_y - }; let canvas_style = format!( - "width:{}px;height:{}px;transform:translate({}px, {}px);", - workspace.canvas_width, workspace.canvas_height, translate_x, translate_y + "width:{}px;height:{}px;", + workspace.canvas_width, workspace.canvas_height ); rsx! { - div { class: "workspace-viewport", onwheel: scroll_viewport, + div { class: "{viewport_class}", onwheel: scroll_viewport, div { class: "workspace-strip-canvas", style: "{canvas_style}", for column in &workspace.columns { for window in &column.windows { @@ -632,7 +642,10 @@ fn render_pane( runtime_status: &RuntimeStatus, ) -> Element { let pane_class = if pane.active { - format!("pane-card pane-card-active pane-card-state-{}", pane.attention.slug()) + format!( + "pane-card pane-card-active pane-card-state-{}", + pane.attention.slug() + ) } else { format!("pane-card pane-card-state-{}", pane.attention.slug()) }; @@ -640,7 +653,11 @@ fn render_pane( .surfaces .iter() .find(|surface| surface.id == pane.active_surface) - .unwrap_or_else(|| pane.surfaces.first().expect("pane snapshot should contain surfaces")); + .unwrap_or_else(|| { + pane.surfaces + .first() + .expect("pane snapshot should contain surfaces") + }); let subtitle = match active_surface.kind { SurfaceKind::Terminal => active_surface .cwd @@ -674,34 +691,44 @@ fn render_pane( }; let add_browser_surface = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::AddBrowserSurface { - pane_id: Some(pane_id), - }) + move |_| { + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(pane_id), + }) + } }; let add_terminal_surface = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::AddTerminalSurface { - pane_id: Some(pane_id), - }) + move |_| { + core.dispatch_shell_action(ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + }) + } }; let split_browser = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { - pane_id: Some(pane_id), - }) + move |_| { + core.dispatch_shell_action(ShellAction::SplitBrowser { + pane_id: Some(pane_id), + }) + } }; let split_terminal = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { - pane_id: Some(pane_id), - }) + move |_| { + core.dispatch_shell_action(ShellAction::SplitTerminal { + pane_id: Some(pane_id), + }) + } }; let close_surface = { let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::CloseSurface { - pane_id, - surface_id: active_surface_id, - }) + move |_| { + core.dispatch_shell_action(ShellAction::CloseSurface { + pane_id, + surface_id: active_surface_id, + }) + } }; let flash_key = pane.focus_flash_token; @@ -766,7 +793,10 @@ fn render_surface_tab( }; let surface_id = surface.id; let focus_surface = move |_| { - core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + core.dispatch_shell_action(ShellAction::FocusSurface { + pane_id, + surface_id, + }); }; rsx! { @@ -835,9 +865,8 @@ fn BrowserToolbar( let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::BrowserReload { surface_id }) }; - let toggle_devtools = move |_| { - core.dispatch_shell_action(ShellAction::ToggleBrowserDevtools { surface_id }) - }; + let toggle_devtools = + move |_| core.dispatch_shell_action(ShellAction::ToggleBrowserDevtools { surface_id }); rsx! { form { class: "browser-toolbar", onsubmit: navigate, @@ -874,13 +903,13 @@ fn BrowserToolbar( } fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { - let badge_class = format!("status-pill status-pill-inline status-pill-{}", surface.attention.slug()); + let badge_class = format!( + "status-pill status-pill-inline status-pill-{}", + surface.attention.slug() + ); match surface.kind { SurfaceKind::Browser => { - let url = surface - .url - .clone() - .unwrap_or_else(|| "about:blank".into()); + let url = surface.url.clone().unwrap_or_else(|| "about:blank".into()); rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", @@ -935,7 +964,10 @@ fn render_agent_item( if workspace_id != current_workspace_id { core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); } - core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + core.dispatch_shell_action(ShellAction::FocusSurface { + pane_id, + surface_id, + }); }; rsx! { @@ -951,7 +983,6 @@ fn render_agent_item( } } - fn render_notification_row( item: &ActivityItemSnapshot, core: SharedCore, @@ -980,7 +1011,10 @@ fn render_notification_row( if workspace_id != current_workspace_id { core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); } else if let (Some(pane_id), Some(surface_id)) = (pane_id, surface_id) { - core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id }); + core.dispatch_shell_action(ShellAction::FocusSurface { + pane_id, + surface_id, + }); } else if let Some(pane_id) = pane_id { core.dispatch_shell_action(ShellAction::FocusPane { pane_id }); } else { @@ -1056,10 +1090,7 @@ fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { } } -fn render_theme_option( - option: &taskers_core::ThemeOptionSnapshot, - core: SharedCore, -) -> Element { +fn render_theme_option(option: &taskers_core::ThemeOptionSnapshot, core: SharedCore) -> Element { let option_id = option.id.clone(); let select = move |_| { core.dispatch_shell_action(ShellAction::SelectTheme { @@ -1102,10 +1133,7 @@ fn render_shortcut_preset( } } -fn render_shortcut_group( - category: &'static str, - bindings: &[ShortcutBindingSnapshot], -) -> Element { +fn render_shortcut_group(category: &'static str, bindings: &[ShortcutBindingSnapshot]) -> Element { let entries = bindings .iter() .filter(|binding| binding.category == category) diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 40310a2..2a50d92 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -641,7 +641,9 @@ button {{ }} .workspace-main-overview .workspace-canvas {{ - background: {border_03}; + background: + radial-gradient(circle at top left, {accent_08} 0%, transparent 32%), + linear-gradient(180deg, {border_04} 0%, {base} 100%); }} .workspace-header {{ @@ -789,12 +791,24 @@ button {{ overflow: hidden; }} +.workspace-viewport-overview {{ + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +}} + .workspace-strip-canvas {{ position: absolute; inset: 0 auto auto 0; transform-origin: top left; }} +.workspace-viewport-overview .workspace-strip-canvas {{ + position: relative; + inset: auto; +}} + .workspace-window-shell {{ position: absolute; display: flex; @@ -971,6 +985,18 @@ button {{ font-size: 11px; }} +.pane-action-cluster {{ + opacity: 0; + pointer-events: none; + transition: opacity 0.14s ease-in-out; +}} + +.pane-card:hover .pane-action-cluster, +.pane-card-active .pane-action-cluster {{ + opacity: 1; + pointer-events: auto; +}} + .pane-action-tab {{ border-color: {accent_20}; }} @@ -1114,6 +1140,60 @@ button {{ {overlay_03}; }} +.workspace-main-overview .workspace-window-shell {{ + box-shadow: 0 18px 42px {overlay_16}; +}} + +.workspace-main-overview .workspace-window-toolbar {{ + min-height: 32px; + padding: 0 8px; + background: {surface_85}; +}} + +.workspace-main-overview .workspace-window-body {{ + padding: 8px; + background: {border_03}; +}} + +.workspace-main-overview .workspace-window-flags, +.workspace-main-overview .pane-action-cluster, +.workspace-main-overview .surface-tabs, +.workspace-main-overview .browser-toolbar, +.workspace-main-overview .surface-backdrop-note, +.workspace-main-overview .surface-chip {{ + display: none; +}} + +.workspace-main-overview .split-container {{ + gap: 8px; +}} + +.workspace-main-overview .pane-header {{ + min-height: 30px; + padding: 0 8px; +}} + +.workspace-main-overview .pane-title {{ + font-size: 11px; +}} + +.workspace-main-overview .pane-meta {{ + font-size: 10px; +}} + +.workspace-main-overview .pane-body {{ + padding: 10px; +}} + +.workspace-main-overview .surface-backdrop {{ + gap: 8px; + padding: 10px; + border-style: solid; + background: + linear-gradient(180deg, {overlay_16} 0%, {overlay_03} 100%), + {border_02}; +}} + .surface-backdrop-copy {{ display: flex; flex-direction: column; @@ -1506,31 +1586,23 @@ button {{ busy_10 = rgba(p.busy, 0.10), busy_12 = rgba(p.busy, 0.12), busy_16 = rgba(p.busy, 0.16), - - busy_text = p.busy_text.to_hex(), completed = p.completed.to_hex(), completed_10 = rgba(p.completed, 0.10), completed_12 = rgba(p.completed, 0.12), completed_16 = rgba(p.completed, 0.16), - - completed_text = p.completed_text.to_hex(), waiting = p.waiting.to_hex(), waiting_10 = rgba(p.waiting, 0.10), waiting_12 = rgba(p.waiting, 0.12), waiting_14 = rgba(p.waiting, 0.14), waiting_18 = rgba(p.waiting, 0.18), - - waiting_text = p.waiting_text.to_hex(), error = p.error.to_hex(), error_10 = rgba(p.error, 0.10), error_12 = rgba(p.error, 0.12), error_16 = rgba(p.error, 0.16), error_18 = rgba(p.error, 0.18), - - error_text = p.error_text.to_hex(), action_window_22 = rgba(p.action_window, 0.22), action_split_22 = rgba(p.action_split, 0.22), From 268074291d318fa36bfd9cfa248f70e8ed23f15f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 13:43:30 +0100 Subject: [PATCH 37/63] fix: align greenfield pane UX with cmux --- greenfield/crates/taskers-core/src/lib.rs | 56 ++++- greenfield/crates/taskers-shell/src/lib.rs | 106 +++------ greenfield/crates/taskers-shell/src/theme.rs | 231 +++++++++---------- 3 files changed, 186 insertions(+), 207 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index e86dd7f..2257e36 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -287,6 +287,16 @@ impl Frame { height: (self.height - clamped).max(1), } } + + pub fn inset(self, amount: i32) -> Self { + let clamped = amount.clamp(0, self.width.min(self.height).saturating_sub(1) / 2); + Self { + x: self.x + clamped, + y: self.y + clamped, + width: (self.width - clamped * 2).max(1), + height: (self.height - clamped * 2).max(1), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -295,6 +305,8 @@ pub struct LayoutMetrics { pub activity_width: i32, pub toolbar_height: i32, pub workspace_padding: i32, + pub window_toolbar_height: i32, + pub window_body_padding: i32, pub split_gap: i32, pub pane_header_height: i32, pub surface_tab_height: i32, @@ -307,9 +319,11 @@ impl Default for LayoutMetrics { activity_width: 312, toolbar_height: 48, workspace_padding: 16, + window_toolbar_height: 34, + window_body_padding: 0, split_gap: 12, - pane_header_height: 38, - surface_tab_height: 34, + pane_header_height: 34, + surface_tab_height: 0, } } } @@ -1217,7 +1231,12 @@ impl TaskersCore { .values() .filter_map(|window| { let (_, frame) = window_frames.get(&window.id)?; - Some(self.collect_surface_plans(workspace_id, workspace, &window.layout, *frame)) + Some(self.collect_surface_plans( + workspace_id, + workspace, + &window.layout, + workspace_window_content_frame(*frame, self.metrics), + )) }) .flatten() .collect() @@ -2434,12 +2453,18 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio: u16, gap: i32) -> (Frame, F fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Frame { let browser_toolbar_height = match kind { PaneKind::Terminal => 0, - PaneKind::Browser => 42, + PaneKind::Browser => 38, }; frame .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) } +fn workspace_window_content_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { + frame + .inset_top(metrics.window_toolbar_height) + .inset(metrics.window_body_padding) +} + fn workspace_preview(summary: &DomainWorkspaceSummary) -> String { if let Some(notification) = summary.latest_notification.as_deref() { return compact_preview(notification); @@ -2753,8 +2778,9 @@ mod tests { use taskers_control::ControlCommand; use super::{ - BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, RuntimeCapability, RuntimeStatus, - SharedCore, ShellAction, ShellSection, SurfaceMountSpec, default_preview_app_state, + BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, + RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, + default_preview_app_state, }; fn bootstrap() -> BootstrapModel { @@ -2794,6 +2820,24 @@ mod tests { assert_eq!(terminal_count, 1); } + #[test] + fn portal_surface_frames_start_below_window_toolbar() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let metrics = LayoutMetrics::default(); + let min_content_y = + snapshot.portal.content.y + metrics.window_toolbar_height + metrics.pane_header_height; + + assert!( + snapshot + .portal + .panes + .iter() + .all(|plan| plan.frame.y >= min_content_y), + "expected native surfaces to stay below window chrome" + ); + } + #[test] fn split_browser_creates_real_browser_pane() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 7faefdf..f4477ec 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -3,8 +3,8 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, - LayoutNodeSnapshot, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeCapability, - RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, + LayoutNodeSnapshot, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, + SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceDirection, WorkspaceId, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, }; @@ -131,9 +131,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "app-shell", aside { class: "workspace-sidebar", div { class: "sidebar-brand", - div { class: "sidebar-heading", "Taskers" } h1 { "Taskers" } - div { class: "workspace-preview", "Shared Dioxus shell over native browser and terminal hosts." } } div { class: "sidebar-nav", button { @@ -162,12 +160,6 @@ pub fn TaskersShell(core: SharedCore) -> Element { )} } } - div { class: "runtime-card", - div { class: "sidebar-heading", "Runtime status" } - {render_runtime_capability("Ghostty runtime", &snapshot.runtime_status.ghostty_runtime)} - {render_runtime_capability("Shell integration", &snapshot.runtime_status.shell_integration)} - {render_runtime_capability("Terminal host", &snapshot.runtime_status.terminal_host)} - } } main { class: "{main_class}", @@ -262,9 +254,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "notification-timeline", if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { div { class: "notification-empty", - div { class: "notification-empty-icon", "◎" } div { class: "notification-empty-title", "No notifications" } - div { class: "notification-empty-subtitle", "Activity from agents and surfaces appears here." } } } else { for item in &snapshot.activity { @@ -470,7 +460,6 @@ fn render_workspace_pull_requests(pull_requests: &[PullRequestSnapshot]) -> Elem rsx! { for pr in pull_requests { div { class: "workspace-pr-row", - span { class: "workspace-pr-icon workspace-pr-status-{pr.status}", "{pr.status_icon}" } span { class: "workspace-pr-number", "#{pr.number}" } span { class: "workspace-pr-title", "{pr.title}" } } @@ -478,26 +467,6 @@ fn render_workspace_pull_requests(pull_requests: &[PullRequestSnapshot]) -> Elem } } -fn render_runtime_capability(label: &'static str, capability: &RuntimeCapability) -> Element { - let class = match capability { - RuntimeCapability::Ready => "status-pill status-pill-ready", - RuntimeCapability::Fallback { .. } => "status-pill status-pill-fallback", - RuntimeCapability::Unavailable { .. } => "status-pill status-pill-unavailable", - }; - - rsx! { - div { class: "runtime-row", - div { class: "runtime-status-row", - span { class: "workspace-preview", "{label}" } - span { class: "{class}", "{capability.label()}" } - } - if let Some(message) = capability.message() { - div { class: "status-copy", "{message}" } - } - } - } -} - fn render_layout( node: &LayoutNodeSnapshot, browser_chrome: Option<&BrowserChromeSnapshot>, @@ -624,9 +593,6 @@ fn render_workspace_window( span { class: "workspace-label", "{window.title}" } span { class: "workspace-meta", "{window.pane_count} panes · {window.surface_count} surfaces" } } - div { class: "workspace-window-flags", - span { class: format!("status-pill status-pill-inline status-pill-{}", window.attention.slug()), "{window.attention.label()}" } - } } div { class: "workspace-window-body", {render_layout(&window.layout, browser_chrome, core.clone(), runtime_status)} @@ -658,17 +624,6 @@ fn render_pane( .first() .expect("pane snapshot should contain surfaces") }); - let subtitle = match active_surface.kind { - SurfaceKind::Terminal => active_surface - .cwd - .clone() - .unwrap_or_else(|| "Embedded terminal".into()), - SurfaceKind::Browser => active_surface - .url - .clone() - .unwrap_or_else(|| "Native browser surface".into()), - }; - let status_class = format!("status-dot status-dot-{}", active_surface.attention.slug()); let pane_id = pane.id; let active_surface_id = active_surface.id; let active_browser_chrome = browser_chrome @@ -737,28 +692,26 @@ fn render_pane( } else { "pane-flash-ring" }; + let close_label = if pane.surfaces.len() > 1 { + "Close current tab" + } else { + "Close current surface" + }; rsx! { section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-header", - div { class: "pane-header-main", - span { class: "{status_class}", "●" } - div { class: "pane-title-stack", - div { class: "pane-title", "{active_surface.title}" } - div { class: "pane-meta", "{subtitle}" } + div { class: "surface-tabs", + for surface in &pane.surfaces { + {render_surface_tab(pane.id, pane.active_surface, surface, core.clone())} } } div { class: "pane-action-cluster", - button { class: "pane-action pane-action-tab", onclick: add_browser_surface, "+ tab" } - button { class: "pane-action pane-action-tab", onclick: add_terminal_surface, "+ term" } - button { class: "pane-action pane-window-action", onclick: split_browser, "+ web" } - button { class: "pane-action pane-split-action", onclick: split_terminal, "+ split" } - button { class: "pane-action pane-close-action", onclick: close_surface, "×" } - } - } - div { class: "surface-tabs", - for surface in &pane.surfaces { - {render_surface_tab(pane.id, pane.active_surface, surface, core.clone())} + button { class: "pane-utility pane-utility-tab", title: "New terminal tab", onclick: add_terminal_surface, "+t" } + button { class: "pane-utility pane-utility-tab", title: "New browser tab", onclick: add_browser_surface, "+w" } + button { class: "pane-utility pane-utility-split", title: "Split terminal right", onclick: split_terminal, "|t" } + button { class: "pane-utility pane-utility-window", title: "Split browser right", onclick: split_browser, "|w" } + button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, "x" } } } if matches!(active_surface.kind, SurfaceKind::Browser) { @@ -783,6 +736,10 @@ fn render_surface_tab( surface: &SurfaceSnapshot, core: SharedCore, ) -> Element { + let kind_label = match surface.kind { + SurfaceKind::Terminal => "term", + SurfaceKind::Browser => "web", + }; let tab_class = if surface.id == active_surface_id { format!( "surface-tab surface-tab-active surface-tab-state-{}", @@ -801,8 +758,8 @@ fn render_surface_tab( rsx! { button { class: "{tab_class}", onclick: focus_surface, - span { class: "surface-tab-label", "{surface.kind.label()}" } - span { class: "pane-meta", "{surface.title}" } + span { class: "surface-tab-label", "{kind_label}" } + span { class: "surface-tab-title", "{surface.title}" } } } } @@ -903,43 +860,32 @@ fn BrowserToolbar( } fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { - let badge_class = format!( - "status-pill status-pill-inline status-pill-{}", - surface.attention.slug() - ); match surface.kind { SurfaceKind::Browser => { let url = surface.url.clone().unwrap_or_else(|| "about:blank".into()); rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "Browser surface" } + div { class: "surface-backdrop-eyebrow", "browser" } div { class: "surface-backdrop-title", "{surface.title}" } - div { class: "surface-backdrop-note", - "The platform host mounts a native browser view here while the shared shell keeps tabs, workspace chrome, settings, and activity state consistent." - } } div { class: "surface-meta", - span { class: "{badge_class}", "{surface.attention.label()}" } span { class: "surface-chip", "URL: {url}" } } } } } SurfaceKind::Terminal => { - let host_message = runtime_status - .terminal_host - .message() - .unwrap_or("Embedded terminal hosting is ready."); rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "Terminal surface" } + div { class: "surface-backdrop-eyebrow", "terminal" } div { class: "surface-backdrop-title", "{surface.title}" } - div { class: "surface-backdrop-note", "{host_message}" } + if let Some(message) = runtime_status.terminal_host.message() { + div { class: "surface-backdrop-note", "{message}" } + } } div { class: "surface-meta", - span { class: "{badge_class}", "{surface.attention.label()}" } if let Some(cwd) = &surface.cwd { span { class: "surface-chip", "cwd: {cwd}" } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 2a50d92..8e1c2d3 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -203,8 +203,6 @@ button {{ .workspace-sidebar, .attention-panel {{ background: {surface_85}; - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); display: flex; flex-direction: column; min-height: 0; @@ -272,7 +270,6 @@ button {{ }} .sidebar-nav-button {{ - border-radius: 7px; padding: 8px 10px; color: {text_subtle}; }} @@ -295,7 +292,6 @@ button {{ background: transparent; color: {text_dim}; border: 1px solid {border_10}; - border-radius: 999px; min-width: 24px; min-height: 24px; padding: 0; @@ -319,7 +315,6 @@ button {{ .workspace-tab {{ position: relative; padding: 8px 10px 8px 14px; - border-radius: 6px; border: 1px solid transparent; display: flex; align-items: stretch; @@ -359,10 +354,9 @@ button {{ .workspace-tab-rail {{ position: absolute; left: 0; - top: 5px; - bottom: 5px; - width: 3px; - border-radius: 1.5px; + top: 0; + bottom: 0; + width: 2px; background: var(--workspace-accent, {accent}); }} @@ -402,7 +396,6 @@ button {{ width: 16px; height: 16px; border: 0; - border-radius: 4px; background: transparent; color: {text_dim}; font-size: 13px; @@ -428,7 +421,6 @@ button {{ flex: 0 0 auto; width: 16px; height: 16px; - border-radius: 999px; display: flex; align-items: center; justify-content: center; @@ -484,13 +476,11 @@ button {{ .workspace-progress-track {{ flex: 1; height: 3px; - border-radius: 1.5px; background: {border_08}; }} .workspace-progress-fill {{ height: 100%; - border-radius: 1.5px; background: var(--workspace-accent, {accent}); transition: width 0.3s ease; }} @@ -564,7 +554,6 @@ button {{ .settings-card {{ background: transparent; border: 1px solid {border_06}; - border-radius: 9px; padding: 10px; display: flex; flex-direction: column; @@ -588,7 +577,6 @@ button {{ .status-pill {{ display: inline-flex; align-items: center; - border-radius: 999px; padding: 4px 8px; font-size: 10px; font-weight: 700; @@ -663,7 +651,6 @@ button {{ align-items: center; gap: 2px; background: {border_04}; - border-radius: 8px; padding: 2px; }} @@ -701,7 +688,6 @@ button {{ .workspace-header-title-btn {{ background: transparent; border: 0; - border-radius: 7px; color: inherit; padding: 6px 8px; text-align: left; @@ -729,7 +715,6 @@ button {{ .activity-action, .shortcut-pill {{ border: 1px solid {border_10}; - border-radius: 999px; background: transparent; }} @@ -813,39 +798,36 @@ button {{ position: absolute; display: flex; flex-direction: column; - border-radius: 10px; background: {elevated}; border: 1px solid {border_08}; overflow: hidden; - box-shadow: 0 18px 42px {overlay_16}; }} .workspace-window-shell-active {{ border-color: {accent_24}; - box-shadow: 0 18px 42px {overlay_16}, 0 0 0 1px {accent_24}; }} .workspace-window-shell-state-busy {{ - box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {busy_10}; + border-color: {busy_12}; }} .workspace-window-shell-state-completed {{ - box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {completed_10}; + border-color: {completed_12}; }} .workspace-window-shell-state-waiting {{ - box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {waiting_10}; + border-color: {waiting_14}; }} .workspace-window-shell-state-error {{ - box-shadow: 0 18px 42px {overlay_16}, inset 0 0 0 1px {error_10}; + border-color: {error_12}; }} .workspace-window-toolbar {{ - min-height: 38px; + min-height: 34px; border-bottom: 1px solid {border_07}; background: {surface}; - padding: 0 10px; + padding: 0 8px; display: flex; align-items: center; justify-content: space-between; @@ -877,7 +859,7 @@ button {{ .workspace-window-body {{ flex: 1; min-height: 0; - padding: 10px; + padding: 0; background: {border_02}; }} @@ -905,7 +887,6 @@ button {{ flex-direction: column; background: {elevated}; border: 1px solid {border_07}; - border-radius: 8px; overflow: hidden; }} @@ -939,10 +920,8 @@ button {{ .pane-flash-ring {{ position: absolute; - inset: 6px; - border-radius: 10px; - border: 3px solid {accent}; - box-shadow: 0 0 12px {accent_20}; + inset: 0; + border: 1px solid {accent}; pointer-events: none; opacity: 0; z-index: 10; @@ -953,29 +932,14 @@ button {{ }} .pane-header {{ - min-height: 38px; + min-height: 34px; border-bottom: 1px solid {border_07}; - padding: 0 10px; + padding: 0 6px 0 8px; display: flex; align-items: center; justify-content: space-between; - gap: 8px; -}} - -.pane-title-stack {{ - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -}} - -.pane-title {{ - color: {text_bright}; - font-size: 13px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + gap: 6px; + background: {surface}; }} .pane-meta, @@ -986,57 +950,42 @@ button {{ }} .pane-action-cluster {{ + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 3px; opacity: 0; pointer-events: none; transition: opacity 0.14s ease-in-out; }} -.pane-card:hover .pane-action-cluster, .pane-card-active .pane-action-cluster {{ opacity: 1; pointer-events: auto; }} -.pane-action-tab {{ - border-color: {accent_20}; -}} - -.pane-window-action {{ - border-color: {action_window_22}; -}} - -.pane-split-action {{ - border-color: {action_split_22}; -}} - -.pane-close-action {{ - border-color: {error_18}; -}} - -.pane-close-action:hover {{ - background: {error_10}; - color: {error}; -}} - .surface-tabs {{ + flex: 1; + min-width: 0; min-height: 34px; - border-bottom: 1px solid {border_06}; display: flex; - align-items: stretch; - gap: 6px; - padding: 6px 8px; + align-items: center; + gap: 2px; + padding: 0; overflow-x: auto; - background: {border_03}; + background: transparent; }} .surface-tab {{ display: inline-flex; align-items: center; gap: 6px; + min-width: 0; + max-width: 320px; + height: 26px; border: 1px solid transparent; - border-radius: 999px; background: transparent; - padding: 5px 9px; + padding: 0 8px; color: {text_muted}; white-space: nowrap; }} @@ -1047,8 +996,8 @@ button {{ }} .surface-tab-active {{ - background: {accent_14}; - border-color: {accent_24}; + background: {overlay_16}; + border-color: {accent_20}; color: {text_bright}; }} @@ -1068,20 +1017,77 @@ button {{ box-shadow: inset 0 0 0 1px {error_10}; }} +.surface-tab-title {{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: {text_subtle}; + font-size: 12px; +}} + +.surface-tab-active .surface-tab-title {{ + color: {text_bright}; +}} + +.surface-tab-label {{ + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 9px; + color: {text_dim}; +}} + +.pane-utility {{ + min-width: 24px; + height: 22px; + border: 0; + padding: 0 6px; + background: transparent; + color: {text_dim}; + font-size: 11px; + font-family: "IBM Plex Mono", ui-monospace, monospace; + line-height: 1; +}} + +.pane-utility:hover {{ + background: {border_06}; + color: {text_bright}; +}} + +.pane-utility-tab {{ + color: {text_subtle}; +}} + +.pane-utility-split {{ + color: {completed}; +}} + +.pane-utility-window {{ + color: {action_window}; +}} + +.pane-utility-close {{ + color: {error_text}; +}} + +.pane-utility-close:hover {{ + background: {error_10}; + color: {error}; +}} + .browser-toolbar {{ - min-height: 42px; + min-height: 34px; border-bottom: 1px solid {border_06}; display: flex; align-items: center; - gap: 8px; - padding: 6px 10px; - background: {overlay_05}; + gap: 6px; + padding: 4px 6px; + background: {surface}; }} .browser-toolbar-button {{ - min-width: 34px; - height: 28px; - border-radius: 8px; + min-width: 30px; + height: 26px; border: 1px solid {border_10}; background: {overlay_05}; color: {text_subtle}; @@ -1102,8 +1108,7 @@ button {{ .browser-address {{ flex: 1; min-width: 0; - height: 28px; - border-radius: 8px; + height: 26px; border: 1px solid {border_10}; padding: 0 10px; background: {overlay_05}; @@ -1120,7 +1125,7 @@ button {{ .pane-body {{ flex: 1; min-height: 0; - padding: 18px; + padding: 0; background: {border_02}; }} @@ -1131,17 +1136,14 @@ button {{ display: flex; flex-direction: column; justify-content: space-between; - gap: 14px; - border: 1px dashed {border_12}; - border-radius: 8px; - padding: 18px; - background: - linear-gradient(180deg, {overlay_16} 0%, {overlay_05} 100%), - {overlay_03}; + gap: 12px; + border: 1px dashed {border_10}; + padding: 12px; + background: {overlay_03}; }} .workspace-main-overview .workspace-window-shell {{ - box-shadow: 0 18px 42px {overlay_16}; + box-shadow: none; }} .workspace-main-overview .workspace-window-toolbar {{ @@ -1157,7 +1159,6 @@ button {{ .workspace-main-overview .workspace-window-flags, .workspace-main-overview .pane-action-cluster, -.workspace-main-overview .surface-tabs, .workspace-main-overview .browser-toolbar, .workspace-main-overview .surface-backdrop-note, .workspace-main-overview .surface-chip {{ @@ -1173,11 +1174,14 @@ button {{ padding: 0 8px; }} -.workspace-main-overview .pane-title {{ - font-size: 11px; +.workspace-main-overview .surface-tab {{ + height: 22px; + max-width: 180px; + padding: 0 6px; }} -.workspace-main-overview .pane-meta {{ +.workspace-main-overview .surface-tab-title, +.workspace-main-overview .surface-tab-label {{ font-size: 10px; }} @@ -1262,14 +1266,12 @@ button {{ .empty-state {{ border: 1px dashed {border_10}; - border-radius: 8px; padding: 12px; color: {text_dim}; font-size: 12px; }} .activity-item {{ - border-radius: 8px; padding: 8px 10px; display: flex; flex-direction: column; @@ -1305,7 +1307,6 @@ button {{ font-weight: 600; color: {text_dim}; padding: 2px 6px; - border-radius: 999px; background: {border_06}; }} @@ -1342,7 +1343,6 @@ button {{ align-items: flex-start; gap: 10px; padding: 10px; - border-radius: 8px; background: {border_03}; transition: background 0.14s ease-in-out; }} @@ -1355,7 +1355,6 @@ button {{ flex: 0 0 auto; width: 8px; height: 8px; - border-radius: 999px; margin-top: 4px; }} @@ -1426,7 +1425,6 @@ button {{ width: 16px; height: 16px; border: 0; - border-radius: 4px; background: transparent; color: {text_dim}; font-size: 13px; @@ -1453,12 +1451,6 @@ button {{ padding: 32px 16px; }} -.notification-empty-icon {{ - font-size: 28px; - color: {text_dim}; - opacity: 0.5; -}} - .notification-empty-title {{ font-weight: 600; font-size: 13px; @@ -1492,7 +1484,6 @@ button {{ .theme-card, .preset-card {{ border: 1px solid {border_08}; - border-radius: 8px; padding: 10px; }} @@ -1602,10 +1593,8 @@ button {{ error_10 = rgba(p.error, 0.10), error_12 = rgba(p.error, 0.12), error_16 = rgba(p.error, 0.16), - error_18 = rgba(p.error, 0.18), error_text = p.error_text.to_hex(), - action_window_22 = rgba(p.action_window, 0.22), - action_split_22 = rgba(p.action_split, 0.22), + action_window = p.action_window.to_hex(), ); css } From 42252ed5dfb5f1890b4ffc215f0e42ec3121106f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 14:02:15 +0100 Subject: [PATCH 38/63] fix: improve greenfield tab and navigation UX --- crates/taskers-control/src/controller.rs | 21 ++ crates/taskers-control/src/protocol.rs | 7 + crates/taskers-domain/src/model.rs | 230 ++++++++++++++-- greenfield/crates/taskers-core/src/lib.rs | 153 ++++++++++- greenfield/crates/taskers-shell/src/lib.rs | 267 +++++++++++++++++-- greenfield/crates/taskers-shell/src/theme.rs | 13 + 6 files changed, 648 insertions(+), 43 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index fff2f02..9f4ad3e 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -283,6 +283,27 @@ impl InMemoryController { true, ) } + ControlCommand::TransferSurface { + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + to_index, + } => { + model.transfer_surface( + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + to_index, + )?; + ( + ControlResponse::Ack { + message: "surface transferred".into(), + }, + true, + ) + } ControlCommand::SetWorkspaceViewport { workspace_id, viewport, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 8bba508..3086f68 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -102,6 +102,13 @@ pub enum ControlCommand { surface_id: SurfaceId, to_index: usize, }, + TransferSurface { + workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + to_index: usize, + }, SetWorkspaceViewport { workspace_id: WorkspaceId, viewport: WorkspaceViewport, diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 1f219d9..1d1ab2e 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -1578,15 +1578,12 @@ impl AppModel { surface_id, })?; - let notification_title = event - .metadata - .as_ref() - .and_then(|metadata| { - metadata - .agent_title - .clone() - .or_else(|| metadata.title.clone()) - }); + let notification_title = event.metadata.as_ref().and_then(|metadata| { + metadata + .agent_title + .clone() + .or_else(|| metadata.title.clone()) + }); let metadata_reported_inactive = event .metadata .as_ref() @@ -1772,6 +1769,113 @@ impl AppModel { Ok(()) } + pub fn transfer_surface( + &mut self, + workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + to_index: usize, + ) -> Result<(), DomainError> { + if source_pane_id == target_pane_id { + return self.move_surface(workspace_id, source_pane_id, surface_id, to_index); + } + + { + let workspace = self + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + if !workspace.panes.contains_key(&source_pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: source_pane_id, + }); + } + if !workspace.panes.contains_key(&target_pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: target_pane_id, + }); + } + if !workspace + .panes + .get(&source_pane_id) + .is_some_and(|pane| pane.surfaces.contains_key(&surface_id)) + { + return Err(DomainError::SurfaceNotInPane { + workspace_id, + pane_id: source_pane_id, + surface_id, + }); + } + } + + let moved_surface = { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_pane = workspace.panes.get_mut(&source_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: source_pane_id, + }, + )?; + source_pane + .surfaces + .shift_remove(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id: source_pane_id, + surface_id, + })? + }; + + let should_close_source_pane = self + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .is_some_and(|pane| pane.surfaces.is_empty()); + + { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let target_pane = workspace.panes.get_mut(&target_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: target_pane_id, + }, + )?; + target_pane.insert_surface(moved_surface); + if target_pane.surfaces.len() > 1 { + let last_index = target_pane.surfaces.len() - 1; + let target_index = to_index.min(last_index); + let _ = target_pane.move_surface(surface_id, target_index); + } + target_pane.active_surface = surface_id; + for notification in &mut workspace.notifications { + if notification.surface_id == surface_id { + notification.pane_id = target_pane_id; + } + } + let _ = workspace.focus_surface(target_pane_id, surface_id); + } + + if should_close_source_pane { + self.close_pane(workspace_id, source_pane_id)?; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let _ = workspace.focus_surface(target_pane_id, surface_id); + } + + Ok(()) + } + pub fn close_pane( &mut self, workspace_id: WorkspaceId, @@ -2137,15 +2241,10 @@ mod tests { assert_eq!(right_column.width, MIN_WORKSPACE_WINDOW_WIDTH); assert_eq!(right_column.window_order.len(), 2); assert_ne!(workspace.active_window, first_window_id); - assert!( - workspace - .columns - .values() - .any(|column| { - column.window_order == vec![first_window_id] - && column.width == MIN_WORKSPACE_WINDOW_WIDTH - }) - ); + assert!(workspace.columns.values().any(|column| { + column.window_order == vec![first_window_id] + && column.width == MIN_WORKSPACE_WINDOW_WIDTH + })); let upper_window_id = right_column.window_order[0]; assert_eq!( workspace @@ -2193,7 +2292,10 @@ mod tests { .values() .map(|column| column.width) .collect::>(); - assert_eq!(widths, vec![MIN_WORKSPACE_WINDOW_WIDTH, MIN_WORKSPACE_WINDOW_WIDTH]); + assert_eq!( + widths, + vec![MIN_WORKSPACE_WINDOW_WIDTH, MIN_WORKSPACE_WINDOW_WIDTH] + ); } #[test] @@ -2218,7 +2320,10 @@ mod tests { .values() .map(|window| window.height) .collect::>(); - assert_eq!(heights, vec![MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_HEIGHT]); + assert_eq!( + heights, + vec![MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_HEIGHT] + ); } #[test] @@ -2438,6 +2543,91 @@ mod tests { assert_eq!(pane.active_surface, second_surface_id); } + #[test] + fn transferring_surface_to_another_pane_focuses_target_pane() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let target_pane_id = model + .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal) + .expect("split"); + let target_placeholder_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&target_pane_id)) + .and_then(|pane| pane.surface_ids().next()) + .expect("placeholder"); + + let first_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .and_then(|pane| pane.surface_ids().next()) + .expect("first surface"); + let second_surface_id = model + .create_surface(workspace_id, source_pane_id, PaneKind::Browser) + .expect("second surface"); + + model + .transfer_surface( + workspace_id, + source_pane_id, + second_surface_id, + target_pane_id, + 0, + ) + .expect("transfer"); + + let workspace = model.active_workspace().expect("workspace"); + let source_order = workspace + .panes + .get(&source_pane_id) + .expect("source pane") + .surface_ids() + .collect::>(); + let target_pane = workspace.panes.get(&target_pane_id).expect("target pane"); + let target_order = target_pane.surface_ids().collect::>(); + + assert_eq!(source_order, vec![first_surface_id]); + assert_eq!(target_order, vec![second_surface_id, target_placeholder_id]); + assert_eq!(target_pane.active_surface, second_surface_id); + assert_eq!(workspace.active_pane, target_pane_id); + } + + #[test] + fn transferring_last_surface_closes_the_source_pane() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let target_pane_id = model + .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal) + .expect("split"); + let moved_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .and_then(|pane| pane.surface_ids().next()) + .expect("surface"); + + model + .transfer_surface( + workspace_id, + source_pane_id, + moved_surface_id, + target_pane_id, + usize::MAX, + ) + .expect("transfer"); + + let workspace = model.active_workspace().expect("workspace"); + assert!(!workspace.panes.contains_key(&source_pane_id)); + let target_order = workspace + .panes + .get(&target_pane_id) + .expect("target pane") + .surface_ids() + .collect::>(); + assert!(target_order.contains(&moved_surface_id)); + assert_eq!(workspace.active_pane, target_pane_id); + } + #[test] fn closing_surface_after_reorder_removes_the_requested_surface() { let mut model = AppModel::new("Main"); diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 2257e36..e6bf7f6 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -694,6 +694,11 @@ pub enum ShellAction { pane_id: PaneId, surface_id: SurfaceId, }, + MoveSurface { + surface_id: SurfaceId, + target_pane_id: PaneId, + target_index: usize, + }, NavigateBrowser { surface_id: SurfaceId, url: String, @@ -1424,6 +1429,11 @@ impl TaskersCore { pane_id, surface_id, } => self.focus_surface_by_id(pane_id, surface_id), + ShellAction::MoveSurface { + surface_id, + target_pane_id, + target_index, + } => self.move_surface_by_id(surface_id, target_pane_id, target_index), ShellAction::NavigateBrowser { surface_id, url } => { self.navigate_browser_surface(surface_id, &url) } @@ -1639,6 +1649,42 @@ impl TaskersCore { }) } + fn move_surface_by_id( + &mut self, + surface_id: SurfaceId, + target_pane_id: PaneId, + target_index: usize, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some((workspace_id, source_pane_id)) = + self.resolve_surface_location(&model, surface_id) + else { + return false; + }; + let Some((target_workspace_id, _)) = self.resolve_workspace_pane(&model, target_pane_id) + else { + return false; + }; + if workspace_id != target_workspace_id { + return false; + } + if source_pane_id == target_pane_id { + return self.dispatch_control(ControlCommand::MoveSurface { + workspace_id, + pane_id: source_pane_id, + surface_id, + to_index: target_index, + }); + } + self.dispatch_control(ControlCommand::TransferSurface { + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + to_index: target_index, + }) + } + fn navigate_browser_surface(&mut self, surface_id: SurfaceId, raw_url: &str) -> bool { let normalized = resolved_browser_uri(raw_url); self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { @@ -2751,7 +2797,7 @@ fn resolved_browser_uri(raw: &str) -> String { if trimmed.contains("://") { return trimmed.to_string(); } - if trimmed.chars().any(char::is_whitespace) { + if !looks_like_browser_location(trimmed) { return format!( "https://duckduckgo.com/?q={}", trimmed.split_whitespace().collect::>().join("+") @@ -2763,9 +2809,20 @@ fn resolved_browser_uri(raw: &str) -> String { format!("https://{trimmed}") } +fn looks_like_browser_location(value: &str) -> bool { + if is_local_browser_target(value) || value.parse::().is_ok() { + return true; + } + + let head = value.split(['/', '?', '#']).next().unwrap_or(value); + head.contains('.') +} + fn is_local_browser_target(value: &str) -> bool { value.starts_with("localhost") || value.starts_with("127.0.0.1") + || value.starts_with("192.168.") + || value.starts_with("10.") || value.starts_with("0.0.0.0") || value.contains(":3000") || value.contains(":5173") @@ -2780,7 +2837,7 @@ mod tests { use super::{ BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, - default_preview_app_state, + default_preview_app_state, resolved_browser_uri, }; fn bootstrap() -> BootstrapModel { @@ -3025,4 +3082,96 @@ mod tests { let overview_snapshot = core.snapshot(); assert_eq!(overview_snapshot.current_workspace.viewport_x, after); } + + #[test] + fn browser_address_bar_normalizes_search_queries() { + assert_eq!(resolved_browser_uri(""), "about:blank"); + assert_eq!( + resolved_browser_uri("rust"), + "https://duckduckgo.com/?q=rust" + ); + assert_eq!( + resolved_browser_uri("cmux tabs"), + "https://duckduckgo.com/?q=cmux+tabs" + ); + assert_eq!(resolved_browser_uri("example.com"), "https://example.com"); + assert_eq!( + resolved_browser_uri("localhost:3000"), + "http://localhost:3000" + ); + } + + #[test] + fn move_surface_shell_action_transfers_surface_between_panes() { + fn find_pane<'a>( + node: &'a super::LayoutNodeSnapshot, + pane_id: taskers_domain::PaneId, + ) -> Option<&'a super::PaneSnapshot> { + match node { + super::LayoutNodeSnapshot::Pane(pane) => (pane.id == pane_id).then_some(pane), + super::LayoutNodeSnapshot::Split { first, second, .. } => { + find_pane(first, pane_id).or_else(|| find_pane(second, pane_id)) + } + } + } + + fn collect_pane_ids( + node: &super::LayoutNodeSnapshot, + pane_ids: &mut Vec, + ) { + match node { + super::LayoutNodeSnapshot::Pane(pane) => pane_ids.push(pane.id), + super::LayoutNodeSnapshot::Split { first, second, .. } => { + collect_pane_ids(first, pane_ids); + collect_pane_ids(second, pane_ids); + } + } + } + + let core = SharedCore::bootstrap(bootstrap()); + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::SplitTerminal { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let mut pane_ids = Vec::new(); + collect_pane_ids(&snapshot.current_workspace.layout, &mut pane_ids); + let target_pane_id = pane_ids + .into_iter() + .find(|pane_id| *pane_id != source_pane_id) + .expect("target pane"); + + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: moved_surface_id, + target_pane_id, + target_index: 0, + }); + + let snapshot = core.snapshot(); + let source_pane = + find_pane(&snapshot.current_workspace.layout, source_pane_id).expect("source pane"); + let target_pane = + find_pane(&snapshot.current_workspace.layout, target_pane_id).expect("target pane"); + + assert!( + !source_pane + .surfaces + .iter() + .any(|surface| surface.id == moved_surface_id) + ); + assert_eq!( + target_pane.surfaces.first().map(|surface| surface.id), + Some(moved_surface_id) + ); + assert_eq!(snapshot.current_workspace.active_pane, target_pane_id); + } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index f4477ec..b18b157 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -3,12 +3,59 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, - LayoutNodeSnapshot, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, + LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, - ShortcutBindingSnapshot, SplitAxis, SurfaceKind, SurfaceSnapshot, WorkspaceDirection, - WorkspaceId, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowSnapshot, + ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, + WorkspaceDirection, WorkspaceId, WorkspaceSummary, WorkspaceViewSnapshot, + WorkspaceWindowSnapshot, }; +#[derive(Clone, Copy, PartialEq, Eq)] +struct DraggedSurface { + pane_id: PaneId, + surface_id: SurfaceId, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SurfaceDropTarget { + PaneEnd { + pane_id: PaneId, + }, + BeforeSurface { + pane_id: PaneId, + surface_id: SurfaceId, + }, +} + +fn compute_surface_drop_index( + dragged: DraggedSurface, + target_pane_id: PaneId, + ordered_surface_ids: &[SurfaceId], + before_surface_id: Option, +) -> usize { + let Some(before_surface_id) = before_surface_id else { + return usize::MAX; + }; + + let Some(mut target_index) = ordered_surface_ids + .iter() + .position(|surface_id| *surface_id == before_surface_id) + else { + return usize::MAX; + }; + + if dragged.pane_id == target_pane_id + && let Some(source_index) = ordered_surface_ids + .iter() + .position(|surface_id| *surface_id == dragged.surface_id) + && source_index < target_index + { + target_index = target_index.saturating_sub(1); + } + + target_index +} + fn app_css(snapshot: &ShellSnapshot) -> String { theme::generate_css(&theme::resolve_palette( &snapshot.settings.selected_theme_id, @@ -113,6 +160,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); + let surface_drag_source = use_signal(|| None::); + let surface_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); let main_class = match snapshot.section { @@ -219,6 +268,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { snapshot.browser_chrome.as_ref(), core.clone(), &snapshot.runtime_status, + surface_drag_source, + surface_drop_target, )} } } else { @@ -472,6 +523,8 @@ fn render_layout( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, + surface_drag_source: Signal>, + surface_drop_target: Signal>, ) -> Element { match node { LayoutNodeSnapshot::Split { @@ -492,15 +545,22 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, browser_chrome, core.clone(), runtime_status)} + {render_layout(first, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, browser_chrome, core.clone(), runtime_status)} + {render_layout(second, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} } } } } - LayoutNodeSnapshot::Pane(pane) => render_pane(pane, browser_chrome, core, runtime_status), + LayoutNodeSnapshot::Pane(pane) => render_pane( + pane, + browser_chrome, + core, + runtime_status, + surface_drag_source, + surface_drop_target, + ), } } @@ -509,6 +569,8 @@ fn render_workspace_strip( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, + surface_drag_source: Signal>, + surface_drop_target: Signal>, ) -> Element { let viewport_class = if workspace.overview_scale < 1.0 { "workspace-viewport workspace-viewport-overview" @@ -548,6 +610,8 @@ fn render_workspace_strip( browser_chrome, core.clone(), runtime_status, + surface_drag_source, + surface_drop_target, )} } } @@ -562,6 +626,8 @@ fn render_workspace_window( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, + surface_drag_source: Signal>, + surface_drop_target: Signal>, ) -> Element { let local_x = window.frame.x - workspace.viewport_origin_x; let local_y = window.frame.y - workspace.viewport_origin_y; @@ -595,7 +661,14 @@ fn render_workspace_window( } } div { class: "workspace-window-body", - {render_layout(&window.layout, browser_chrome, core.clone(), runtime_status)} + {render_layout( + &window.layout, + browser_chrome, + core.clone(), + runtime_status, + surface_drag_source, + surface_drop_target, + )} } } } @@ -606,14 +679,36 @@ fn render_pane( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, + mut surface_drag_source: Signal>, + mut surface_drop_target: Signal>, ) -> Element { + let pane_id = pane.id; + let pane_is_drop_target = matches!( + *surface_drop_target.read(), + Some(SurfaceDropTarget::PaneEnd { + pane_id: target_pane_id, + }) if target_pane_id == pane_id + ); let pane_class = if pane.active { format!( - "pane-card pane-card-active pane-card-state-{}", - pane.attention.slug() + "pane-card pane-card-active pane-card-state-{}{}", + pane.attention.slug(), + if pane_is_drop_target { + " pane-card-drop-target" + } else { + "" + } ) } else { - format!("pane-card pane-card-state-{}", pane.attention.slug()) + format!( + "pane-card pane-card-state-{}{}", + pane.attention.slug(), + if pane_is_drop_target { + " pane-card-drop-target" + } else { + "" + } + ) }; let active_surface = pane .surfaces @@ -624,8 +719,12 @@ fn render_pane( .first() .expect("pane snapshot should contain surfaces") }); - let pane_id = pane.id; let active_surface_id = active_surface.id; + let ordered_surface_ids = pane + .surfaces + .iter() + .map(|surface| surface.id) + .collect::>(); let active_browser_chrome = browser_chrome .filter(|chrome| chrome.surface_id == active_surface.id) .cloned(); @@ -697,13 +796,57 @@ fn render_pane( } else { "Close current surface" }; + let set_pane_drop_target = move |event: Event| { + event.prevent_default(); + surface_drop_target.set(Some(SurfaceDropTarget::PaneEnd { pane_id })); + }; + let clear_pane_drop_target = move |_: Event| { + if *surface_drop_target.read() == Some(SurfaceDropTarget::PaneEnd { pane_id }) { + surface_drop_target.set(None); + } + }; + let drop_on_pane = { + let core = core.clone(); + let ordered_surface_ids = ordered_surface_ids.clone(); + move |event: Event| { + event.prevent_default(); + let dragged = *surface_drag_source.read(); + surface_drag_source.set(None); + surface_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: dragged.surface_id, + target_pane_id: pane_id, + target_index: compute_surface_drop_index( + dragged, + pane_id, + &ordered_surface_ids, + None, + ), + }); + } + }; rsx! { section { class: "{pane_class}", onclick: focus_pane, - div { class: "pane-header", + div { + class: "pane-header", + ondragover: set_pane_drop_target, + ondragleave: clear_pane_drop_target, + ondrop: drop_on_pane, div { class: "surface-tabs", for surface in &pane.surfaces { - {render_surface_tab(pane.id, pane.active_surface, surface, core.clone())} + {render_surface_tab( + pane.id, + pane.active_surface, + surface, + core.clone(), + surface_drag_source, + surface_drop_target, + &ordered_surface_ids, + )} } } div { class: "pane-action-cluster", @@ -731,33 +874,115 @@ fn render_pane( } fn render_surface_tab( - pane_id: taskers_core::PaneId, - active_surface_id: taskers_core::SurfaceId, + pane_id: PaneId, + active_surface_id: SurfaceId, surface: &SurfaceSnapshot, core: SharedCore, + mut surface_drag_source: Signal>, + mut surface_drop_target: Signal>, + ordered_surface_ids: &[SurfaceId], ) -> Element { let kind_label = match surface.kind { SurfaceKind::Terminal => "term", SurfaceKind::Browser => "web", }; + let surface_id = surface.id; + let is_drop_target = matches!( + *surface_drop_target.read(), + Some(SurfaceDropTarget::BeforeSurface { + pane_id: target_pane_id, + surface_id: target_surface_id, + }) if target_pane_id == pane_id && target_surface_id == surface_id + ); let tab_class = if surface.id == active_surface_id { format!( - "surface-tab surface-tab-active surface-tab-state-{}", - surface.attention.slug() + "surface-tab surface-tab-active surface-tab-state-{}{}", + surface.attention.slug(), + if is_drop_target { + " surface-tab-drop-target" + } else { + "" + } ) } else { - format!("surface-tab surface-tab-state-{}", surface.attention.slug()) + format!( + "surface-tab surface-tab-state-{}{}", + surface.attention.slug(), + if is_drop_target { + " surface-tab-drop-target" + } else { + "" + } + ) }; - let surface_id = surface.id; + let focus_core = core.clone(); let focus_surface = move |_| { - core.dispatch_shell_action(ShellAction::FocusSurface { + focus_core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id, }); }; + let start_drag = move |_: Event| { + surface_drag_source.set(Some(DraggedSurface { + pane_id, + surface_id, + })); + }; + let clear_drag = move |_: Event| { + surface_drag_source.set(None); + surface_drop_target.set(None); + }; + let set_surface_drop_target = move |event: Event| { + event.prevent_default(); + surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { + pane_id, + surface_id, + })); + }; + let clear_surface_drop_target = move |_: Event| { + if *surface_drop_target.read() + == Some(SurfaceDropTarget::BeforeSurface { + pane_id, + surface_id, + }) + { + surface_drop_target.set(None); + } + }; + let drop_surface = { + let core = core.clone(); + let ordered_surface_ids = ordered_surface_ids.to_vec(); + move |event: Event| { + event.prevent_default(); + let dragged = *surface_drag_source.read(); + surface_drag_source.set(None); + surface_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: dragged.surface_id, + target_pane_id: pane_id, + target_index: compute_surface_drop_index( + dragged, + pane_id, + &ordered_surface_ids, + Some(surface_id), + ), + }); + } + }; rsx! { - button { class: "{tab_class}", onclick: focus_surface, + button { + class: "{tab_class}", + draggable: "true", + onclick: focus_surface, + ondragstart: start_drag, + ondragend: clear_drag, + ondragover: set_surface_drop_target, + ondragleave: clear_surface_drop_target, + ondrop: drop_surface, span { class: "surface-tab-label", "{kind_label}" } span { class: "surface-tab-title", "{surface.title}" } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 8e1c2d3..ab44632 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -894,6 +894,10 @@ button {{ border-color: {accent_20}; }} +.pane-card-drop-target {{ + border-color: {accent_24}; +}} + .pane-card-state-busy {{ box-shadow: inset 0 0 0 1px {busy_10}; }} @@ -995,6 +999,15 @@ button {{ border-color: {border_10}; }} +.surface-tab[draggable] {{ + cursor: grab; +}} + +.surface-tab-drop-target {{ + border-color: {accent_24}; + background: {accent_12}; +}} + .surface-tab-active {{ background: {overlay_16}; border-color: {accent_20}; From eac2993c63b5a0d28240723e4da4c362e95b0631 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 15:52:29 +0100 Subject: [PATCH 39/63] refactor: add greenfield shortcut and action model --- greenfield/crates/taskers-core/src/lib.rs | 785 +++++++++++++++------- greenfield/crates/taskers/src/main.rs | 135 +++- 2 files changed, 682 insertions(+), 238 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index e6bf7f6..0f91f79 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -8,9 +8,9 @@ use std::{ use taskers_app_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ - ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, MIN_WORKSPACE_WINDOW_HEIGHT, - MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, PaneMetadataPatch, - SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, Workspace, + ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, Direction, KEYBOARD_RESIZE_STEP, + MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, + PaneMetadataPatch, SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, Workspace, WorkspaceSummary as DomainWorkspaceSummary, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; @@ -185,6 +185,275 @@ impl ShortcutPreset { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShortcutAction { + ToggleOverview, + CloseTerminal, + OpenBrowserSplit, + FocusBrowserAddress, + ReloadBrowserPage, + ToggleBrowserDevtools, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + NewWindowLeft, + NewWindowRight, + NewWindowUp, + NewWindowDown, + MoveWindowLeft, + MoveWindowRight, + MoveWindowUp, + MoveWindowDown, + ResizeWindowLeft, + ResizeWindowRight, + ResizeWindowUp, + ResizeWindowDown, + ResizeSplitLeft, + ResizeSplitRight, + ResizeSplitUp, + ResizeSplitDown, + SplitRight, + SplitDown, +} + +impl ShortcutAction { + pub const ALL: [Self; 28] = [ + Self::ToggleOverview, + Self::CloseTerminal, + Self::OpenBrowserSplit, + Self::FocusBrowserAddress, + Self::ReloadBrowserPage, + Self::ToggleBrowserDevtools, + Self::FocusLeft, + Self::FocusRight, + Self::FocusUp, + Self::FocusDown, + Self::NewWindowLeft, + Self::NewWindowRight, + Self::NewWindowUp, + Self::NewWindowDown, + Self::MoveWindowLeft, + Self::MoveWindowRight, + Self::MoveWindowUp, + Self::MoveWindowDown, + Self::ResizeWindowLeft, + Self::ResizeWindowRight, + Self::ResizeWindowUp, + Self::ResizeWindowDown, + Self::ResizeSplitLeft, + Self::ResizeSplitRight, + Self::ResizeSplitUp, + Self::ResizeSplitDown, + Self::SplitRight, + Self::SplitDown, + ]; + + pub fn id(self) -> &'static str { + match self { + Self::ToggleOverview => "toggle_overview", + Self::CloseTerminal => "close_terminal", + Self::OpenBrowserSplit => "open_browser_split", + Self::FocusBrowserAddress => "focus_browser_address", + Self::ReloadBrowserPage => "reload_browser_page", + Self::ToggleBrowserDevtools => "toggle_browser_devtools", + Self::FocusLeft => "focus_left", + Self::FocusRight => "focus_right", + Self::FocusUp => "focus_up", + Self::FocusDown => "focus_down", + Self::NewWindowLeft => "new_window_left", + Self::NewWindowRight => "new_window_right", + Self::NewWindowUp => "new_window_up", + Self::NewWindowDown => "new_window_down", + Self::MoveWindowLeft => "move_window_left", + Self::MoveWindowRight => "move_window_right", + Self::MoveWindowUp => "move_window_up", + Self::MoveWindowDown => "move_window_down", + Self::ResizeWindowLeft => "resize_window_left", + Self::ResizeWindowRight => "resize_window_right", + Self::ResizeWindowUp => "resize_window_up", + Self::ResizeWindowDown => "resize_window_down", + Self::ResizeSplitLeft => "resize_split_left", + Self::ResizeSplitRight => "resize_split_right", + Self::ResizeSplitUp => "resize_split_up", + Self::ResizeSplitDown => "resize_split_down", + Self::SplitRight => "split_right", + Self::SplitDown => "split_down", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::ToggleOverview => "Toggle overview", + Self::CloseTerminal => "Close terminal", + Self::OpenBrowserSplit => "Open browser in split", + Self::FocusBrowserAddress => "Focus browser address bar", + Self::ReloadBrowserPage => "Reload browser page", + Self::ToggleBrowserDevtools => "Toggle browser devtools", + Self::FocusLeft => "Focus left", + Self::FocusRight => "Focus right", + Self::FocusUp => "Focus up", + Self::FocusDown => "Focus down", + Self::NewWindowLeft => "New window left", + Self::NewWindowRight => "New window right", + Self::NewWindowUp => "New window up", + Self::NewWindowDown => "New window down", + Self::MoveWindowLeft => "Move window left", + Self::MoveWindowRight => "Move window right", + Self::MoveWindowUp => "Move window up", + Self::MoveWindowDown => "Move window down", + Self::ResizeWindowLeft => "Make window narrower", + Self::ResizeWindowRight => "Make window wider", + Self::ResizeWindowUp => "Make window shorter", + Self::ResizeWindowDown => "Make window taller", + Self::ResizeSplitLeft => "Make split narrower", + Self::ResizeSplitRight => "Make split wider", + Self::ResizeSplitUp => "Make split shorter", + Self::ResizeSplitDown => "Make split taller", + Self::SplitRight => "Split right", + Self::SplitDown => "Split down", + } + } + + pub fn detail(self) -> &'static str { + match self { + Self::ToggleOverview => "Zoom the current workspace out to fit the full column strip.", + Self::CloseTerminal => "Close the active pane.", + Self::OpenBrowserSplit => { + "Split the active pane to the right and open a browser surface." + } + Self::FocusBrowserAddress => "Focus the address bar for the active browser surface.", + Self::ReloadBrowserPage => "Reload the active browser surface.", + Self::ToggleBrowserDevtools => "Show or hide devtools for the active browser surface.", + Self::FocusLeft => { + "Move focus to the nearest pane on the left before falling back to another window." + } + Self::FocusRight => { + "Move focus to the nearest pane on the right before falling back to another window." + } + Self::FocusUp => { + "Move focus to the nearest pane above before falling back to another window." + } + Self::FocusDown => { + "Move focus to the nearest pane below before falling back to another window." + } + Self::NewWindowLeft => "Create a top-level window in a new column on the left.", + Self::NewWindowRight => "Create a top-level window in a new column on the right.", + Self::NewWindowUp => "Create a stacked top-level window above the active window.", + Self::NewWindowDown => "Create a stacked top-level window below the active window.", + Self::MoveWindowLeft => "Move the active top-level window into the column on the left.", + Self::MoveWindowRight => { + "Move the active top-level window into the column on the right." + } + Self::MoveWindowUp => "Move the active top-level window above the current stack.", + Self::MoveWindowDown => "Move the active top-level window below the current stack.", + Self::ResizeWindowLeft => "Reduce the active column width.", + Self::ResizeWindowRight => "Increase the active column width.", + Self::ResizeWindowUp => "Reduce the active top-level window height.", + Self::ResizeWindowDown => "Increase the active top-level window height.", + Self::ResizeSplitLeft => "Reduce the active split width.", + Self::ResizeSplitRight => "Increase the active split width.", + Self::ResizeSplitUp => "Reduce the active split height.", + Self::ResizeSplitDown => "Increase the active split height.", + Self::SplitRight => "Split the active pane to the right inside the current window.", + Self::SplitDown => "Split the active pane downward inside the current window.", + } + } + + pub fn category(self) -> &'static str { + match self { + Self::ToggleOverview | Self::CloseTerminal => "General", + Self::OpenBrowserSplit + | Self::FocusBrowserAddress + | Self::ReloadBrowserPage + | Self::ToggleBrowserDevtools => "Browser", + Self::FocusLeft | Self::FocusRight | Self::FocusUp | Self::FocusDown => "Focus", + Self::NewWindowLeft + | Self::NewWindowRight + | Self::NewWindowUp + | Self::NewWindowDown + | Self::MoveWindowLeft + | Self::MoveWindowRight + | Self::MoveWindowUp + | Self::MoveWindowDown => "Top-level windows", + Self::SplitRight | Self::SplitDown => "Pane splits", + Self::ResizeWindowLeft + | Self::ResizeWindowRight + | Self::ResizeWindowUp + | Self::ResizeWindowDown + | Self::ResizeSplitLeft + | Self::ResizeSplitRight + | Self::ResizeSplitUp + | Self::ResizeSplitDown => "Advanced resize", + } + } + + pub fn accelerators(self, preset: ShortcutPreset) -> &'static [&'static str] { + match preset { + ShortcutPreset::Balanced => match self { + Self::ToggleOverview => &["o"], + Self::CloseTerminal => &["x"], + Self::OpenBrowserSplit => &["l"], + Self::FocusBrowserAddress => &["l"], + Self::ReloadBrowserPage => &["r"], + Self::ToggleBrowserDevtools => &["i"], + Self::FocusLeft => &["h", "Left"], + Self::FocusRight => &["l", "Right"], + Self::FocusUp => &["k", "Up"], + Self::FocusDown => &["j", "Down"], + Self::NewWindowLeft => &[], + Self::NewWindowRight => &["t"], + Self::NewWindowUp => &[], + Self::NewWindowDown => &["g"], + Self::MoveWindowLeft => &[], + Self::MoveWindowRight => &[], + Self::MoveWindowUp => &[], + Self::MoveWindowDown => &[], + Self::ResizeWindowLeft => &[], + Self::ResizeWindowRight => &[], + Self::ResizeWindowUp => &[], + Self::ResizeWindowDown => &[], + Self::ResizeSplitLeft => &[], + Self::ResizeSplitRight => &[], + Self::ResizeSplitUp => &[], + Self::ResizeSplitDown => &[], + Self::SplitRight => &["t"], + Self::SplitDown => &["g"], + }, + ShortcutPreset::PowerUser => match self { + Self::ToggleOverview => &["o"], + Self::CloseTerminal => &["x"], + Self::OpenBrowserSplit => &["l"], + Self::FocusBrowserAddress => &["l"], + Self::ReloadBrowserPage => &["r"], + Self::ToggleBrowserDevtools => &["i"], + Self::FocusLeft => &["h", "Left"], + Self::FocusRight => &["l", "Right"], + Self::FocusUp => &["k", "Up"], + Self::FocusDown => &["j", "Down"], + Self::NewWindowLeft => &[], + Self::NewWindowRight => &["t"], + Self::NewWindowUp => &[], + Self::NewWindowDown => &["g"], + Self::MoveWindowLeft => &["h", "Left"], + Self::MoveWindowRight => &["l", "Right"], + Self::MoveWindowUp => &["k", "Up"], + Self::MoveWindowDown => &["j", "Down"], + Self::ResizeWindowLeft => &["Home"], + Self::ResizeWindowRight => &["End"], + Self::ResizeWindowUp => &["Page_Up"], + Self::ResizeWindowDown => &["Page_Down"], + Self::ResizeSplitLeft => &["Home"], + Self::ResizeSplitRight => &["End"], + Self::ResizeSplitUp => &["Page_Up"], + Self::ResizeSplitDown => &["Page_Down"], + Self::SplitRight => &["t"], + Self::SplitDown => &["g"], + }, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuntimeCapability { Ready, @@ -1413,10 +1682,10 @@ impl TaskersCore { } ShellAction::ScrollViewport { dx, dy } => self.scroll_viewport_by(dx, dy), ShellAction::SplitBrowser { pane_id } => { - self.split_with_kind(pane_id, PaneKind::Browser) + self.split_with_kind_axis(pane_id, PaneKind::Browser, DomainSplitAxis::Horizontal) } ShellAction::SplitTerminal { pane_id } => { - self.split_with_kind(pane_id, PaneKind::Terminal) + self.split_with_kind_axis(pane_id, PaneKind::Terminal, DomainSplitAxis::Horizontal) } ShellAction::AddBrowserSurface { pane_id } => { self.add_surface_to_pane(pane_id, PaneKind::Browser) @@ -1476,6 +1745,166 @@ impl TaskersCore { } } + fn dispatch_shortcut_action(&mut self, action: ShortcutAction) -> bool { + match action { + ShortcutAction::ToggleOverview => { + self.dispatch_shell_action(ShellAction::ToggleOverview) + } + ShortcutAction::CloseTerminal => self.run_workspace_shortcut(|core, workspace_id| { + let pane_id = core + .app_state + .snapshot_model() + .workspaces + .get(&workspace_id) + .map(|workspace| workspace.active_pane)?; + Some(core.dispatch_control(ControlCommand::ClosePane { + workspace_id, + pane_id, + })) + }), + ShortcutAction::OpenBrowserSplit => self.run_workspace_shortcut(|core, _| { + Some(core.split_with_kind_axis( + None, + PaneKind::Browser, + DomainSplitAxis::Horizontal, + )) + }), + ShortcutAction::FocusBrowserAddress => false, + ShortcutAction::ReloadBrowserPage => { + self.with_active_browser_surface(|core, surface_id| { + core.queue_host_command(HostCommand::BrowserReload { surface_id }) + }) + } + ShortcutAction::ToggleBrowserDevtools => { + self.with_active_browser_surface(|core, surface_id| { + core.queue_host_command(HostCommand::BrowserToggleDevtools { surface_id }) + }) + } + ShortcutAction::FocusLeft => self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::FocusPaneDirection { + workspace_id, + direction: Direction::Left, + })) + }), + ShortcutAction::FocusRight => self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::FocusPaneDirection { + workspace_id, + direction: Direction::Right, + })) + }), + ShortcutAction::FocusUp => self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::FocusPaneDirection { + workspace_id, + direction: Direction::Up, + })) + }), + ShortcutAction::FocusDown => self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::FocusPaneDirection { + workspace_id, + direction: Direction::Down, + })) + }), + ShortcutAction::NewWindowLeft => self.run_workspace_shortcut(|core, _| { + Some(core.create_workspace_window(WorkspaceDirection::Left)) + }), + ShortcutAction::NewWindowRight => self.run_workspace_shortcut(|core, _| { + Some(core.create_workspace_window(WorkspaceDirection::Right)) + }), + ShortcutAction::NewWindowUp => self.run_workspace_shortcut(|core, _| { + Some(core.create_workspace_window(WorkspaceDirection::Up)) + }), + ShortcutAction::NewWindowDown => self.run_workspace_shortcut(|core, _| { + Some(core.create_workspace_window(WorkspaceDirection::Down)) + }), + ShortcutAction::MoveWindowLeft + | ShortcutAction::MoveWindowRight + | ShortcutAction::MoveWindowUp + | ShortcutAction::MoveWindowDown => false, + ShortcutAction::ResizeWindowLeft => { + self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::ResizeActiveWindow { + workspace_id, + direction: Direction::Left, + amount: KEYBOARD_RESIZE_STEP, + })) + }) + } + ShortcutAction::ResizeWindowRight => { + self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::ResizeActiveWindow { + workspace_id, + direction: Direction::Right, + amount: KEYBOARD_RESIZE_STEP, + })) + }) + } + ShortcutAction::ResizeWindowUp => self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::ResizeActiveWindow { + workspace_id, + direction: Direction::Up, + amount: KEYBOARD_RESIZE_STEP, + })) + }), + ShortcutAction::ResizeWindowDown => { + self.run_workspace_shortcut(|core, workspace_id| { + Some(core.dispatch_control(ControlCommand::ResizeActiveWindow { + workspace_id, + direction: Direction::Down, + amount: KEYBOARD_RESIZE_STEP, + })) + }) + } + ShortcutAction::ResizeSplitLeft => self.run_workspace_shortcut(|core, workspace_id| { + Some( + core.dispatch_control(ControlCommand::ResizeActivePaneSplit { + workspace_id, + direction: Direction::Left, + amount: KEYBOARD_RESIZE_STEP, + }), + ) + }), + ShortcutAction::ResizeSplitRight => { + self.run_workspace_shortcut(|core, workspace_id| { + Some( + core.dispatch_control(ControlCommand::ResizeActivePaneSplit { + workspace_id, + direction: Direction::Right, + amount: KEYBOARD_RESIZE_STEP, + }), + ) + }) + } + ShortcutAction::ResizeSplitUp => self.run_workspace_shortcut(|core, workspace_id| { + Some( + core.dispatch_control(ControlCommand::ResizeActivePaneSplit { + workspace_id, + direction: Direction::Up, + amount: KEYBOARD_RESIZE_STEP, + }), + ) + }), + ShortcutAction::ResizeSplitDown => self.run_workspace_shortcut(|core, workspace_id| { + Some( + core.dispatch_control(ControlCommand::ResizeActivePaneSplit { + workspace_id, + direction: Direction::Down, + amount: KEYBOARD_RESIZE_STEP, + }), + ) + }), + ShortcutAction::SplitRight => self.run_workspace_shortcut(|core, _| { + Some(core.split_with_kind_axis( + None, + PaneKind::Terminal, + DomainSplitAxis::Horizontal, + )) + }), + ShortcutAction::SplitDown => self.run_workspace_shortcut(|core, _| { + Some(core.split_with_kind_axis(None, PaneKind::Terminal, DomainSplitAxis::Vertical)) + }), + } + } + fn focus_workspace(&mut self, workspace_id: WorkspaceId) -> bool { let mut changed = false; if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { @@ -1536,7 +1965,12 @@ impl TaskersCore { }) } - fn split_with_kind(&mut self, pane_id: Option, kind: PaneKind) -> bool { + fn split_with_kind_axis( + &mut self, + pane_id: Option, + kind: PaneKind, + axis: DomainSplitAxis, + ) -> bool { let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { return false; }; @@ -1544,7 +1978,7 @@ impl TaskersCore { let response = match self.dispatch_control_with_response(ControlCommand::SplitPane { workspace_id, pane_id: Some(target_pane_id), - axis: DomainSplitAxis::Horizontal, + axis, }) { Some(response) => response, None => return false, @@ -1713,6 +2147,99 @@ impl TaskersCore { self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { surface_id, patch }) } + fn run_workspace_shortcut( + &mut self, + handler: impl FnOnce(&mut Self, WorkspaceId) -> Option, + ) -> bool { + let Some(workspace_id) = self.prepare_workspace_interaction() else { + return false; + }; + let Some(mut changed) = handler(self, workspace_id) else { + return false; + }; + changed |= self.ensure_active_window_visible(); + changed + } + + fn prepare_workspace_interaction(&mut self) -> Option { + let mut changed = false; + if self.ui.section != ShellSection::Workspace { + self.ui.section = ShellSection::Workspace; + changed = true; + } + if self.ui.overview_mode { + self.ui.overview_mode = false; + changed = true; + } + if changed { + self.bump_local_revision(); + } + self.app_state.snapshot_model().active_workspace_id() + } + + fn ensure_active_window_visible(&mut self) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let Some(workspace) = model.workspaces.get(&workspace_id) else { + return false; + }; + let viewport_frame = self.workspace_viewport_frame(); + let Some(active_frame) = + workspace_window_placements(workspace, viewport_frame.width, viewport_frame.height) + .into_iter() + .find(|placement| placement.window_id == workspace.active_window) + .map(|placement| placement.frame) + else { + return false; + }; + + let mut next_viewport = workspace.viewport.clone(); + let visible_right = next_viewport.x + viewport_frame.width; + let visible_bottom = next_viewport.y + viewport_frame.height; + if active_frame.x < next_viewport.x { + next_viewport.x = active_frame.x; + } else if active_frame.right() > visible_right { + next_viewport.x = active_frame.right() - viewport_frame.width; + } + if active_frame.y < next_viewport.y { + next_viewport.y = active_frame.y; + } else if active_frame.bottom() > visible_bottom { + next_viewport.y = active_frame.bottom() - viewport_frame.height; + } + if next_viewport == workspace.viewport { + return false; + } + self.dispatch_control(ControlCommand::SetWorkspaceViewport { + workspace_id, + viewport: next_viewport, + }) + } + + fn with_active_browser_surface( + &mut self, + handler: impl FnOnce(&mut Self, SurfaceId) -> bool, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let Some(workspace) = model.workspaces.get(&workspace_id) else { + return false; + }; + let Some(pane) = workspace.panes.get(&workspace.active_pane) else { + return false; + }; + let Some(surface) = pane.active_surface() else { + return false; + }; + if surface.kind != PaneKind::Browser { + return false; + } + handler(self, surface.id) + } + fn resolve_target_pane(&self, pane_id: Option) -> Option<(WorkspaceId, PaneId)> { let model = self.app_state.snapshot_model(); if let Some(pane_id) = pane_id { @@ -1835,6 +2362,10 @@ impl SharedCore { self.inner.lock().snapshot() } + pub fn selected_shortcut_preset(&self) -> ShortcutPreset { + self.inner.lock().ui.selected_shortcut_preset + } + pub fn set_window_size(&self, size: PixelSize) { let mut inner = self.inner.lock(); if inner.set_window_size(size) { @@ -1849,6 +2380,15 @@ impl SharedCore { } } + pub fn dispatch_shortcut_action(&self, action: ShortcutAction) -> bool { + let mut inner = self.inner.lock(); + let changed = inner.dispatch_shortcut_action(action); + if changed { + let _ = self.revisions.send(inner.revision()); + } + changed + } + pub fn apply_host_event(&self, event: HostEvent) { let mut inner = self.inner.lock(); if inner.apply_host_event(event) { @@ -1895,226 +2435,19 @@ fn builtin_theme_options(selected_theme_id: &str) -> Vec { .collect() } -#[derive(Clone, Copy)] -struct ShortcutBindingSpec { - id: &'static str, - label: &'static str, - detail: &'static str, - category: &'static str, - balanced: &'static [&'static str], - power_user: &'static [&'static str], -} - -const SHORTCUT_BINDINGS: &[ShortcutBindingSpec] = &[ - ShortcutBindingSpec { - id: "toggle_overview", - label: "Toggle overview", - detail: "Zoom the current workspace out to fit the full column strip.", - category: "General", - balanced: &["o"], - power_user: &["o"], - }, - ShortcutBindingSpec { - id: "close_terminal", - label: "Close terminal", - detail: "Close the active pane or active top-level window.", - category: "General", - balanced: &["x"], - power_user: &["x"], - }, - ShortcutBindingSpec { - id: "open_browser_split", - label: "Open browser in split", - detail: "Split the active pane to the right and open a browser surface.", - category: "Browser", - balanced: &["l"], - power_user: &["l"], - }, - ShortcutBindingSpec { - id: "focus_browser_address", - label: "Focus browser address bar", - detail: "Focus the address bar for the active browser surface.", - category: "Browser", - balanced: &["l"], - power_user: &["l"], - }, - ShortcutBindingSpec { - id: "reload_browser_page", - label: "Reload browser page", - detail: "Reload the active browser surface.", - category: "Browser", - balanced: &["r"], - power_user: &["r"], - }, - ShortcutBindingSpec { - id: "toggle_browser_devtools", - label: "Toggle browser devtools", - detail: "Show or hide devtools for the active browser surface.", - category: "Browser", - balanced: &["i"], - power_user: &["i"], - }, - ShortcutBindingSpec { - id: "focus_left", - label: "Focus left", - detail: "Move focus to the column on the left, then fall back to pane focus.", - category: "Focus", - balanced: &["h", "Left"], - power_user: &["h", "Left"], - }, - ShortcutBindingSpec { - id: "focus_right", - label: "Focus right", - detail: "Move focus to the column on the right, then fall back to pane focus.", - category: "Focus", - balanced: &["l", "Right"], - power_user: &["l", "Right"], - }, - ShortcutBindingSpec { - id: "focus_up", - label: "Focus up", - detail: "Move focus to the stacked window above, then fall back to pane focus.", - category: "Focus", - balanced: &["k", "Up"], - power_user: &["k", "Up"], - }, - ShortcutBindingSpec { - id: "focus_down", - label: "Focus down", - detail: "Move focus to the stacked window below, then fall back to pane focus.", - category: "Focus", - balanced: &["j", "Down"], - power_user: &["j", "Down"], - }, - ShortcutBindingSpec { - id: "new_window_left", - label: "New window left", - detail: "Create a top-level window in a new column on the left.", - category: "Top-level windows", - balanced: &[], - power_user: &["h", "Left"], - }, - ShortcutBindingSpec { - id: "new_window_right", - label: "New window right", - detail: "Create a top-level window in a new column on the right.", - category: "Top-level windows", - balanced: &["t"], - power_user: &["t"], - }, - ShortcutBindingSpec { - id: "new_window_up", - label: "New window up", - detail: "Create a stacked top-level window above the active window.", - category: "Top-level windows", - balanced: &[], - power_user: &["k", "Up"], - }, - ShortcutBindingSpec { - id: "new_window_down", - label: "New window down", - detail: "Create a stacked top-level window below the active window.", - category: "Top-level windows", - balanced: &["g"], - power_user: &["g"], - }, - ShortcutBindingSpec { - id: "resize_window_left", - label: "Make window narrower", - detail: "Reduce the active column width.", - category: "Advanced resize", - balanced: &[], - power_user: &["Home"], - }, - ShortcutBindingSpec { - id: "resize_window_right", - label: "Make window wider", - detail: "Increase the active column width.", - category: "Advanced resize", - balanced: &[], - power_user: &["End"], - }, - ShortcutBindingSpec { - id: "resize_window_up", - label: "Make window shorter", - detail: "Reduce the active top-level window height.", - category: "Advanced resize", - balanced: &[], - power_user: &["Page_Up"], - }, - ShortcutBindingSpec { - id: "resize_window_down", - label: "Make window taller", - detail: "Increase the active top-level window height.", - category: "Advanced resize", - balanced: &[], - power_user: &["Page_Down"], - }, - ShortcutBindingSpec { - id: "resize_split_left", - label: "Make split narrower", - detail: "Reduce the active split width.", - category: "Advanced resize", - balanced: &[], - power_user: &["Home"], - }, - ShortcutBindingSpec { - id: "resize_split_right", - label: "Make split wider", - detail: "Increase the active split width.", - category: "Advanced resize", - balanced: &[], - power_user: &["End"], - }, - ShortcutBindingSpec { - id: "resize_split_up", - label: "Make split shorter", - detail: "Reduce the active split height.", - category: "Advanced resize", - balanced: &[], - power_user: &["Page_Up"], - }, - ShortcutBindingSpec { - id: "resize_split_down", - label: "Make split taller", - detail: "Increase the active split height.", - category: "Advanced resize", - balanced: &[], - power_user: &["Page_Down"], - }, - ShortcutBindingSpec { - id: "split_right", - label: "Split right", - detail: "Split the active pane to the right inside the current window.", - category: "Pane splits", - balanced: &["t"], - power_user: &["t"], - }, - ShortcutBindingSpec { - id: "split_down", - label: "Split down", - detail: "Split the active pane downward inside the current window.", - category: "Pane splits", - balanced: &["g"], - power_user: &["g"], - }, -]; - fn shortcut_bindings(preset: ShortcutPreset) -> Vec { - SHORTCUT_BINDINGS - .iter() - .map(|binding| ShortcutBindingSnapshot { - id: binding.id.into(), - label: binding.label.into(), - detail: binding.detail.into(), - category: binding.category.into(), - accelerators: match preset { - ShortcutPreset::Balanced => binding.balanced, - ShortcutPreset::PowerUser => binding.power_user, - } - .iter() - .map(|value| (*value).into()) - .collect(), + ShortcutAction::ALL + .into_iter() + .map(|action| ShortcutBindingSnapshot { + id: action.id().into(), + label: action.label().into(), + detail: action.detail().into(), + category: action.category().into(), + accelerators: action + .accelerators(preset) + .iter() + .map(|value| (*value).into()) + .collect(), }) .collect() } diff --git a/greenfield/crates/taskers/src/main.rs b/greenfield/crates/taskers/src/main.rs index 324027f..2d1f785 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/greenfield/crates/taskers/src/main.rs @@ -2,7 +2,7 @@ use adw::prelude::*; use anyhow::{Context, Result}; use axum::{Router, extract::ws::WebSocketUpgrade, response::Html, routing::get}; use clap::{Parser, ValueEnum}; -use gtk::glib; +use gtk::{EventControllerKey, gdk, glib}; use std::{ cell::{Cell, RefCell}, fs::{File, OpenOptions, remove_file}, @@ -16,12 +16,12 @@ use std::{ thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +use taskers_app_core::{AppState, load_or_bootstrap}; +use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; use taskers_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, - ShortcutPreset, SurfaceKind, + ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, }; -use taskers_app_core::{AppState, load_or_bootstrap}; -use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; use taskers_domain::AppModel; use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; @@ -137,7 +137,10 @@ fn build_ui_result( cli: Cli, ) -> Result<()> { let diagnostics = DiagnosticsWriter::from_cli(&cli); - log_runtime_status(diagnostics.as_ref(), &bootstrap.core.snapshot().runtime_status); + log_runtime_status( + diagnostics.as_ref(), + &bootstrap.core.snapshot().runtime_status, + ); let shell_url = launch_liveview_server(bootstrap.core.clone())?; let settings = WebKitSettings::builder() @@ -177,6 +180,7 @@ fn build_ui_result( }); let host_widget = host.borrow().widget(); window.set_content(Some(&host_widget)); + connect_navigation_shortcuts(&window, &shell_view, &core); for note in bootstrap.startup_notes { log_diagnostic( @@ -243,6 +247,106 @@ fn build_ui_result( Ok(()) } +fn normalize_shortcut_modifiers(state: gdk::ModifierType) -> gdk::ModifierType { + state + & (gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::SHIFT_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::META_MASK + | gdk::ModifierType::SUPER_MASK + | gdk::ModifierType::HYPER_MASK) +} + +fn is_modifier_key(key: gdk::Key) -> bool { + matches!( + key, + gdk::Key::Control_L + | gdk::Key::Control_R + | gdk::Key::Shift_L + | gdk::Key::Shift_R + | gdk::Key::Alt_L + | gdk::Key::Alt_R + | gdk::Key::Meta_L + | gdk::Key::Meta_R + | gdk::Key::Super_L + | gdk::Key::Super_R + | gdk::Key::Hyper_L + | gdk::Key::Hyper_R + ) +} + +fn shortcut_matches( + preset: ShortcutPreset, + action: ShortcutAction, + key: gdk::Key, + state: gdk::ModifierType, +) -> bool { + action + .accelerators(preset) + .iter() + .filter_map(|accelerator| gtk::accelerator_parse(*accelerator)) + .any(|(expected_key, expected_modifiers)| { + key == expected_key && normalize_shortcut_modifiers(state) == expected_modifiers + }) +} + +fn focus_active_browser_address(shell_view: &WebView) { + shell_view.evaluate_javascript( + "(() => { + const address = document.querySelector('.browser-address'); + if (!(address instanceof HTMLInputElement)) return false; + address.focus(); + address.select(); + return true; + })();", + None, + None, + None::<>k::gio::Cancellable>, + |_| {}, + ); +} + +fn connect_navigation_shortcuts( + window: &adw::ApplicationWindow, + shell_view: &WebView, + core: &SharedCore, +) { + let controller = EventControllerKey::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let shortcuts_core = core.clone(); + let shortcuts_shell = shell_view.clone(); + controller.connect_key_pressed(move |_, key, _, state| { + if is_modifier_key(key) { + return glib::Propagation::Proceed; + } + + let preset = shortcuts_core.selected_shortcut_preset(); + + if shortcut_matches(preset, ShortcutAction::FocusBrowserAddress, key, state) { + let snapshot = shortcuts_core.snapshot(); + if snapshot.section == ShellSection::Workspace && snapshot.browser_chrome.is_some() { + focus_active_browser_address(&shortcuts_shell); + return glib::Propagation::Stop; + } + return glib::Propagation::Proceed; + } + + for action in ShortcutAction::ALL { + if action == ShortcutAction::FocusBrowserAddress { + continue; + } + if shortcut_matches(preset, action, key, state) + && shortcuts_core.dispatch_shortcut_action(action) + { + return glib::Propagation::Stop; + } + } + + glib::Propagation::Proceed + }); + window.add_controller(controller); +} + fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result { let runtime = resolve_runtime_bootstrap(); let mut startup_notes = runtime.startup_notes; @@ -309,7 +413,7 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result glib::ExitCode { host } Err(error) => { - eprintln!("ghostty {} self-probe failed during host init: {error}", mode.as_arg()); + eprintln!( + "ghostty {} self-probe failed during host init: {error}", + mode.as_arg() + ); return glib::ExitCode::FAILURE; } }; @@ -454,7 +561,7 @@ fn run_internal_surface_probe( terminal_host: RuntimeCapability::Ready, }, selected_theme_id: "dark".into(), - selected_shortcut_preset: ShortcutPreset::Balanced, + selected_shortcut_preset: ShortcutPreset::PowerUser, }); core.set_window_size(PixelSize::new(1200, 800)); @@ -547,7 +654,10 @@ fn probe_ghostty_backend_process(mode: GhosttyProbeMode) -> Result<()> { Ok(None) => { let _ = child.kill(); let _ = child.wait(); - anyhow::bail!("Ghostty self-probe timed out; probe log: {}", log_path.display()); + anyhow::bail!( + "Ghostty self-probe timed out; probe log: {}", + log_path.display() + ); } Err(error) => { anyhow::bail!( @@ -681,8 +791,7 @@ fn spawn_control_server(app_state: AppState, socket_path: PathBuf) -> String { .dispatch(command) .map_err(|error| error.to_string()) }; - if let Err(error) = - serve_with_handler(listener, handler, pending::<()>()).await + if let Err(error) = serve_with_handler(listener, handler, pending::<()>()).await { eprintln!("control server error: {error}"); } @@ -705,7 +814,9 @@ fn launch_liveview_server(core: SharedCore) -> Result { listener .set_nonblocking(true) .context("failed to set loopback listener nonblocking")?; - let addr = listener.local_addr().context("failed to read loopback addr")?; + let addr = listener + .local_addr() + .context("failed to read loopback addr")?; let url = format!("http://{addr}/"); thread::spawn(move || { From e791c6fa4922440e116b4ea96f605880a5a5067f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 16:00:30 +0100 Subject: [PATCH 40/63] feat: add workspace window move primitives --- crates/taskers-control/src/controller.rs | 13 + crates/taskers-control/src/protocol.rs | 7 +- crates/taskers-domain/src/lib.rs | 2 +- crates/taskers-domain/src/model.rs | 324 ++++++++++++++++++++-- greenfield/crates/taskers-core/src/lib.rs | 132 ++++++++- 5 files changed, 444 insertions(+), 34 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 9f4ad3e..78b4e26 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -114,6 +114,19 @@ impl InMemoryController { true, ) } + ControlCommand::MoveWorkspaceWindow { + workspace_id, + workspace_window_id, + target, + } => { + model.move_workspace_window(workspace_id, workspace_window_id, target)?; + ( + ControlResponse::Ack { + message: "workspace window moved".into(), + }, + true, + ) + } ControlCommand::FocusPane { workspace_id, pane_id, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 3086f68..a790200 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use taskers_domain::{ AppModel, Direction, PaneId, PaneKind, PaneMetadataPatch, PersistedSession, SignalEvent, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceViewport, - WorkspaceWindowId, + WorkspaceWindowId, WorkspaceWindowMoveTarget, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -34,6 +34,11 @@ pub enum ControlCommand { workspace_id: WorkspaceId, workspace_window_id: WorkspaceWindowId, }, + MoveWorkspaceWindow { + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + target: WorkspaceWindowMoveTarget, + }, FocusPane { workspace_id: WorkspaceId, pane_id: PaneId, diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index c52e698..84ebea7 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -16,6 +16,6 @@ pub use model::{ PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceSummary, - WorkspaceViewport, WorkspaceWindowRecord, + WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 1d1ab2e..fa4a3d5 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -332,6 +332,15 @@ pub struct WorkspaceViewport { pub y: i32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceWindowMoveTarget { + ColumnBefore { column_id: WorkspaceColumnId }, + ColumnAfter { column_id: WorkspaceColumnId }, + StackAbove { window_id: WorkspaceWindowId }, + StackBelow { window_id: WorkspaceWindowId }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowFrame { pub x: i32, @@ -1318,11 +1327,6 @@ impl AppModel { .ok_or(DomainError::MissingWorkspace(workspace_id))?; let active_window_id = workspace.active_window; - if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) { - workspace.focus_window(next_window_id); - return Ok(()); - } - let next_pane = workspace .windows .get(&active_window_id) @@ -1332,11 +1336,130 @@ impl AppModel { window.active_pane = next_pane; } workspace.sync_active_from_window(active_window_id); + return Ok(()); + } + + if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) { + workspace.focus_window(next_window_id); } Ok(()) } + pub fn move_workspace_window( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + target: WorkspaceWindowMoveTarget, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + if !workspace.windows.contains_key(&workspace_window_id) { + return Err(DomainError::MissingWorkspaceWindow(workspace_window_id)); + } + + let (source_column_id, _source_column_index, source_window_index) = workspace + .position_for_window(workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + let source_window_count = workspace + .columns + .get(&source_column_id) + .map(|column| column.window_order.len()) + .unwrap_or_default(); + + match target { + WorkspaceWindowMoveTarget::ColumnBefore { column_id } + | WorkspaceWindowMoveTarget::ColumnAfter { column_id } => { + let place_after = matches!(target, WorkspaceWindowMoveTarget::ColumnAfter { .. }); + if !workspace.columns.contains_key(&column_id) { + return Err(DomainError::MissingWorkspaceColumn(column_id)); + } + if source_window_count <= 1 { + if source_column_id == column_id { + workspace.sync_active_from_window(workspace_window_id); + return Ok(()); + } + let source_column = workspace + .columns + .shift_remove(&source_column_id) + .ok_or(DomainError::MissingWorkspaceColumn(source_column_id))?; + let mut insert_index = workspace + .columns + .get_index_of(&column_id) + .ok_or(DomainError::MissingWorkspaceColumn(column_id))?; + if place_after { + insert_index += 1; + } + workspace.insert_column_at(insert_index, source_column); + } else { + remove_window_from_column(workspace, source_column_id, source_window_index)?; + let target_width = workspace + .columns + .get(&column_id) + .map(|column| column.width) + .ok_or(DomainError::MissingWorkspaceColumn(column_id))?; + let (retained_width, new_width) = + split_top_level_extent(target_width, MIN_WORKSPACE_WINDOW_WIDTH); + let target_column = workspace + .columns + .get_mut(&column_id) + .ok_or(DomainError::MissingWorkspaceColumn(column_id))?; + target_column.width = retained_width; + + let mut new_column = WorkspaceColumnRecord::new(workspace_window_id); + new_column.width = new_width; + let insert_index = workspace + .columns + .get_index_of(&column_id) + .ok_or(DomainError::MissingWorkspaceColumn(column_id))?; + workspace.insert_column_at( + if place_after { + insert_index + 1 + } else { + insert_index + }, + new_column, + ); + } + } + WorkspaceWindowMoveTarget::StackAbove { window_id } + | WorkspaceWindowMoveTarget::StackBelow { window_id } => { + let place_below = matches!(target, WorkspaceWindowMoveTarget::StackBelow { .. }); + if workspace_window_id == window_id { + workspace.sync_active_from_window(workspace_window_id); + return Ok(()); + } + let (target_column_id, _, _) = workspace + .position_for_window(window_id) + .ok_or(DomainError::MissingWorkspaceWindow(window_id))?; + + remove_window_from_column(workspace, source_column_id, source_window_index)?; + let (_, _, target_window_index) = workspace + .position_for_window(window_id) + .ok_or(DomainError::MissingWorkspaceWindow(window_id))?; + let insert_index = if place_below { + target_window_index + 1 + } else { + target_window_index + }; + let target_column = workspace + .columns + .get_mut(&target_column_id) + .ok_or(DomainError::MissingWorkspaceColumn(target_column_id))?; + target_column + .window_order + .insert(insert_index, workspace_window_id); + target_column.active_window = workspace_window_id; + } + } + + workspace.normalize(); + workspace.sync_active_from_window(workspace_window_id); + Ok(()) + } + pub fn resize_active_window( &mut self, workspace_id: WorkspaceId, @@ -2166,6 +2289,36 @@ fn workspace_agent_state( } } +fn remove_window_from_column( + workspace: &mut Workspace, + column_id: WorkspaceColumnId, + window_index: usize, +) -> Result<(), DomainError> { + let remove_column = { + let column = workspace + .columns + .get_mut(&column_id) + .ok_or(DomainError::MissingWorkspaceColumn(column_id))?; + if window_index >= column.window_order.len() { + return Err(DomainError::MissingWorkspaceColumn(column_id)); + } + column.window_order.remove(window_index); + if column.window_order.is_empty() { + true + } else { + if !column.window_order.contains(&column.active_window) { + let replacement_index = window_index.min(column.window_order.len() - 1); + column.active_window = column.window_order[replacement_index]; + } + false + } + }; + if remove_column { + workspace.columns.shift_remove(&column_id); + } + Ok(()) +} + fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Option { let fallback = [ Direction::Right, @@ -2346,29 +2499,22 @@ mod tests { } #[test] - fn directional_focus_prefers_top_level_windows_and_restores_inner_focus() { + fn directional_focus_prefers_inner_split_before_neighboring_window() { let mut model = AppModel::new("Main"); let workspace_id = model.active_workspace_id().expect("workspace"); let first_pane = model .active_workspace() .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id)) .expect("pane"); + let split_right_pane = model + .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal) + .expect("split"); let right_window_pane = model .create_workspace_window(workspace_id, Direction::Right) .expect("window"); - let lower_window_pane = model - .create_workspace_window(workspace_id, Direction::Down) - .expect("window"); - let lower_right_pane = model - .split_pane(workspace_id, Some(lower_window_pane), SplitAxis::Vertical) - .expect("split"); - - model - .focus_pane(workspace_id, lower_window_pane) - .expect("focus lower window"); model .focus_pane(workspace_id, first_pane) - .expect("focus left window"); + .expect("focus first pane"); model .focus_pane_direction(workspace_id, Direction::Right) .expect("move right"); @@ -2379,15 +2525,12 @@ mod tests { .get(&workspace_id) .expect("workspace") .active_pane, - lower_window_pane + split_right_pane ); model - .focus_pane(workspace_id, lower_right_pane) - .expect("focus lower pane"); - model - .focus_pane_direction(workspace_id, Direction::Up) - .expect("move up"); + .focus_pane_direction(workspace_id, Direction::Right) + .expect("move right again"); assert_eq!( model .workspaces @@ -2397,15 +2540,9 @@ mod tests { right_window_pane ); - model - .focus_pane_direction(workspace_id, Direction::Down) - .expect("move down again"); model .focus_pane_direction(workspace_id, Direction::Left) .expect("move left"); - model - .focus_pane_direction(workspace_id, Direction::Right) - .expect("move right again"); assert_eq!( model @@ -2413,7 +2550,7 @@ mod tests { .get(&workspace_id) .expect("workspace") .active_pane, - lower_right_pane + split_right_pane ); } @@ -2444,6 +2581,133 @@ mod tests { assert_eq!(right_column.window_order.len(), 1); } + #[test] + fn moving_single_window_column_reorders_columns_and_preserves_width() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_window_id = model.active_workspace().expect("workspace").active_window; + let right_window_pane = model + .create_workspace_window(workspace_id, Direction::Right) + .expect("window"); + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let right_window_id = workspace + .window_for_pane(right_window_pane) + .expect("right window id"); + let left_column_id = workspace + .column_for_window(first_window_id) + .expect("left column"); + let right_column_id = workspace + .column_for_window(right_window_id) + .expect("right column"); + let _ = workspace; + + model + .set_workspace_column_width( + workspace_id, + right_column_id, + DEFAULT_WORKSPACE_WINDOW_WIDTH + 240, + ) + .expect("set width"); + model + .move_workspace_window( + workspace_id, + right_window_id, + WorkspaceWindowMoveTarget::ColumnBefore { + column_id: left_column_id, + }, + ) + .expect("move window"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let ordered_columns = workspace.columns.values().collect::>(); + assert_eq!(ordered_columns.len(), 2); + assert_eq!(ordered_columns[0].window_order, vec![right_window_id]); + assert_eq!( + ordered_columns[0].width, + DEFAULT_WORKSPACE_WINDOW_WIDTH + 240 + ); + assert_eq!(ordered_columns[1].window_order, vec![first_window_id]); + assert_eq!(workspace.active_window, right_window_id); + } + + #[test] + fn moving_stacked_window_sideways_creates_a_new_column() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_window_id = model.active_workspace().expect("workspace").active_window; + let lower_window_pane = model + .create_workspace_window(workspace_id, Direction::Down) + .expect("window"); + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let lower_window_id = workspace + .window_for_pane(lower_window_pane) + .expect("lower window id"); + let source_column_id = workspace + .column_for_window(first_window_id) + .expect("source column"); + let _ = workspace; + + model + .set_workspace_column_width( + workspace_id, + source_column_id, + DEFAULT_WORKSPACE_WINDOW_WIDTH + 400, + ) + .expect("set width"); + model + .move_workspace_window( + workspace_id, + lower_window_id, + WorkspaceWindowMoveTarget::ColumnAfter { + column_id: source_column_id, + }, + ) + .expect("move window"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let ordered_columns = workspace.columns.values().collect::>(); + assert_eq!(ordered_columns.len(), 2); + assert_eq!(ordered_columns[0].window_order, vec![first_window_id]); + assert_eq!(ordered_columns[0].width, 840); + assert_eq!(ordered_columns[1].window_order, vec![lower_window_id]); + assert_eq!(ordered_columns[1].width, 840); + assert_eq!(workspace.active_window, lower_window_id); + } + + #[test] + fn moving_window_into_stack_removes_empty_source_column() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_window_id = model.active_workspace().expect("workspace").active_window; + let right_window_pane = model + .create_workspace_window(workspace_id, Direction::Right) + .expect("window"); + let right_window_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.window_for_pane(right_window_pane)) + .expect("right window id"); + + model + .move_workspace_window( + workspace_id, + right_window_id, + WorkspaceWindowMoveTarget::StackBelow { + window_id: first_window_id, + }, + ) + .expect("stack window"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let only_column = workspace.columns.values().next().expect("single column"); + assert_eq!(workspace.columns.len(), 1); + assert_eq!( + only_column.window_order, + vec![first_window_id, right_window_id] + ); + assert_eq!(workspace.active_window, right_window_id); + } + #[test] fn moving_surface_reorders_pane_without_changing_active_surface() { let mut model = AppModel::new("Main"); diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 0f91f79..5176c75 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -18,7 +18,10 @@ use taskers_runtime::ShellLaunchSpec; use time::OffsetDateTime; use tokio::sync::watch; -pub use taskers_domain::{PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId}; +pub use taskers_domain::{ + PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + WorkspaceWindowMoveTarget, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ActivityId { @@ -940,6 +943,10 @@ pub enum ShellAction { FocusWorkspaceWindow { window_id: WorkspaceWindowId, }, + MoveWorkspaceWindow { + window_id: WorkspaceWindowId, + target: WorkspaceWindowMoveTarget, + }, ScrollViewport { dx: i32, dy: i32, @@ -1680,6 +1687,9 @@ impl TaskersCore { ShellAction::FocusWorkspaceWindow { window_id } => { self.focus_workspace_window(window_id) } + ShellAction::MoveWorkspaceWindow { window_id, target } => { + self.move_workspace_window_by_id(window_id, target) + } ShellAction::ScrollViewport { dx, dy } => self.scroll_viewport_by(dx, dy), ShellAction::SplitBrowser { pane_id } => { self.split_with_kind_axis(pane_id, PaneKind::Browser, DomainSplitAxis::Horizontal) @@ -1819,7 +1829,16 @@ impl TaskersCore { ShortcutAction::MoveWindowLeft | ShortcutAction::MoveWindowRight | ShortcutAction::MoveWindowUp - | ShortcutAction::MoveWindowDown => false, + | ShortcutAction::MoveWindowDown => self.run_workspace_shortcut(|core, _| { + let direction = match action { + ShortcutAction::MoveWindowLeft => Direction::Left, + ShortcutAction::MoveWindowRight => Direction::Right, + ShortcutAction::MoveWindowUp => Direction::Up, + ShortcutAction::MoveWindowDown => Direction::Down, + _ => unreachable!("move action already matched"), + }; + Some(core.move_active_workspace_window(direction)) + }), ShortcutAction::ResizeWindowLeft => { self.run_workspace_shortcut(|core, workspace_id| { Some(core.dispatch_control(ControlCommand::ResizeActiveWindow { @@ -1948,6 +1967,22 @@ impl TaskersCore { }) } + fn move_workspace_window_by_id( + &mut self, + window_id: WorkspaceWindowId, + target: WorkspaceWindowMoveTarget, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::MoveWorkspaceWindow { + workspace_id, + workspace_window_id: window_id, + target, + }) + } + fn scroll_viewport_by(&mut self, dx: i32, dy: i32) -> bool { let model = self.app_state.snapshot_model(); let Some(workspace_id) = model.active_workspace_id() else { @@ -2147,6 +2182,99 @@ impl TaskersCore { self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { surface_id, patch }) } + fn move_active_workspace_window(&mut self, direction: Direction) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let Some(workspace) = model.workspaces.get(&workspace_id) else { + return false; + }; + let active_window_id = workspace.active_window; + let Some((active_column_id, active_column_index, active_window_index)) = workspace + .columns + .iter() + .enumerate() + .find_map(|(column_index, (column_id, column))| { + column + .window_order + .iter() + .position(|candidate| *candidate == active_window_id) + .map(|window_index| (*column_id, column_index, window_index)) + }) + else { + return false; + }; + + let target = match direction { + Direction::Left => { + if let Some((column_id, _)) = active_column_index + .checked_sub(1) + .and_then(|index| workspace.columns.get_index(index)) + { + WorkspaceWindowMoveTarget::ColumnBefore { + column_id: *column_id, + } + } else if workspace + .columns + .get(&active_column_id) + .is_some_and(|column| column.window_order.len() > 1) + { + WorkspaceWindowMoveTarget::ColumnBefore { + column_id: active_column_id, + } + } else { + return false; + } + } + Direction::Right => { + if let Some((column_id, _)) = workspace.columns.get_index(active_column_index + 1) { + WorkspaceWindowMoveTarget::ColumnAfter { + column_id: *column_id, + } + } else if workspace + .columns + .get(&active_column_id) + .is_some_and(|column| column.window_order.len() > 1) + { + WorkspaceWindowMoveTarget::ColumnAfter { + column_id: active_column_id, + } + } else { + return false; + } + } + Direction::Up => { + let Some(window_id) = workspace + .columns + .get(&active_column_id) + .and_then(|column| { + active_window_index + .checked_sub(1) + .and_then(|index| column.window_order.get(index)) + }) + .copied() + else { + return false; + }; + WorkspaceWindowMoveTarget::StackAbove { window_id } + } + Direction::Down => { + let Some(window_id) = workspace + .columns + .get(&active_column_id) + .and_then(|column| column.window_order.get(active_window_index + 1)) + .copied() + else { + return false; + }; + WorkspaceWindowMoveTarget::StackBelow { window_id } + } + }; + + self.move_workspace_window_by_id(active_window_id, target) + } + fn run_workspace_shortcut( &mut self, handler: impl FnOnce(&mut Self, WorkspaceId) -> Option, From 30d69714d0dffa6c38a8a9702d47fedade06e103 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 16:06:22 +0100 Subject: [PATCH 41/63] feat: polish greenfield tiling interactions --- greenfield/crates/taskers-core/src/lib.rs | 13 +- greenfield/crates/taskers-shell/src/lib.rs | 283 +++++++++++-------- greenfield/crates/taskers-shell/src/theme.rs | 192 ++++++------- 3 files changed, 262 insertions(+), 226 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 5176c75..717f52a 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -19,8 +19,7 @@ use time::OffsetDateTime; use tokio::sync::watch; pub use taskers_domain::{ - PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, - WorkspaceWindowMoveTarget, + PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, WorkspaceWindowMoveTarget, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -581,6 +580,7 @@ pub struct LayoutMetrics { pub window_body_padding: i32, pub split_gap: i32, pub pane_header_height: i32, + pub browser_toolbar_height: i32, pub surface_tab_height: i32, } @@ -589,12 +589,13 @@ impl Default for LayoutMetrics { Self { sidebar_width: 248, activity_width: 312, - toolbar_height: 48, + toolbar_height: 42, workspace_padding: 16, - window_toolbar_height: 34, + window_toolbar_height: 28, window_body_padding: 0, split_gap: 12, - pane_header_height: 34, + pane_header_height: 28, + browser_toolbar_height: 34, surface_tab_height: 0, } } @@ -2960,7 +2961,7 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio: u16, gap: i32) -> (Frame, F fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Frame { let browser_toolbar_height = match kind { PaneKind::Terminal => 0, - PaneKind::Browser => 38, + PaneKind::Browser => metrics.browser_toolbar_height, }; frame .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index b18b157..c8836eb 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -4,10 +4,9 @@ use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, - SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, - ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, - WorkspaceDirection, WorkspaceId, WorkspaceSummary, WorkspaceViewSnapshot, - WorkspaceWindowSnapshot, + SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, + ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, + WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -16,6 +15,11 @@ struct DraggedSurface { surface_id: SurfaceId, } +#[derive(Clone, Copy, PartialEq, Eq)] +struct DraggedWindow { + window_id: taskers_core::WorkspaceWindowId, +} + #[derive(Clone, Copy, PartialEq, Eq)] enum SurfaceDropTarget { PaneEnd { @@ -93,14 +97,6 @@ pub fn TaskersShell(core: SharedCore) -> Element { }) } }; - let show_workspace_header = { - let core = core.clone(); - move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Workspace, - }) - } - }; let show_settings_nav = { let core = core.clone(); move |_| { @@ -109,59 +105,23 @@ pub fn TaskersShell(core: SharedCore) -> Element { }) } }; - let show_settings_header = { - let core = core.clone(); - move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Settings, - }) - } - }; let create_workspace = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::CreateWorkspace) }; - let split_terminal = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: None }) - }; - let split_browser = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }) - }; - let toggle_overview = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ToggleOverview) - }; - let scroll_left = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ScrollViewport { dx: -360, dy: 0 }) - }; - let scroll_right = { - let core = core.clone(); - move |_| core.dispatch_shell_action(ShellAction::ScrollViewport { dx: 360, dy: 0 }) - }; - let create_window_right = { + let show_active_section_header = { let core = core.clone(); + let section = snapshot.section; move |_| { - core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { - direction: WorkspaceDirection::Right, - }) + core.dispatch_shell_action(ShellAction::ShowSection { section }); } }; - let create_window_down = { - let core = core.clone(); - move |_| { - core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { - direction: WorkspaceDirection::Down, - }) - } - }; - let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); let surface_drag_source = use_signal(|| None::); let surface_drop_target = use_signal(|| None::); + let window_drag_source = use_signal(|| None::); + let window_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); let main_class = match snapshot.section { @@ -216,46 +176,13 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "workspace-header-main", button { class: "workspace-header-title-btn", - onclick: show_workspace_header, - span { class: "workspace-header-label", "{snapshot.current_workspace.title}" } - span { class: "workspace-header-meta", - "{snapshot.current_workspace.pane_count} panes · {snapshot.current_workspace.surface_count} surfaces" - } - } - } - div { class: "workspace-header-actions", - if matches!(snapshot.section, ShellSection::Workspace) { - div { class: "workspace-header-group", - button { - class: if snapshot.overview_mode { - "workspace-header-action workspace-header-action-active" - } else { - "workspace-header-action" - }, - onclick: toggle_overview, - "◫" + onclick: show_active_section_header, + span { class: "workspace-header-label", + if matches!(snapshot.section, ShellSection::Workspace) { + "{snapshot.current_workspace.title}" + } else { + "Settings" } - if !snapshot.overview_mode { - button { class: "workspace-header-action", onclick: scroll_left, "◀" } - button { class: "workspace-header-action", onclick: scroll_right, "▶" } - } - } - div { class: "workspace-header-divider" } - div { class: "workspace-header-group", - button { class: "workspace-header-action", onclick: create_window_right, "+ col" } - button { class: "workspace-header-action", onclick: create_window_down, "+ stack" } - button { class: "workspace-header-action", onclick: split_terminal, "+ split" } - button { - class: "workspace-header-action workspace-header-action-primary", - onclick: split_browser, - "+ browser" - } - } - } else { - button { - class: "workspace-header-action workspace-header-action-active", - onclick: show_settings_header, - "Preferences" } } } @@ -270,6 +197,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { &snapshot.runtime_status, surface_drag_source, surface_drop_target, + window_drag_source, + window_drop_target, )} } } else { @@ -571,6 +500,8 @@ fn render_workspace_strip( runtime_status: &RuntimeStatus, surface_drag_source: Signal>, surface_drop_target: Signal>, + window_drag_source: Signal>, + window_drop_target: Signal>, ) -> Element { let viewport_class = if workspace.overview_scale < 1.0 { "workspace-viewport workspace-viewport-overview" @@ -612,6 +543,8 @@ fn render_workspace_strip( runtime_status, surface_drag_source, surface_drop_target, + window_drag_source, + window_drop_target, )} } } @@ -626,8 +559,10 @@ fn render_workspace_window( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, - surface_drop_target: Signal>, + mut surface_drag_source: Signal>, + mut surface_drop_target: Signal>, + mut window_drag_source: Signal>, + window_drop_target: Signal>, ) -> Element { let local_x = window.frame.x - workspace.viewport_origin_x; let local_y = window.frame.y - workspace.viewport_origin_y; @@ -636,28 +571,81 @@ fn render_workspace_window( local_x, local_y, window.frame.width, window.frame.height ); let window_class = if window.active { - format!( - "workspace-window-shell workspace-window-shell-active workspace-window-shell-state-{}", - window.attention.slug() - ) + "workspace-window-shell workspace-window-shell-active" } else { - format!( - "workspace-window-shell workspace-window-shell-state-{}", - window.attention.slug() - ) + "workspace-window-shell" }; let window_id = window.id; let focus_core = core.clone(); let focus_window = move |_| { focus_core.dispatch_shell_action(ShellAction::FocusWorkspaceWindow { window_id }); }; + let start_window_drag = move |_: Event| { + surface_drag_source.set(None); + surface_drop_target.set(None); + window_drag_source.set(Some(DraggedWindow { window_id })); + }; + let clear_window_drag = { + let mut window_drag_source = window_drag_source; + let mut window_drop_target = window_drop_target; + let mut surface_drop_target = surface_drop_target; + move |_: Event| { + window_drag_source.set(None); + window_drop_target.set(None); + surface_drop_target.set(None); + } + }; + let drag_active = window_drag_source.read().is_some(); + let left_target = WorkspaceWindowMoveTarget::ColumnBefore { + column_id: window.column_id, + }; + let right_target = WorkspaceWindowMoveTarget::ColumnAfter { + column_id: window.column_id, + }; + let top_target = WorkspaceWindowMoveTarget::StackAbove { window_id }; + let bottom_target = WorkspaceWindowMoveTarget::StackBelow { window_id }; rsx! { section { class: "{window_class}", style: "{style}", + {render_window_drop_zone( + "workspace-window-drop-zone workspace-window-drop-zone-left", + left_target, + drag_active, + window_drag_source, + window_drop_target, + core.clone(), + )} + {render_window_drop_zone( + "workspace-window-drop-zone workspace-window-drop-zone-right", + right_target, + drag_active, + window_drag_source, + window_drop_target, + core.clone(), + )} + {render_window_drop_zone( + "workspace-window-drop-zone workspace-window-drop-zone-top", + top_target, + drag_active, + window_drag_source, + window_drop_target, + core.clone(), + )} + {render_window_drop_zone( + "workspace-window-drop-zone workspace-window-drop-zone-bottom", + bottom_target, + drag_active, + window_drag_source, + window_drop_target, + core.clone(), + )} div { class: "workspace-window-toolbar", - button { class: "workspace-window-title", onclick: focus_window, + draggable: "true", + onclick: focus_window, + ondragstart: start_window_drag, + ondragend: clear_window_drag, + div { class: "workspace-window-title", span { class: "workspace-label", "{window.title}" } - span { class: "workspace-meta", "{window.pane_count} panes · {window.surface_count} surfaces" } } } div { class: "workspace-window-body", @@ -674,6 +662,60 @@ fn render_workspace_window( } } +fn render_window_drop_zone( + base_class: &'static str, + target: WorkspaceWindowMoveTarget, + visible: bool, + mut window_drag_source: Signal>, + mut window_drop_target: Signal>, + core: SharedCore, +) -> Element { + let class = if *window_drop_target.read() == Some(target) { + format!("{base_class} workspace-window-drop-zone-active") + } else if visible { + format!("{base_class} workspace-window-drop-zone-visible") + } else { + base_class.to_string() + }; + let set_drop_target = move |event: Event| { + if window_drag_source.read().is_none() { + return; + } + event.prevent_default(); + window_drop_target.set(Some(target)); + }; + let clear_drop_target = move |_: Event| { + if *window_drop_target.read() == Some(target) { + window_drop_target.set(None); + } + }; + let drop_window = move |event: Event| { + if window_drag_source.read().is_none() { + return; + } + event.prevent_default(); + let dragged = *window_drag_source.read(); + window_drag_source.set(None); + window_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + core.dispatch_shell_action(ShellAction::MoveWorkspaceWindow { + window_id: dragged.window_id, + target, + }); + }; + + rsx! { + div { + class: "{class}", + ondragover: set_drop_target, + ondragleave: clear_drop_target, + ondrop: drop_window, + } + } +} + fn render_pane( pane: &PaneSnapshot, browser_chrome: Option<&BrowserChromeSnapshot>, @@ -691,8 +733,7 @@ fn render_pane( ); let pane_class = if pane.active { format!( - "pane-card pane-card-active pane-card-state-{}{}", - pane.attention.slug(), + "pane-card pane-card-active{}", if pane_is_drop_target { " pane-card-drop-target" } else { @@ -701,8 +742,7 @@ fn render_pane( ) } else { format!( - "pane-card pane-card-state-{}{}", - pane.attention.slug(), + "pane-card{}", if pane_is_drop_target { " pane-card-drop-target" } else { @@ -759,20 +799,19 @@ fn render_pane( }) } }; - let split_browser = { + let split_terminal = { let core = core.clone(); move |_| { - core.dispatch_shell_action(ShellAction::SplitBrowser { + core.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: Some(pane_id), }) } }; - let split_terminal = { + let split_down = { let core = core.clone(); move |_| { - core.dispatch_shell_action(ShellAction::SplitTerminal { - pane_id: Some(pane_id), - }) + core.dispatch_shell_action(ShellAction::FocusPane { pane_id }); + core.dispatch_shortcut_action(ShortcutAction::SplitDown); } }; let close_surface = { @@ -797,6 +836,9 @@ fn render_pane( "Close current surface" }; let set_pane_drop_target = move |event: Event| { + if surface_drag_source.read().is_none() { + return; + } event.prevent_default(); surface_drop_target.set(Some(SurfaceDropTarget::PaneEnd { pane_id })); }; @@ -852,8 +894,8 @@ fn render_pane( div { class: "pane-action-cluster", button { class: "pane-utility pane-utility-tab", title: "New terminal tab", onclick: add_terminal_surface, "+t" } button { class: "pane-utility pane-utility-tab", title: "New browser tab", onclick: add_browser_surface, "+w" } - button { class: "pane-utility pane-utility-split", title: "Split terminal right", onclick: split_terminal, "|t" } - button { class: "pane-utility pane-utility-window", title: "Split browser right", onclick: split_browser, "|w" } + button { class: "pane-utility pane-utility-split", title: "Split right", onclick: split_terminal, "|r" } + button { class: "pane-utility pane-utility-split", title: "Split down", onclick: split_down, "|d" } button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, "x" } } } @@ -896,8 +938,7 @@ fn render_surface_tab( ); let tab_class = if surface.id == active_surface_id { format!( - "surface-tab surface-tab-active surface-tab-state-{}{}", - surface.attention.slug(), + "surface-tab surface-tab-active{}", if is_drop_target { " surface-tab-drop-target" } else { @@ -906,8 +947,7 @@ fn render_surface_tab( ) } else { format!( - "surface-tab surface-tab-state-{}{}", - surface.attention.slug(), + "surface-tab{}", if is_drop_target { " surface-tab-drop-target" } else { @@ -933,6 +973,9 @@ fn render_surface_tab( surface_drop_target.set(None); }; let set_surface_drop_target = move |event: Event| { + if surface_drag_source.read().is_none() { + return; + } event.prevent_default(); surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { pane_id, diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index ab44632..f5a8a77 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -1,4 +1,5 @@ use std::fmt::Write as _; +use taskers_core::LayoutMetrics; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Color { @@ -170,6 +171,14 @@ fn rgba(color: Color, alpha: f32) -> String { } pub fn generate_css(p: &ThemePalette) -> String { + let metrics = LayoutMetrics::default(); + let sidebar_width = metrics.sidebar_width; + let activity_width = metrics.activity_width; + let workspace_toolbar_height = metrics.toolbar_height; + let window_toolbar_height = metrics.window_toolbar_height; + let pane_header_height = metrics.pane_header_height; + let browser_toolbar_height = metrics.browser_toolbar_height; + let split_gap = metrics.split_gap; let mut css = String::with_capacity(18_000); let _ = write!( css, @@ -196,7 +205,7 @@ button {{ height: 100vh; background: {base}; display: grid; - grid-template-columns: 248px minmax(0, 1fr) 312px; + grid-template-columns: {sidebar_width}px minmax(0, 1fr) {activity_width}px; overflow: hidden; }} @@ -635,13 +644,13 @@ button {{ }} .workspace-header {{ - height: 48px; - min-height: 48px; + height: {workspace_toolbar_height}px; + min-height: {workspace_toolbar_height}px; border-bottom: 1px solid {border_07}; padding: 0 12px; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 10px; background: {base}; }} @@ -682,25 +691,26 @@ button {{ .workspace-header-main, .shortcut-row {{ - justify-content: space-between; + justify-content: flex-start; }} .workspace-header-title-btn {{ background: transparent; border: 0; color: inherit; - padding: 6px 8px; + padding: 4px 0; text-align: left; }} .workspace-header-title-btn:hover {{ - background: {border_06}; + color: {text_bright}; }} .workspace-header-label {{ display: block; font-weight: 600; - font-size: 14px; + font-size: 13px; + letter-spacing: 0.02em; color: {text_bright}; }} @@ -799,7 +809,7 @@ button {{ display: flex; flex-direction: column; background: {elevated}; - border: 1px solid {border_08}; + border: 1px solid {border_07}; overflow: hidden; }} @@ -807,53 +817,27 @@ button {{ border-color: {accent_24}; }} -.workspace-window-shell-state-busy {{ - border-color: {busy_12}; -}} - -.workspace-window-shell-state-completed {{ - border-color: {completed_12}; -}} - -.workspace-window-shell-state-waiting {{ - border-color: {waiting_14}; -}} - -.workspace-window-shell-state-error {{ - border-color: {error_12}; -}} - .workspace-window-toolbar {{ - min-height: 34px; + height: {window_toolbar_height}px; + min-height: {window_toolbar_height}px; border-bottom: 1px solid {border_07}; background: {surface}; padding: 0 8px; display: flex; align-items: center; - justify-content: space-between; - gap: 8px; + justify-content: flex-start; + gap: 6px; + cursor: grab; + user-select: none; }} .workspace-window-title {{ - background: transparent; - border: 0; color: inherit; - padding: 0; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - text-align: left; -}} - -.workspace-window-title:hover {{ - color: {text_bright}; + min-width: 0; }} -.workspace-window-flags {{ - display: flex; - align-items: center; - gap: 6px; +.workspace-window-toolbar:active {{ + cursor: grabbing; }} .workspace-window-body {{ @@ -869,7 +853,7 @@ button {{ min-width: 0; min-height: 0; display: flex; - gap: 12px; + gap: {split_gap}px; }} .split-child {{ @@ -898,22 +882,6 @@ button {{ border-color: {accent_24}; }} -.pane-card-state-busy {{ - box-shadow: inset 0 0 0 1px {busy_10}; -}} - -.pane-card-state-completed {{ - box-shadow: inset 0 0 0 1px {completed_10}; -}} - -.pane-card-state-waiting {{ - box-shadow: inset 0 0 0 1px {waiting_12}; -}} - -.pane-card-state-error {{ - box-shadow: inset 0 0 0 1px {error_10}; -}} - @keyframes focus-flash {{ 0% {{ opacity: 0; }} 25% {{ opacity: 1; }} @@ -936,13 +904,14 @@ button {{ }} .pane-header {{ - min-height: 34px; + height: {pane_header_height}px; + min-height: {pane_header_height}px; border-bottom: 1px solid {border_07}; - padding: 0 6px 0 8px; + padding: 0 4px 0 6px; display: flex; align-items: center; justify-content: space-between; - gap: 6px; + gap: 4px; background: {surface}; }} @@ -957,7 +926,7 @@ button {{ flex: 0 0 auto; display: flex; align-items: center; - gap: 3px; + gap: 2px; opacity: 0; pointer-events: none; transition: opacity 0.14s ease-in-out; @@ -971,10 +940,10 @@ button {{ .surface-tabs {{ flex: 1; min-width: 0; - min-height: 34px; + min-height: {pane_header_height}px; display: flex; align-items: center; - gap: 2px; + gap: 1px; padding: 0; overflow-x: auto; background: transparent; @@ -983,13 +952,13 @@ button {{ .surface-tab {{ display: inline-flex; align-items: center; - gap: 6px; + gap: 5px; min-width: 0; max-width: 320px; - height: 26px; + height: 22px; border: 1px solid transparent; background: transparent; - padding: 0 8px; + padding: 0 6px; color: {text_muted}; white-space: nowrap; }} @@ -1014,22 +983,6 @@ button {{ color: {text_bright}; }} -.surface-tab-state-busy {{ - box-shadow: inset 0 0 0 1px {busy_10}; -}} - -.surface-tab-state-completed {{ - box-shadow: inset 0 0 0 1px {completed_10}; -}} - -.surface-tab-state-waiting {{ - box-shadow: inset 0 0 0 1px {waiting_10}; -}} - -.surface-tab-state-error {{ - box-shadow: inset 0 0 0 1px {error_10}; -}} - .surface-tab-title {{ min-width: 0; overflow: hidden; @@ -1051,13 +1004,13 @@ button {{ }} .pane-utility {{ - min-width: 24px; - height: 22px; + min-width: 22px; + height: 20px; border: 0; - padding: 0 6px; + padding: 0 5px; background: transparent; color: {text_dim}; - font-size: 11px; + font-size: 10px; font-family: "IBM Plex Mono", ui-monospace, monospace; line-height: 1; }} @@ -1067,16 +1020,58 @@ button {{ color: {text_bright}; }} -.pane-utility-tab {{ +.pane-utility-split {{ color: {text_subtle}; }} -.pane-utility-split {{ - color: {completed}; +.workspace-window-drop-zone {{ + position: absolute; + z-index: 12; + opacity: 0; + pointer-events: none; + background: transparent; + transition: opacity 0.12s ease-in-out, background 0.12s ease-in-out; +}} + +.workspace-window-drop-zone-visible {{ + opacity: 0.45; + pointer-events: auto; +}} + +.workspace-window-drop-zone-active {{ + opacity: 1; + pointer-events: auto; + background: {accent_24}; +}} + +.workspace-window-drop-zone-left, +.workspace-window-drop-zone-right {{ + top: 10px; + bottom: 10px; + width: 6px; +}} + +.workspace-window-drop-zone-left {{ + left: 0; +}} + +.workspace-window-drop-zone-right {{ + right: 0; }} -.pane-utility-window {{ - color: {action_window}; +.workspace-window-drop-zone-top, +.workspace-window-drop-zone-bottom {{ + left: 12px; + right: 12px; + height: 6px; +}} + +.workspace-window-drop-zone-top {{ + top: 0; +}} + +.workspace-window-drop-zone-bottom {{ + bottom: 0; }} .pane-utility-close {{ @@ -1089,7 +1084,8 @@ button {{ }} .browser-toolbar {{ - min-height: 34px; + height: {browser_toolbar_height}px; + min-height: {browser_toolbar_height}px; border-bottom: 1px solid {border_06}; display: flex; align-items: center; @@ -1587,18 +1583,15 @@ button {{ accent_22 = rgba(p.accent, 0.22), accent_24 = rgba(p.accent, 0.24), busy = p.busy.to_hex(), - busy_10 = rgba(p.busy, 0.10), busy_12 = rgba(p.busy, 0.12), busy_16 = rgba(p.busy, 0.16), busy_text = p.busy_text.to_hex(), completed = p.completed.to_hex(), - completed_10 = rgba(p.completed, 0.10), completed_12 = rgba(p.completed, 0.12), completed_16 = rgba(p.completed, 0.16), completed_text = p.completed_text.to_hex(), waiting = p.waiting.to_hex(), waiting_10 = rgba(p.waiting, 0.10), - waiting_12 = rgba(p.waiting, 0.12), waiting_14 = rgba(p.waiting, 0.14), waiting_18 = rgba(p.waiting, 0.18), waiting_text = p.waiting_text.to_hex(), @@ -1607,7 +1600,6 @@ button {{ error_12 = rgba(p.error, 0.12), error_16 = rgba(p.error, 0.16), error_text = p.error_text.to_hex(), - action_window = p.action_window.to_hex(), ); css } From 67ec5cd9c617173bdea112530f5879d5d91ee930 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 17:10:05 +0100 Subject: [PATCH 42/63] refactor: add side-aware pane split and transfer primitives --- crates/taskers-control/src/controller.rs | 35 +++ crates/taskers-control/src/protocol.rs | 15 ++ crates/taskers-domain/src/layout.rs | 33 ++- crates/taskers-domain/src/model.rs | 270 ++++++++++++++++++++++- 4 files changed, 347 insertions(+), 6 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 78b4e26..65d8d8a 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -90,6 +90,20 @@ impl InMemoryController { true, ) } + ControlCommand::SplitPaneDirection { + workspace_id, + pane_id, + direction, + } => { + let new_pane_id = + model.split_pane_direction(workspace_id, Some(pane_id), direction)?; + ( + ControlResponse::PaneSplit { + pane_id: new_pane_id, + }, + true, + ) + } ControlCommand::CreateWorkspaceWindow { workspace_id, direction, @@ -317,6 +331,27 @@ impl InMemoryController { true, ) } + ControlCommand::MoveSurfaceToSplit { + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + direction, + } => { + let new_pane_id = model.move_surface_to_split( + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + direction, + )?; + ( + ControlResponse::SurfaceMovedToSplit { + pane_id: new_pane_id, + }, + true, + ) + } ControlCommand::SetWorkspaceViewport { workspace_id, viewport, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index a790200..06b2b46 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -26,6 +26,11 @@ pub enum ControlCommand { pane_id: Option, axis: SplitAxis, }, + SplitPaneDirection { + workspace_id: WorkspaceId, + pane_id: PaneId, + direction: Direction, + }, CreateWorkspaceWindow { workspace_id: WorkspaceId, direction: Direction, @@ -114,6 +119,13 @@ pub enum ControlCommand { target_pane_id: PaneId, to_index: usize, }, + MoveSurfaceToSplit { + workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + direction: Direction, + }, SetWorkspaceViewport { workspace_id: WorkspaceId, viewport: WorkspaceViewport, @@ -161,6 +173,9 @@ pub enum ControlResponse { PaneSplit { pane_id: PaneId, }, + SurfaceMovedToSplit { + pane_id: PaneId, + }, SurfaceCreated { surface_id: SurfaceId, }, diff --git a/crates/taskers-domain/src/layout.rs b/crates/taskers-domain/src/layout.rs index 85f859f..9c648ba 100644 --- a/crates/taskers-domain/src/layout.rs +++ b/crates/taskers-domain/src/layout.rs @@ -54,21 +54,46 @@ impl LayoutNode { new_pane: PaneId, ratio: u16, ) -> bool { + let direction = match axis { + SplitAxis::Horizontal => Direction::Right, + SplitAxis::Vertical => Direction::Down, + }; + self.split_leaf_with_direction(target, direction, new_pane, ratio) + } + + pub fn split_leaf_with_direction( + &mut self, + target: PaneId, + direction: Direction, + new_pane: PaneId, + ratio: u16, + ) -> bool { + let (axis, new_pane_first) = match direction { + Direction::Left => (SplitAxis::Horizontal, true), + Direction::Right => (SplitAxis::Horizontal, false), + Direction::Up => (SplitAxis::Vertical, true), + Direction::Down => (SplitAxis::Vertical, false), + }; match self { Self::Leaf { pane_id } if *pane_id == target => { let existing = *pane_id; + let (first, second) = if new_pane_first { + (Self::leaf(new_pane), Self::leaf(existing)) + } else { + (Self::leaf(existing), Self::leaf(new_pane)) + }; *self = Self::Split { axis, ratio: clamp_ratio(ratio), - first: Box::new(Self::leaf(existing)), - second: Box::new(Self::leaf(new_pane)), + first: Box::new(first), + second: Box::new(second), }; true } Self::Leaf { .. } => false, Self::Split { first, second, .. } => { - first.split_leaf(target, axis, new_pane, ratio) - || second.split_leaf(target, axis, new_pane, ratio) + first.split_leaf_with_direction(target, direction, new_pane, ratio) + || second.split_leaf_with_direction(target, direction, new_pane, ratio) } } } diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index fa4a3d5..f956222 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -13,7 +13,7 @@ use crate::{ pub const SESSION_SCHEMA_VERSION: u32 = 4; pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280; pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860; -pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 2; +pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 16; pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720; pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420; pub const KEYBOARD_RESIZE_STEP: i32 = 80; @@ -54,6 +54,8 @@ pub enum DomainError { pane_id: PaneId, surface_id: SurfaceId, }, + #[error("{0}")] + InvalidOperation(&'static str), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -151,6 +153,10 @@ pub struct PaneRecord { impl PaneRecord { pub fn new(kind: PaneKind) -> Self { let surface = SurfaceRecord::new(kind); + Self::from_surface(surface) + } + + fn from_surface(surface: SurfaceRecord) -> Self { let active_surface = surface.id; let mut surfaces = IndexMap::new(); surfaces.insert(active_surface, surface); @@ -1197,6 +1203,19 @@ impl AppModel { workspace_id: WorkspaceId, target_pane: Option, axis: SplitAxis, + ) -> Result { + let direction = match axis { + SplitAxis::Horizontal => Direction::Right, + SplitAxis::Vertical => Direction::Down, + }; + self.split_pane_direction(workspace_id, target_pane, direction) + } + + pub fn split_pane_direction( + &mut self, + workspace_id: WorkspaceId, + target_pane: Option, + direction: Direction, ) -> Result { let workspace = self .workspaces @@ -1219,7 +1238,9 @@ impl AppModel { workspace.panes.insert(new_pane_id, new_pane); if let Some(window) = workspace.windows.get_mut(&window_id) { - window.layout.split_leaf(target, axis, new_pane_id, 500); + window + .layout + .split_leaf_with_direction(target, direction, new_pane_id, 500); window.active_pane = new_pane_id; } workspace.sync_active_from_window(window_id); @@ -1999,6 +2020,120 @@ impl AppModel { Ok(()) } + pub fn move_surface_to_split( + &mut self, + workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + direction: Direction, + ) -> Result { + { + let workspace = self + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_pane = workspace + .panes + .get(&source_pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: source_pane_id, + })?; + if !workspace.panes.contains_key(&target_pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: target_pane_id, + }); + } + if !source_pane.surfaces.contains_key(&surface_id) { + return Err(DomainError::SurfaceNotInPane { + workspace_id, + pane_id: source_pane_id, + surface_id, + }); + } + if source_pane_id == target_pane_id && source_pane.surfaces.len() <= 1 { + return Err(DomainError::InvalidOperation( + "cannot split a pane from its only surface", + )); + } + } + + let target_window_id = self + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.window_for_pane(target_pane_id)) + .ok_or(DomainError::MissingPane(target_pane_id))?; + + let moved_surface = { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_pane = workspace.panes.get_mut(&source_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: source_pane_id, + }, + )?; + source_pane + .surfaces + .shift_remove(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id: source_pane_id, + surface_id, + })? + }; + let new_pane = PaneRecord::from_surface(moved_surface); + let new_pane_id = new_pane.id; + + let should_close_source_pane = self + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .is_some_and(|pane| pane.surfaces.is_empty()); + + { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.panes.insert(new_pane_id, new_pane); + + let target_window = workspace + .windows + .get_mut(&target_window_id) + .ok_or(DomainError::MissingPane(target_pane_id))?; + target_window.layout.split_leaf_with_direction( + target_pane_id, + direction, + new_pane_id, + 500, + ); + target_window.active_pane = new_pane_id; + for notification in &mut workspace.notifications { + if notification.surface_id == surface_id { + notification.pane_id = new_pane_id; + } + } + workspace.sync_active_from_window(target_window_id); + let _ = workspace.focus_surface(new_pane_id, surface_id); + } + + if should_close_source_pane { + self.close_pane(workspace_id, source_pane_id)?; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let _ = workspace.focus_surface(new_pane_id, surface_id); + } + + Ok(new_pane_id) + } + pub fn close_pane( &mut self, workspace_id: WorkspaceId, @@ -2498,6 +2633,32 @@ mod tests { assert_eq!(active_window.layout.leaves(), vec![first_pane, new_pane]); } + #[test] + fn split_pane_direction_places_new_pane_on_requested_side() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_pane = model + .active_workspace() + .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id)) + .expect("pane"); + + let left_pane = model + .split_pane_direction(workspace_id, Some(first_pane), Direction::Left) + .expect("split left"); + let upper_pane = model + .split_pane_direction(workspace_id, Some(first_pane), Direction::Up) + .expect("split up"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let active_window = workspace.active_window_record().expect("window"); + + assert_eq!(workspace.active_pane, upper_pane); + assert_eq!( + active_window.layout.leaves(), + vec![left_pane, upper_pane, first_pane] + ); + } + #[test] fn directional_focus_prefers_inner_split_before_neighboring_window() { let mut model = AppModel::new("Main"); @@ -2856,6 +3017,111 @@ mod tests { assert_eq!(workspace.active_pane, target_pane_id); } + #[test] + fn moving_surface_to_split_from_same_pane_creates_neighbor_pane() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let first_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("first surface"); + let moved_surface_id = model + .create_surface(workspace_id, source_pane_id, PaneKind::Browser) + .expect("second surface"); + + let new_pane_id = model + .move_surface_to_split( + workspace_id, + source_pane_id, + moved_surface_id, + source_pane_id, + Direction::Right, + ) + .expect("move to split"); + + let workspace = model.active_workspace().expect("workspace"); + let window = workspace.active_window_record().expect("window"); + let source_pane = workspace.panes.get(&source_pane_id).expect("source pane"); + let target_pane = workspace.panes.get(&new_pane_id).expect("new pane"); + + assert_eq!(window.layout.leaves(), vec![source_pane_id, new_pane_id]); + assert_eq!(source_pane.surface_ids().collect::>(), vec![first_surface_id]); + assert_eq!(target_pane.surface_ids().collect::>(), vec![moved_surface_id]); + assert_eq!(workspace.active_pane, new_pane_id); + assert_eq!(target_pane.active_surface, moved_surface_id); + } + + #[test] + fn moving_surface_to_split_across_windows_closes_empty_source_window() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let target_pane_id = model + .create_workspace_window(workspace_id, Direction::Right) + .expect("window"); + let target_window_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.window_for_pane(target_pane_id)) + .expect("target window"); + let moved_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + let new_pane_id = model + .move_surface_to_split( + workspace_id, + source_pane_id, + moved_surface_id, + target_pane_id, + Direction::Left, + ) + .expect("move to split"); + + let workspace = model.active_workspace().expect("workspace"); + let target_window = workspace.windows.get(&target_window_id).expect("window"); + + assert_eq!(workspace.windows.len(), 1); + assert!(!workspace.panes.contains_key(&source_pane_id)); + assert_eq!(workspace.active_window, target_window_id); + assert_eq!(workspace.active_pane, new_pane_id); + assert_eq!(target_window.layout.leaves(), vec![new_pane_id, target_pane_id]); + assert_eq!( + workspace + .panes + .get(&new_pane_id) + .expect("new pane") + .surface_ids() + .collect::>(), + vec![moved_surface_id] + ); + } + + #[test] + fn moving_only_surface_to_split_from_same_pane_is_rejected() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + let error = model + .move_surface_to_split(workspace_id, pane_id, surface_id, pane_id, Direction::Right) + .expect_err("reject self split of only surface"); + + assert!(matches!( + error, + DomainError::InvalidOperation("cannot split a pane from its only surface") + )); + } + #[test] fn transferring_last_surface_closes_the_source_pane() { let mut model = AppModel::new("Main"); From ba6099add0cdfdf94aa3fe9c309dcbb4c51f6127 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 17:13:35 +0100 Subject: [PATCH 43/63] feat: separate greenfield window pane and tab hierarchy --- greenfield/crates/taskers-core/src/lib.rs | 224 +++++++++++-- greenfield/crates/taskers-host/src/lib.rs | 60 +++- greenfield/crates/taskers-shell/src/lib.rs | 334 ++++++++++++++----- greenfield/crates/taskers-shell/src/theme.rs | 148 +++++++- 4 files changed, 621 insertions(+), 145 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 717f52a..8cadb08 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -8,7 +8,7 @@ use std::{ use taskers_app_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ - ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, Direction, KEYBOARD_RESIZE_STEP, + ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, PaneMetadataPatch, SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, Workspace, WorkspaceSummary as DomainWorkspaceSummary, @@ -19,7 +19,8 @@ use time::OffsetDateTime; use tokio::sync::watch; pub use taskers_domain::{ - PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, WorkspaceWindowMoveTarget, + Direction, PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + WorkspaceWindowMoveTarget, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -592,11 +593,11 @@ impl Default for LayoutMetrics { toolbar_height: 42, workspace_padding: 16, window_toolbar_height: 28, - window_body_padding: 0, - split_gap: 12, - pane_header_height: 28, + window_body_padding: 10, + split_gap: 8, + pane_header_height: 26, browser_toolbar_height: 34, - surface_tab_height: 0, + surface_tab_height: 28, } } } @@ -785,6 +786,14 @@ pub struct BrowserChromeSnapshot { pub devtools_open: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShellDragMode { + #[default] + None, + Window, + Surface, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentStateSnapshot { Working, @@ -869,6 +878,7 @@ pub struct ShellSnapshot { pub revision: u64, pub section: ShellSection, pub overview_mode: bool, + pub drag_mode: ShellDragMode, pub workspaces: Vec, pub current_workspace: WorkspaceViewSnapshot, pub browser_chrome: Option, @@ -976,6 +986,15 @@ pub enum ShellAction { target_pane_id: PaneId, target_index: usize, }, + MoveSurfaceToSplit { + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + direction: Direction, + }, + BeginWindowDrag, + BeginSurfaceDrag, + EndDrag, NavigateBrowser { surface_id: SurfaceId, url: String, @@ -1011,6 +1030,7 @@ pub enum ShellAction { struct UiState { section: ShellSection, overview_mode: bool, + drag_mode: ShellDragMode, selected_theme_id: String, selected_shortcut_preset: ShortcutPreset, window_size: PixelSize, @@ -1071,6 +1091,7 @@ impl TaskersCore { ui: UiState { section: ShellSection::Workspace, overview_mode: false, + drag_mode: ShellDragMode::None, selected_theme_id: bootstrap.selected_theme_id, selected_shortcut_preset: bootstrap.selected_shortcut_preset, window_size: PixelSize::new(1440, 900), @@ -1129,6 +1150,7 @@ impl TaskersCore { revision: self.revision, section: self.ui.section, overview_mode: self.ui.overview_mode, + drag_mode: self.ui.drag_mode, workspaces: self.workspace_summaries(&model), current_workspace: WorkspaceViewSnapshot { id: workspace_id, @@ -1714,6 +1736,20 @@ impl TaskersCore { target_pane_id, target_index, } => self.move_surface_by_id(surface_id, target_pane_id, target_index), + ShellAction::MoveSurfaceToSplit { + source_pane_id, + surface_id, + target_pane_id, + direction, + } => self.move_surface_to_split_by_id( + source_pane_id, + surface_id, + target_pane_id, + direction, + ), + ShellAction::BeginWindowDrag => self.set_drag_mode(ShellDragMode::Window), + ShellAction::BeginSurfaceDrag => self.set_drag_mode(ShellDragMode::Surface), + ShellAction::EndDrag => self.set_drag_mode(ShellDragMode::None), ShellAction::NavigateBrowser { surface_id, url } => { self.navigate_browser_surface(surface_id, &url) } @@ -2155,6 +2191,44 @@ impl TaskersCore { }) } + fn move_surface_to_split_by_id( + &mut self, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_pane_id: PaneId, + direction: Direction, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some((workspace_id, located_source_pane_id)) = + self.resolve_surface_location(&model, surface_id) + else { + return false; + }; + let Some((target_workspace_id, _)) = self.resolve_workspace_pane(&model, target_pane_id) + else { + return false; + }; + if workspace_id != target_workspace_id || located_source_pane_id != source_pane_id { + return false; + } + let Some(response) = + self.dispatch_control_with_response(ControlCommand::MoveSurfaceToSplit { + workspace_id, + source_pane_id, + surface_id, + target_pane_id, + direction, + }) + else { + return false; + }; + let changed = matches!(response, ControlResponse::SurfaceMovedToSplit { .. }); + if changed { + return self.ensure_active_window_visible() || changed; + } + false + } + fn navigate_browser_surface(&mut self, surface_id: SurfaceId, raw_url: &str) -> bool { let normalized = resolved_browser_uri(raw_url); self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { @@ -2300,12 +2374,25 @@ impl TaskersCore { self.ui.overview_mode = false; changed = true; } + if self.ui.drag_mode != ShellDragMode::None { + self.ui.drag_mode = ShellDragMode::None; + changed = true; + } if changed { self.bump_local_revision(); } self.app_state.snapshot_model().active_workspace_id() } + fn set_drag_mode(&mut self, drag_mode: ShellDragMode) -> bool { + if self.ui.drag_mode == drag_mode { + return false; + } + self.ui.drag_mode = drag_mode; + self.bump_local_revision(); + true + } + fn ensure_active_window_visible(&mut self) -> bool { let model = self.app_state.snapshot_model(); let Some(workspace_id) = model.active_workspace_id() else { @@ -3297,9 +3384,9 @@ mod tests { use taskers_control::ControlCommand; use super::{ - BootstrapModel, BrowserMountSpec, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, - RuntimeStatus, SharedCore, ShellAction, ShellSection, SurfaceMountSpec, - default_preview_app_state, resolved_browser_uri, + BootstrapModel, BrowserMountSpec, Direction, HostCommand, HostEvent, LayoutMetrics, + RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, + SurfaceMountSpec, default_preview_app_state, resolved_browser_uri, }; fn bootstrap() -> BootstrapModel { @@ -3317,6 +3404,31 @@ mod tests { } } + fn find_pane<'a>( + node: &'a super::LayoutNodeSnapshot, + pane_id: taskers_domain::PaneId, + ) -> Option<&'a super::PaneSnapshot> { + match node { + super::LayoutNodeSnapshot::Pane(pane) => (pane.id == pane_id).then_some(pane), + super::LayoutNodeSnapshot::Split { first, second, .. } => { + find_pane(first, pane_id).or_else(|| find_pane(second, pane_id)) + } + } + } + + fn collect_pane_ids( + node: &super::LayoutNodeSnapshot, + pane_ids: &mut Vec, + ) { + match node { + super::LayoutNodeSnapshot::Pane(pane) => pane_ids.push(pane.id), + super::LayoutNodeSnapshot::Split { first, second, .. } => { + collect_pane_ids(first, pane_ids); + collect_pane_ids(second, pane_ids); + } + } + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); @@ -3344,8 +3456,10 @@ mod tests { let core = SharedCore::bootstrap(bootstrap()); let snapshot = core.snapshot(); let metrics = LayoutMetrics::default(); - let min_content_y = - snapshot.portal.content.y + metrics.window_toolbar_height + metrics.pane_header_height; + let min_content_y = snapshot.portal.content.y + + metrics.window_toolbar_height + + metrics.pane_header_height + + metrics.surface_tab_height; assert!( snapshot @@ -3491,6 +3605,20 @@ mod tests { assert!(matches!(core.snapshot().section, ShellSection::Settings)); } + #[test] + fn shell_drag_actions_update_snapshot_drag_mode() { + let core = SharedCore::bootstrap(bootstrap()); + + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); + assert_eq!(core.snapshot().drag_mode, ShellDragMode::Surface); + + core.dispatch_shell_action(ShellAction::BeginWindowDrag); + assert_eq!(core.snapshot().drag_mode, ShellDragMode::Window); + + core.dispatch_shell_action(ShellAction::EndDrag); + assert_eq!(core.snapshot().drag_mode, ShellDragMode::None); + } + #[test] fn external_app_state_mutations_advance_shared_core_revision() { let app_state = default_preview_app_state(); @@ -3565,31 +3693,6 @@ mod tests { #[test] fn move_surface_shell_action_transfers_surface_between_panes() { - fn find_pane<'a>( - node: &'a super::LayoutNodeSnapshot, - pane_id: taskers_domain::PaneId, - ) -> Option<&'a super::PaneSnapshot> { - match node { - super::LayoutNodeSnapshot::Pane(pane) => (pane.id == pane_id).then_some(pane), - super::LayoutNodeSnapshot::Split { first, second, .. } => { - find_pane(first, pane_id).or_else(|| find_pane(second, pane_id)) - } - } - } - - fn collect_pane_ids( - node: &super::LayoutNodeSnapshot, - pane_ids: &mut Vec, - ) { - match node { - super::LayoutNodeSnapshot::Pane(pane) => pane_ids.push(pane.id), - super::LayoutNodeSnapshot::Split { first, second, .. } => { - collect_pane_ids(first, pane_ids); - collect_pane_ids(second, pane_ids); - } - } - } - let core = SharedCore::bootstrap(bootstrap()); let source_pane_id = core.snapshot().current_workspace.active_pane; core.dispatch_shell_action(ShellAction::AddBrowserSurface { @@ -3636,4 +3739,53 @@ mod tests { ); assert_eq!(snapshot.current_workspace.active_pane, target_pane_id); } + + #[test] + fn move_surface_to_split_shell_action_creates_neighbor_pane() { + let core = SharedCore::bootstrap(bootstrap()); + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::MoveSurfaceToSplit { + source_pane_id, + surface_id: moved_surface_id, + target_pane_id: source_pane_id, + direction: Direction::Right, + }); + + let snapshot = core.snapshot(); + let mut pane_ids = Vec::new(); + collect_pane_ids(&snapshot.current_workspace.layout, &mut pane_ids); + let new_pane_id = pane_ids + .into_iter() + .find(|pane_id| { + *pane_id != source_pane_id + && find_pane(&snapshot.current_workspace.layout, *pane_id) + .is_some_and(|pane| pane.active_surface == moved_surface_id) + }) + .expect("new pane"); + let source_pane = + find_pane(&snapshot.current_workspace.layout, source_pane_id).expect("source pane"); + let target_pane = + find_pane(&snapshot.current_workspace.layout, new_pane_id).expect("target pane"); + + assert!( + !source_pane + .surfaces + .iter() + .any(|surface| surface.id == moved_surface_id) + ); + assert_eq!( + target_pane.surfaces.first().map(|surface| surface.id), + Some(moved_surface_id) + ); + assert_eq!(snapshot.current_workspace.active_pane, new_pane_id); + } } diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 6b754ba..f452f19 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -11,8 +11,8 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; use taskers_core::{ - BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellSnapshot, SurfaceId, - SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, + BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellDragMode, ShellSnapshot, + SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, }; use taskers_domain::PaneKind; use taskers_ghostty::{GhosttyHost, SurfaceDescriptor}; @@ -166,6 +166,7 @@ impl TaskersHost { } pub fn sync_snapshot(&mut self, snapshot: &ShellSnapshot) -> Result<()> { + let interactive = native_surfaces_interactive(snapshot.drag_mode); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -174,8 +175,8 @@ impl TaskersHost { format!("host sync start panes={}", snapshot.portal.panes.len()), ), ); - self.sync_browser_surfaces(&snapshot.portal, snapshot.revision)?; - self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision)?; + self.sync_browser_surfaces(&snapshot.portal, snapshot.revision, interactive)?; + self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision, interactive)?; Ok(()) } @@ -228,7 +229,12 @@ impl TaskersHost { Ok(()) } - fn sync_browser_surfaces(&mut self, portal: &SurfacePortalPlan, revision: u64) -> Result<()> { + fn sync_browser_surfaces( + &mut self, + portal: &SurfacePortalPlan, + revision: u64, + interactive: bool, + ) -> Result<()> { let desired = browser_plans(portal); let desired_ids = desired .iter() @@ -263,6 +269,7 @@ impl TaskersHost { &self.surface_layer, &plan, revision, + interactive, self.diagnostics.as_ref(), )?, None => { @@ -270,6 +277,7 @@ impl TaskersHost { &self.surface_layer, &plan, revision, + interactive, self.event_sink.clone(), self.diagnostics.clone(), )?; @@ -281,7 +289,12 @@ impl TaskersHost { Ok(()) } - fn sync_terminal_surfaces(&mut self, portal: &SurfacePortalPlan, revision: u64) -> Result<()> { + fn sync_terminal_surfaces( + &mut self, + portal: &SurfacePortalPlan, + revision: u64, + interactive: bool, + ) -> Result<()> { let desired = terminal_plans(portal); let desired_ids = desired .iter() @@ -321,6 +334,7 @@ impl TaskersHost { plan.frame, plan.active, revision, + interactive, host, self.diagnostics.as_ref(), ), @@ -329,6 +343,7 @@ impl TaskersHost { &self.surface_layer, &plan, revision, + interactive, self.event_sink.clone(), self.diagnostics.clone(), host, @@ -356,6 +371,7 @@ impl BrowserSurface { fixed: &Fixed, plan: &PortalSurfacePlan, revision: u64, + interactive: bool, event_sink: HostEventSink, diagnostics: Option, ) -> Result { @@ -371,7 +387,7 @@ impl BrowserSurface { .focusable(true) .settings(&settings) .build(); - webview.set_can_target(true); + webview.set_can_target(interactive); webview.load_uri(&url); (event_sink)(HostEvent::SurfaceUrlChanged { surface_id: plan.surface_id, @@ -505,7 +521,7 @@ impl BrowserSurface { }); } - if plan.active { + if plan.active && interactive { webview.grab_focus(); } @@ -543,16 +559,18 @@ impl BrowserSurface { fixed: &Fixed, plan: &PortalSurfacePlan, revision: u64, + interactive: bool, diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { position_widget(fixed, self.webview.upcast_ref(), plan.frame); + self.webview.set_can_target(interactive); let BrowserMountSpec { url } = browser_spec(plan)?; if self.url != *url { self.webview.load_uri(url); self.url = url.clone(); } - if plan.active { + if plan.active && interactive { self.webview.grab_focus(); } @@ -629,6 +647,7 @@ impl TerminalSurface { fixed: &Fixed, plan: &PortalSurfacePlan, revision: u64, + interactive: bool, event_sink: HostEventSink, diagnostics: Option, host: &GhosttyHost, @@ -641,12 +660,12 @@ impl TerminalSurface { widget.set_hexpand(true); widget.set_vexpand(true); widget.set_focusable(true); - widget.set_can_target(true); + widget.set_can_target(interactive); position_widget(fixed, &widget, plan.frame); connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); - if plan.active { + if plan.active && interactive { let _ = host.focus_surface(&widget); } @@ -670,11 +689,13 @@ impl TerminalSurface { frame: taskers_core::Frame, active: bool, revision: u64, + interactive: bool, host: &GhosttyHost, diagnostics: Option<&DiagnosticsSink>, ) { + self.widget.set_can_target(interactive); position_widget(fixed, &self.widget, frame); - if active { + if active && interactive { let _ = host.focus_surface(&self.widget); } @@ -874,6 +895,10 @@ fn detach_from_fixed(fixed: &Fixed, widget: &Widget) { } } +fn native_surfaces_interactive(drag_mode: ShellDragMode) -> bool { + drag_mode == ShellDragMode::None +} + fn workspace_pan_delta(dx: f64, dy: f64) -> Option<(i32, i32)> { if !dx.is_finite() || !dy.is_finite() { return None; @@ -945,8 +970,8 @@ fn current_timestamp_ms() -> u128 { #[cfg(test)] mod tests { - use super::{browser_plans, terminal_plans, workspace_pan_delta}; - use taskers_core::{BootstrapModel, SharedCore, SurfaceMountSpec}; + use super::{browser_plans, native_surfaces_interactive, terminal_plans, workspace_pan_delta}; + use taskers_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; #[test] fn partitions_portal_plans_by_surface_kind() { @@ -969,4 +994,11 @@ mod tests { assert_eq!(workspace_pan_delta(6.0, 18.0), None); assert_eq!(workspace_pan_delta(f64::NAN, 0.0), None); } + + #[test] + fn native_surfaces_disable_pointer_targeting_during_shell_drags() { + assert!(native_surfaces_interactive(ShellDragMode::None)); + assert!(!native_surfaces_interactive(ShellDragMode::Window)); + assert!(!native_surfaces_interactive(ShellDragMode::Surface)); + } } diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index c8836eb..a2fdb00 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -2,7 +2,7 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ - ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, + ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, @@ -22,13 +22,17 @@ struct DraggedWindow { #[derive(Clone, Copy, PartialEq, Eq)] enum SurfaceDropTarget { - PaneEnd { + AppendToPane { pane_id: PaneId, }, BeforeSurface { pane_id: PaneId, surface_id: SurfaceId, }, + SplitPane { + pane_id: PaneId, + direction: Direction, + }, } fn compute_surface_drop_index( @@ -60,6 +64,71 @@ fn compute_surface_drop_index( target_index } +fn pane_has_surface_drop_target(target: Option, pane_id: PaneId) -> bool { + match target { + Some(SurfaceDropTarget::AppendToPane { + pane_id: target_pane_id, + }) + | Some(SurfaceDropTarget::BeforeSurface { + pane_id: target_pane_id, + .. + }) + | Some(SurfaceDropTarget::SplitPane { + pane_id: target_pane_id, + .. + }) => target_pane_id == pane_id, + None => false, + } +} + +fn pane_allows_surface_split( + dragged: Option, + pane_id: PaneId, + surface_count: usize, +) -> bool { + dragged.is_some_and(|dragged| dragged.pane_id != pane_id || surface_count > 1) +} + +fn apply_surface_drop( + core: &SharedCore, + dragged: DraggedSurface, + target: SurfaceDropTarget, + ordered_surface_ids: &[SurfaceId], +) { + match target { + SurfaceDropTarget::AppendToPane { pane_id } => { + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: dragged.surface_id, + target_pane_id: pane_id, + target_index: usize::MAX, + }); + } + SurfaceDropTarget::BeforeSurface { + pane_id, + surface_id, + } => { + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: dragged.surface_id, + target_pane_id: pane_id, + target_index: compute_surface_drop_index( + dragged, + pane_id, + ordered_surface_ids, + Some(surface_id), + ), + }); + } + SurfaceDropTarget::SplitPane { pane_id, direction } => { + core.dispatch_shell_action(ShellAction::MoveSurfaceToSplit { + source_pane_id: dragged.pane_id, + surface_id: dragged.surface_id, + target_pane_id: pane_id, + direction, + }); + } + } +} + fn app_css(snapshot: &ShellSnapshot) -> String { theme::generate_css(&theme::resolve_palette( &snapshot.settings.selected_theme_id, @@ -580,12 +649,17 @@ fn render_workspace_window( let focus_window = move |_| { focus_core.dispatch_shell_action(ShellAction::FocusWorkspaceWindow { window_id }); }; - let start_window_drag = move |_: Event| { - surface_drag_source.set(None); - surface_drop_target.set(None); - window_drag_source.set(Some(DraggedWindow { window_id })); + let start_window_drag = { + let core = core.clone(); + move |_: Event| { + surface_drag_source.set(None); + surface_drop_target.set(None); + window_drag_source.set(Some(DraggedWindow { window_id })); + core.dispatch_shell_action(ShellAction::BeginWindowDrag); + } }; let clear_window_drag = { + let core = core.clone(); let mut window_drag_source = window_drag_source; let mut window_drop_target = window_drop_target; let mut surface_drop_target = surface_drop_target; @@ -593,6 +667,7 @@ fn render_workspace_window( window_drag_source.set(None); window_drop_target.set(None); surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::EndDrag); } }; let drag_active = window_drag_source.read().is_some(); @@ -704,6 +779,7 @@ fn render_window_drop_zone( window_id: dragged.window_id, target, }); + core.dispatch_shell_action(ShellAction::EndDrag); }; rsx! { @@ -721,16 +797,12 @@ fn render_pane( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - mut surface_drag_source: Signal>, - mut surface_drop_target: Signal>, + surface_drag_source: Signal>, + surface_drop_target: Signal>, ) -> Element { let pane_id = pane.id; - let pane_is_drop_target = matches!( - *surface_drop_target.read(), - Some(SurfaceDropTarget::PaneEnd { - pane_id: target_pane_id, - }) if target_pane_id == pane_id - ); + let dragged_surface = *surface_drag_source.read(); + let pane_is_drop_target = pane_has_surface_drop_target(*surface_drop_target.read(), pane_id); let pane_class = if pane.active { format!( "pane-card pane-card-active{}", @@ -778,6 +850,18 @@ fn render_pane( .map(|url| format!("{}-{}", active_surface.id, url)) }) .unwrap_or_else(|| active_surface.id.to_string()); + let pane_kind = match active_surface.kind { + SurfaceKind::Terminal => "terminal", + SurfaceKind::Browser => "browser", + }; + let tab_count_label = if pane.surfaces.len() == 1 { + "1 tab".to_string() + } else { + format!("{} tabs", pane.surfaces.len()) + }; + let pane_allows_split = + pane_allows_surface_split(dragged_surface, pane_id, pane.surfaces.len()); + let surface_drag_active = dragged_surface.is_some(); let focus_pane = { let core = core.clone(); @@ -835,49 +919,23 @@ fn render_pane( } else { "Close current surface" }; - let set_pane_drop_target = move |event: Event| { - if surface_drag_source.read().is_none() { - return; - } - event.prevent_default(); - surface_drop_target.set(Some(SurfaceDropTarget::PaneEnd { pane_id })); - }; - let clear_pane_drop_target = move |_: Event| { - if *surface_drop_target.read() == Some(SurfaceDropTarget::PaneEnd { pane_id }) { - surface_drop_target.set(None); - } - }; - let drop_on_pane = { - let core = core.clone(); - let ordered_surface_ids = ordered_surface_ids.clone(); - move |event: Event| { - event.prevent_default(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); - surface_drop_target.set(None); - let Some(dragged) = dragged else { - return; - }; - core.dispatch_shell_action(ShellAction::MoveSurface { - surface_id: dragged.surface_id, - target_pane_id: pane_id, - target_index: compute_surface_drop_index( - dragged, - pane_id, - &ordered_surface_ids, - None, - ), - }); - } - }; rsx! { section { class: "{pane_class}", onclick: focus_pane, - div { - class: "pane-header", - ondragover: set_pane_drop_target, - ondragleave: clear_pane_drop_target, - ondrop: drop_on_pane, + div { class: "pane-toolbar", + div { class: "pane-toolbar-meta", + span { class: "pane-toolbar-eyebrow", "pane" } + span { class: "pane-toolbar-detail", "{pane_kind} · {tab_count_label}" } + } + div { class: "pane-action-cluster", + button { class: "pane-utility pane-utility-tab", title: "New terminal tab", onclick: add_terminal_surface, "+t" } + button { class: "pane-utility pane-utility-tab", title: "New browser tab", onclick: add_browser_surface, "+w" } + button { class: "pane-utility pane-utility-split", title: "Split right", onclick: split_terminal, "|r" } + button { class: "pane-utility pane-utility-split", title: "Split down", onclick: split_down, "|d" } + button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, "x" } + } + } + div { class: "pane-tabs", div { class: "surface-tabs", for surface in &pane.surfaces { {render_surface_tab( @@ -890,13 +948,16 @@ fn render_pane( &ordered_surface_ids, )} } - } - div { class: "pane-action-cluster", - button { class: "pane-utility pane-utility-tab", title: "New terminal tab", onclick: add_terminal_surface, "+t" } - button { class: "pane-utility pane-utility-tab", title: "New browser tab", onclick: add_browser_surface, "+w" } - button { class: "pane-utility pane-utility-split", title: "Split right", onclick: split_terminal, "|r" } - button { class: "pane-utility pane-utility-split", title: "Split down", onclick: split_down, "|d" } - button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, "x" } + if surface_drag_active { + {render_surface_pane_drop_target( + "surface-tab surface-tab-append-target", + "+", + SurfaceDropTarget::AppendToPane { pane_id }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + } } } if matches!(active_surface.kind, SurfaceKind::Browser) { @@ -909,12 +970,121 @@ fn render_pane( } div { class: "pane-body", {render_surface_backdrop(active_surface, runtime_status)} + if surface_drag_active { + div { class: "pane-drop-overlay", + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-center", + "append", + SurfaceDropTarget::AppendToPane { pane_id }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + if pane_allows_split { + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-left", + "split left", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Left, + }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-right", + "split right", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Right, + }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-top", + "split up", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Up, + }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-bottom", + "split down", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Down, + }, + core.clone(), + surface_drag_source, + surface_drop_target, + )} + } + } + } } div { key: "{flash_key}", class: "{flash_class}" } } } } +fn render_surface_pane_drop_target( + base_class: &'static str, + label: &'static str, + target: SurfaceDropTarget, + core: SharedCore, + mut surface_drag_source: Signal>, + mut surface_drop_target: Signal>, +) -> Element { + let class = if *surface_drop_target.read() == Some(target) { + format!("{base_class} pane-drop-target-active") + } else { + base_class.to_string() + }; + let set_drop_target = move |event: Event| { + if surface_drag_source.read().is_none() { + return; + } + event.prevent_default(); + surface_drop_target.set(Some(target)); + }; + let clear_drop_target = move |_: Event| { + if *surface_drop_target.read() == Some(target) { + surface_drop_target.set(None); + } + }; + let drop_surface = { + let core = core.clone(); + move |event: Event| { + event.prevent_default(); + let dragged = *surface_drag_source.read(); + surface_drag_source.set(None); + surface_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + apply_surface_drop(&core, dragged, target, &[]); + core.dispatch_shell_action(ShellAction::EndDrag); + } + }; + + rsx! { + div { + class: "{class}", + ondragover: set_drop_target, + ondragleave: clear_drop_target, + ondrop: drop_surface, + "{label}" + } + } +} + fn render_surface_tab( pane_id: PaneId, active_surface_id: SurfaceId, @@ -962,15 +1132,23 @@ fn render_surface_tab( surface_id, }); }; - let start_drag = move |_: Event| { - surface_drag_source.set(Some(DraggedSurface { - pane_id, - surface_id, - })); + let start_drag = { + let core = core.clone(); + move |_: Event| { + surface_drag_source.set(Some(DraggedSurface { + pane_id, + surface_id, + })); + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); + } }; - let clear_drag = move |_: Event| { - surface_drag_source.set(None); - surface_drop_target.set(None); + let clear_drag = { + let core = core.clone(); + move |_: Event| { + surface_drag_source.set(None); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::EndDrag); + } }; let set_surface_drop_target = move |event: Event| { if surface_drag_source.read().is_none() { @@ -1003,16 +1181,16 @@ fn render_surface_tab( let Some(dragged) = dragged else { return; }; - core.dispatch_shell_action(ShellAction::MoveSurface { - surface_id: dragged.surface_id, - target_pane_id: pane_id, - target_index: compute_surface_drop_index( - dragged, + apply_surface_drop( + &core, + dragged, + SurfaceDropTarget::BeforeSurface { pane_id, - &ordered_surface_ids, - Some(surface_id), - ), - }); + surface_id, + }, + &ordered_surface_ids, + ); + core.dispatch_shell_action(ShellAction::EndDrag); } }; diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index f5a8a77..3f1a372 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -176,7 +176,9 @@ pub fn generate_css(p: &ThemePalette) -> String { let activity_width = metrics.activity_width; let workspace_toolbar_height = metrics.toolbar_height; let window_toolbar_height = metrics.window_toolbar_height; + let window_body_padding = metrics.window_body_padding; let pane_header_height = metrics.pane_header_height; + let surface_tab_height = metrics.surface_tab_height; let browser_toolbar_height = metrics.browser_toolbar_height; let split_gap = metrics.split_gap; let mut css = String::with_capacity(18_000); @@ -808,8 +810,8 @@ button {{ position: absolute; display: flex; flex-direction: column; - background: {elevated}; - border: 1px solid {border_07}; + background: {surface}; + border: 2px solid {border_07}; overflow: hidden; }} @@ -820,9 +822,9 @@ button {{ .workspace-window-toolbar {{ height: {window_toolbar_height}px; min-height: {window_toolbar_height}px; - border-bottom: 1px solid {border_07}; + border-bottom: 1px solid {border_10}; background: {surface}; - padding: 0 8px; + padding: 0 10px; display: flex; align-items: center; justify-content: flex-start; @@ -832,8 +834,10 @@ button {{ }} .workspace-window-title {{ - color: inherit; + color: {text_bright}; min-width: 0; + font-size: 12px; + font-weight: 600; }} .workspace-window-toolbar:active {{ @@ -843,8 +847,8 @@ button {{ .workspace-window-body {{ flex: 1; min-height: 0; - padding: 0; - background: {border_02}; + padding: {window_body_padding}px; + background: {surface}; }} .split-container {{ @@ -870,12 +874,12 @@ button {{ display: flex; flex-direction: column; background: {elevated}; - border: 1px solid {border_07}; + border: 1px solid {border_10}; overflow: hidden; }} .pane-card-active {{ - border-color: {accent_20}; + border-color: {accent_24}; }} .pane-card-drop-target {{ @@ -903,25 +907,46 @@ button {{ animation: focus-flash 0.9s ease-in-out; }} -.pane-header {{ +.pane-toolbar {{ height: {pane_header_height}px; min-height: {pane_header_height}px; - border-bottom: 1px solid {border_07}; - padding: 0 4px 0 6px; + border-bottom: 1px solid {border_10}; + padding: 0 8px; display: flex; align-items: center; justify-content: space-between; - gap: 4px; + gap: 8px; background: {surface}; }} -.pane-meta, +.pane-toolbar-meta, .surface-tab-label, .shortcut-label {{ color: {text_subtle}; font-size: 11px; }} +.pane-toolbar-meta {{ + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +}} + +.pane-toolbar-eyebrow {{ + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 10px; + color: {text_dim}; + font-family: "IBM Plex Mono", ui-monospace, monospace; +}} + +.pane-toolbar-detail {{ + color: {text_subtle}; + font-size: 11px; + white-space: nowrap; +}} + .pane-action-cluster {{ flex: 0 0 auto; display: flex; @@ -937,10 +962,20 @@ button {{ pointer-events: auto; }} +.pane-tabs {{ + height: {surface_tab_height}px; + min-height: {surface_tab_height}px; + border-bottom: 1px solid {border_10}; + padding: 0 6px; + display: flex; + align-items: center; + background: {overlay_05}; +}} + .surface-tabs {{ flex: 1; min-width: 0; - min-height: {pane_header_height}px; + min-height: 0; display: flex; align-items: center; gap: 1px; @@ -968,6 +1003,13 @@ button {{ border-color: {border_10}; }} +.surface-tab-append-target {{ + min-width: 28px; + justify-content: center; + border-style: dashed; + color: {text_dim}; +}} + .surface-tab[draggable] {{ cursor: grab; }} @@ -1036,6 +1078,7 @@ button {{ .workspace-window-drop-zone-visible {{ opacity: 0.45; pointer-events: auto; + background: {accent_12}; }} .workspace-window-drop-zone-active {{ @@ -1136,6 +1179,72 @@ button {{ min-height: 0; padding: 0; background: {border_02}; + position: relative; + overflow: hidden; +}} + +.pane-drop-overlay {{ + position: absolute; + inset: 0; + z-index: 8; + pointer-events: none; +}} + +.pane-drop-target {{ + position: absolute; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed {accent_24}; + background: {accent_12}; + color: {text_bright}; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + pointer-events: auto; +}} + +.pane-drop-target-active {{ + background: {accent_20}; + border-style: solid; +}} + +.pane-drop-target-center {{ + inset: 18%; +}} + +.pane-drop-target-edge {{ + z-index: 1; +}} + +.pane-drop-target-left, +.pane-drop-target-right {{ + top: 10px; + bottom: 10px; + width: 56px; +}} + +.pane-drop-target-left {{ + left: 10px; +}} + +.pane-drop-target-right {{ + right: 10px; +}} + +.pane-drop-target-top, +.pane-drop-target-bottom {{ + left: 10px; + right: 10px; + height: 48px; +}} + +.pane-drop-target-top {{ + top: 10px; +}} + +.pane-drop-target-bottom {{ + bottom: 10px; }} .surface-backdrop {{ @@ -1178,8 +1287,9 @@ button {{ gap: 8px; }} -.workspace-main-overview .pane-header {{ - min-height: 30px; +.workspace-main-overview .pane-toolbar, +.workspace-main-overview .pane-tabs {{ + min-height: 28px; padding: 0 8px; }} @@ -1198,6 +1308,10 @@ button {{ padding: 10px; }} +.workspace-main-overview .pane-drop-overlay {{ + display: none; +}} + .workspace-main-overview .surface-backdrop {{ gap: 8px; padding: 10px; From 19022992a62e1fb9cab913b16d24f452d245d29b Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 17:31:06 +0100 Subject: [PATCH 44/63] fix: tighten greenfield browser and workspace interactions --- crates/taskers-control/src/controller.rs | 19 ++ crates/taskers-control/src/protocol.rs | 9 + crates/taskers-domain/src/model.rs | 336 +++++++++++++++---- greenfield/crates/taskers-core/src/lib.rs | 239 ++++++++++++- greenfield/crates/taskers-host/src/lib.rs | 54 +-- greenfield/crates/taskers-shell/src/lib.rs | 45 ++- greenfield/crates/taskers-shell/src/theme.rs | 5 + 7 files changed, 589 insertions(+), 118 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 65d8d8a..1f348d3 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -352,6 +352,25 @@ impl InMemoryController { true, ) } + ControlCommand::MoveSurfaceToWorkspace { + source_workspace_id, + source_pane_id, + surface_id, + target_workspace_id, + } => { + let new_pane_id = model.move_surface_to_workspace( + source_workspace_id, + source_pane_id, + surface_id, + target_workspace_id, + )?; + ( + ControlResponse::SurfaceMovedToWorkspace { + pane_id: new_pane_id, + }, + true, + ) + } ControlCommand::SetWorkspaceViewport { workspace_id, viewport, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 06b2b46..7bb1601 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -126,6 +126,12 @@ pub enum ControlCommand { target_pane_id: PaneId, direction: Direction, }, + MoveSurfaceToWorkspace { + source_workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_workspace_id: WorkspaceId, + }, SetWorkspaceViewport { workspace_id: WorkspaceId, viewport: WorkspaceViewport, @@ -176,6 +182,9 @@ pub enum ControlResponse { SurfaceMovedToSplit { pane_id: PaneId, }, + SurfaceMovedToWorkspace { + pane_id: PaneId, + }, SurfaceCreated { surface_id: SurfaceId, }, diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index f956222..745d7b6 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -29,6 +29,77 @@ fn split_top_level_extent(extent: i32, min_extent: i32) -> (i32, i32) { (retained_extent.max(min_extent), new_extent.max(min_extent)) } +fn insert_window_relative_to_active( + workspace: &mut Workspace, + workspace_window_id: WorkspaceWindowId, + direction: Direction, +) -> Result<(), DomainError> { + let (source_column_id, source_column_index, source_window_index) = workspace + .position_for_window(workspace.active_window) + .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; + + match direction { + Direction::Left | Direction::Right => { + let source_width = workspace + .columns + .get(&source_column_id) + .map(|column| column.width) + .expect("active column should exist"); + let (retained_width, new_width) = + split_top_level_extent(source_width, MIN_WORKSPACE_WINDOW_WIDTH); + let column = workspace + .columns + .get_mut(&source_column_id) + .expect("active column should exist"); + column.width = retained_width; + + let mut new_column = WorkspaceColumnRecord::new(workspace_window_id); + new_column.width = new_width; + let insert_index = if matches!(direction, Direction::Left) { + source_column_index + } else { + source_column_index + 1 + }; + workspace.insert_column_at(insert_index, new_column); + } + Direction::Up | Direction::Down => { + let source_window_height = workspace + .windows + .get(&workspace.active_window) + .map(|window| window.height) + .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; + let (retained_height, new_height) = + split_top_level_extent(source_window_height, MIN_WORKSPACE_WINDOW_HEIGHT); + let source_window = workspace + .windows + .get_mut(&workspace.active_window) + .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; + source_window.height = retained_height; + let new_window = workspace + .windows + .get_mut(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + new_window.height = new_height; + + let column = workspace + .columns + .get_mut(&source_column_id) + .expect("active column should exist"); + let insert_index = if matches!(direction, Direction::Up) { + source_window_index + } else { + source_window_index + 1 + }; + column + .window_order + .insert(insert_index, workspace_window_id); + column.active_window = workspace_window_id; + } + } + + Ok(()) +} + #[derive(Debug, Error)] pub enum DomainError { #[error("window {0} was not found")] @@ -1131,67 +1202,7 @@ impl AppModel { let new_window = WorkspaceWindowRecord::new(new_pane_id); let new_window_id = new_window.id; workspace.windows.insert(new_window_id, new_window); - - let (source_column_id, source_column_index, source_window_index) = workspace - .position_for_window(workspace.active_window) - .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; - - match direction { - Direction::Left | Direction::Right => { - let source_width = workspace - .columns - .get(&source_column_id) - .map(|column| column.width) - .expect("active column should exist"); - let (retained_width, new_width) = - split_top_level_extent(source_width, MIN_WORKSPACE_WINDOW_WIDTH); - let column = workspace - .columns - .get_mut(&source_column_id) - .expect("active column should exist"); - column.width = retained_width; - - let mut new_column = WorkspaceColumnRecord::new(new_window_id); - new_column.width = new_width; - let insert_index = if matches!(direction, Direction::Left) { - source_column_index - } else { - source_column_index + 1 - }; - workspace.insert_column_at(insert_index, new_column); - } - Direction::Up | Direction::Down => { - let source_window_height = workspace - .windows - .get(&workspace.active_window) - .map(|window| window.height) - .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; - let (retained_height, new_height) = - split_top_level_extent(source_window_height, MIN_WORKSPACE_WINDOW_HEIGHT); - let source_window = workspace - .windows - .get_mut(&workspace.active_window) - .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?; - source_window.height = retained_height; - let new_window = workspace - .windows - .get_mut(&new_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(new_window_id))?; - new_window.height = new_height; - - let column = workspace - .columns - .get_mut(&source_column_id) - .expect("active column should exist"); - let insert_index = if matches!(direction, Direction::Up) { - source_window_index - } else { - source_window_index + 1 - }; - column.window_order.insert(insert_index, new_window_id); - column.active_window = new_window_id; - } - } + insert_window_relative_to_active(workspace, new_window_id, direction)?; workspace.sync_active_from_window(new_window_id); @@ -2033,13 +2044,14 @@ impl AppModel { .workspaces .get(&workspace_id) .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let source_pane = workspace - .panes - .get(&source_pane_id) - .ok_or(DomainError::PaneNotInWorkspace { - workspace_id, - pane_id: source_pane_id, - })?; + let source_pane = + workspace + .panes + .get(&source_pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: source_pane_id, + })?; if !workspace.panes.contains_key(&target_pane_id) { return Err(DomainError::PaneNotInWorkspace { workspace_id, @@ -2134,6 +2146,120 @@ impl AppModel { Ok(new_pane_id) } + pub fn move_surface_to_workspace( + &mut self, + source_workspace_id: WorkspaceId, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_workspace_id: WorkspaceId, + ) -> Result { + if source_workspace_id == target_workspace_id { + return Err(DomainError::InvalidOperation( + "surface is already in the target workspace", + )); + } + + { + let source_workspace = self + .workspaces + .get(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + if !self.workspaces.contains_key(&target_workspace_id) { + return Err(DomainError::MissingWorkspace(target_workspace_id)); + } + let source_pane = source_workspace.panes.get(&source_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + }, + )?; + if !source_pane.surfaces.contains_key(&surface_id) { + return Err(DomainError::SurfaceNotInPane { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id, + }); + } + } + + let moved_surface = { + let source_workspace = self + .workspaces + .get_mut(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + let source_pane = source_workspace.panes.get_mut(&source_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + }, + )?; + source_pane + .surfaces + .shift_remove(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id, + })? + }; + + let should_close_source_pane = self + .workspaces + .get(&source_workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .is_some_and(|pane| pane.surfaces.is_empty()); + + let mut moved_notifications = Vec::new(); + { + let source_workspace = self + .workspaces + .get_mut(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + source_workspace.notifications.retain(|notification| { + if notification.surface_id == surface_id { + moved_notifications.push(notification.clone()); + false + } else { + true + } + }); + } + + let new_pane = PaneRecord::from_surface(moved_surface); + let new_pane_id = new_pane.id; + let new_window = WorkspaceWindowRecord::new(new_pane_id); + let new_window_id = new_window.id; + + { + let target_workspace = self + .workspaces + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + target_workspace.panes.insert(new_pane_id, new_pane); + target_workspace.windows.insert(new_window_id, new_window); + insert_window_relative_to_active(target_workspace, new_window_id, Direction::Right)?; + for notification in &mut moved_notifications { + notification.pane_id = new_pane_id; + } + target_workspace.notifications.extend(moved_notifications); + target_workspace.sync_active_from_window(new_window_id); + let _ = target_workspace.focus_surface(new_pane_id, surface_id); + } + + if should_close_source_pane { + self.close_pane(source_workspace_id, source_pane_id)?; + } + + self.switch_workspace(self.active_window, target_workspace_id)?; + let target_workspace = self + .workspaces + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + let _ = target_workspace.focus_surface(new_pane_id, surface_id); + + Ok(new_pane_id) + } + pub fn close_pane( &mut self, workspace_id: WorkspaceId, @@ -3047,8 +3173,14 @@ mod tests { let target_pane = workspace.panes.get(&new_pane_id).expect("new pane"); assert_eq!(window.layout.leaves(), vec![source_pane_id, new_pane_id]); - assert_eq!(source_pane.surface_ids().collect::>(), vec![first_surface_id]); - assert_eq!(target_pane.surface_ids().collect::>(), vec![moved_surface_id]); + assert_eq!( + source_pane.surface_ids().collect::>(), + vec![first_surface_id] + ); + assert_eq!( + target_pane.surface_ids().collect::>(), + vec![moved_surface_id] + ); assert_eq!(workspace.active_pane, new_pane_id); assert_eq!(target_pane.active_surface, moved_surface_id); } @@ -3089,7 +3221,10 @@ mod tests { assert!(!workspace.panes.contains_key(&source_pane_id)); assert_eq!(workspace.active_window, target_window_id); assert_eq!(workspace.active_pane, new_pane_id); - assert_eq!(target_window.layout.leaves(), vec![new_pane_id, target_pane_id]); + assert_eq!( + target_window.layout.leaves(), + vec![new_pane_id, target_pane_id] + ); assert_eq!( workspace .panes @@ -3122,6 +3257,65 @@ mod tests { )); } + #[test] + fn moving_surface_to_another_workspace_creates_new_window_and_switches_workspace() { + let mut model = AppModel::new("Main"); + let source_workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let first_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("first surface"); + let moved_surface_id = model + .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser) + .expect("second surface"); + let target_workspace_id = model.create_workspace("Secondary"); + + let new_pane_id = model + .move_surface_to_workspace( + source_workspace_id, + source_pane_id, + moved_surface_id, + target_workspace_id, + ) + .expect("move to workspace"); + + let source_workspace = model + .workspaces + .get(&source_workspace_id) + .expect("source workspace"); + let target_workspace = model + .workspaces + .get(&target_workspace_id) + .expect("target workspace"); + let moved_window_id = target_workspace + .window_for_pane(new_pane_id) + .expect("moved window"); + + assert_eq!(model.active_workspace_id(), Some(target_workspace_id)); + assert_eq!( + source_workspace + .panes + .get(&source_pane_id) + .expect("source pane") + .surface_ids() + .collect::>(), + vec![first_surface_id] + ); + assert_eq!( + target_workspace + .panes + .get(&new_pane_id) + .expect("new pane") + .surface_ids() + .collect::>(), + vec![moved_surface_id] + ); + assert_eq!(target_workspace.active_window, moved_window_id); + assert_eq!(target_workspace.active_pane, new_pane_id); + } + #[test] fn transferring_last_surface_closes_the_source_pane() { let mut model = AppModel::new("Main"); diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 8cadb08..5fe7c2d 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -926,6 +926,7 @@ pub enum HostEvent { #[derive(Debug, Clone, PartialEq, Eq)] pub enum HostCommand { + BrowserNavigate { surface_id: SurfaceId, url: String }, BrowserBack { surface_id: SurfaceId }, BrowserForward { surface_id: SurfaceId }, BrowserReload { surface_id: SurfaceId }, @@ -992,6 +993,11 @@ pub enum ShellAction { target_pane_id: PaneId, direction: Direction, }, + MoveSurfaceToWorkspace { + source_pane_id: PaneId, + surface_id: SurfaceId, + target_workspace_id: WorkspaceId, + }, BeginWindowDrag, BeginSurfaceDrag, EndDrag, @@ -1118,6 +1124,12 @@ impl TaskersCore { .active_window_record() .expect("active workspace window should exist"); let viewport = self.workspace_viewport_frame(); + let clamped_viewport = clamped_workspace_viewport( + workspace, + viewport.width, + viewport.height, + workspace.viewport.clone(), + ); let render_context = workspace_render_context( workspace, self.ui.overview_mode, @@ -1137,8 +1149,8 @@ impl TaskersCore { placement.frame, canvas_metrics, viewport, - workspace.viewport.x, - workspace.viewport.y, + clamped_viewport.x, + clamped_viewport.y, render_context.overview_mode, ), ), @@ -1166,8 +1178,8 @@ impl TaskersCore { viewport_origin_x: viewport.x, viewport_origin_y: viewport.y, active_pane: workspace.active_pane, - viewport_x: workspace.viewport.x, - viewport_y: workspace.viewport.y, + viewport_x: clamped_viewport.x, + viewport_y: clamped_viewport.y, overview_scale: render_context.overview_scale as f32, canvas_width: canvas_metrics.width, canvas_height: canvas_metrics.height, @@ -1747,6 +1759,15 @@ impl TaskersCore { target_pane_id, direction, ), + ShellAction::MoveSurfaceToWorkspace { + source_pane_id, + surface_id, + target_workspace_id, + } => self.move_surface_to_workspace_by_id( + source_pane_id, + surface_id, + target_workspace_id, + ), ShellAction::BeginWindowDrag => self.set_drag_mode(ShellDragMode::Window), ShellAction::BeginSurfaceDrag => self.set_drag_mode(ShellDragMode::Surface), ShellAction::EndDrag => self.set_drag_mode(ShellDragMode::None), @@ -2028,12 +2049,28 @@ impl TaskersCore { let Some(workspace) = model.workspaces.get(&workspace_id) else { return false; }; + let viewport_frame = self.workspace_viewport_frame(); + let current_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + workspace.viewport.clone(), + ); + let next_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + taskers_domain::WorkspaceViewport { + x: current_viewport.x.saturating_add(dx), + y: current_viewport.y.saturating_add(dy), + }, + ); + if next_viewport == workspace.viewport { + return false; + } self.dispatch_control(ControlCommand::SetWorkspaceViewport { workspace_id, - viewport: taskers_domain::WorkspaceViewport { - x: workspace.viewport.x.saturating_add(dx), - y: workspace.viewport.y.saturating_add(dy), - }, + viewport: next_viewport, }) } @@ -2229,15 +2266,52 @@ impl TaskersCore { false } + fn move_surface_to_workspace_by_id( + &mut self, + source_pane_id: PaneId, + surface_id: SurfaceId, + target_workspace_id: WorkspaceId, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some((source_workspace_id, located_source_pane_id)) = + self.resolve_surface_location(&model, surface_id) + else { + return false; + }; + if located_source_pane_id != source_pane_id || source_workspace_id == target_workspace_id { + return false; + } + let Some(response) = + self.dispatch_control_with_response(ControlCommand::MoveSurfaceToWorkspace { + source_workspace_id, + source_pane_id, + surface_id, + target_workspace_id, + }) + else { + return false; + }; + let changed = matches!(response, ControlResponse::SurfaceMovedToWorkspace { .. }); + if changed { + return self.ensure_active_window_visible() || changed; + } + false + } + fn navigate_browser_surface(&mut self, surface_id: SurfaceId, raw_url: &str) -> bool { let normalized = resolved_browser_uri(raw_url); - self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { + let mut changed = self.dispatch_control(ControlCommand::UpdateSurfaceMetadata { surface_id, patch: PaneMetadataPatch { - url: Some(normalized), + url: Some(normalized.clone()), ..PaneMetadataPatch::default() }, - }) + }); + changed |= self.queue_host_command(HostCommand::BrowserNavigate { + surface_id, + url: normalized, + }); + changed } fn queue_host_command(&mut self, command: HostCommand) -> bool { @@ -2402,6 +2476,12 @@ impl TaskersCore { return false; }; let viewport_frame = self.workspace_viewport_frame(); + let current_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + workspace.viewport.clone(), + ); let Some(active_frame) = workspace_window_placements(workspace, viewport_frame.width, viewport_frame.height) .into_iter() @@ -2411,7 +2491,7 @@ impl TaskersCore { return false; }; - let mut next_viewport = workspace.viewport.clone(); + let mut next_viewport = current_viewport; let visible_right = next_viewport.x + viewport_frame.width; let visible_bottom = next_viewport.y + viewport_frame.height; if active_frame.x < next_viewport.x { @@ -2424,6 +2504,12 @@ impl TaskersCore { } else if active_frame.bottom() > visible_bottom { next_viewport.y = active_frame.bottom() - viewport_frame.height; } + let next_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + next_viewport, + ); if next_viewport == workspace.viewport { return false; } @@ -2821,6 +2907,23 @@ fn workspace_canvas_metrics(placements: &[WorkspaceWindowPlacement]) -> CanvasMe canvas_metrics_from_frames(&frames) } +fn clamped_workspace_viewport( + workspace: &Workspace, + viewport_width: i32, + viewport_height: i32, + viewport: taskers_domain::WorkspaceViewport, +) -> taskers_domain::WorkspaceViewport { + let placements = workspace_window_placements(workspace, viewport_width, viewport_height); + let canvas = workspace_canvas_metrics(&placements); + let max_x = (canvas.width - viewport_width).max(0); + let max_y = (canvas.height - viewport_height).max(0); + + taskers_domain::WorkspaceViewport { + x: viewport.x.clamp(0, max_x), + y: viewport.y.clamp(0, max_y), + } +} + fn canvas_metrics_from_frames(frames: &[WindowFrame]) -> CanvasMetrics { let min_x = frames.iter().map(|frame| frame.x).min().unwrap_or(0); let min_y = frames.iter().map(|frame| frame.y).min().unwrap_or(0); @@ -3346,18 +3449,35 @@ fn resolved_browser_uri(raw: &str) -> String { if trimmed.contains("://") { return trimmed.to_string(); } + if is_local_browser_target(trimmed) { + return format!("http://{trimmed}"); + } + if has_explicit_uri_scheme(trimmed) { + return trimmed.to_string(); + } if !looks_like_browser_location(trimmed) { return format!( "https://duckduckgo.com/?q={}", trimmed.split_whitespace().collect::>().join("+") ); } - if is_local_browser_target(trimmed) { - return format!("http://{trimmed}"); - } format!("https://{trimmed}") } +fn has_explicit_uri_scheme(value: &str) -> bool { + let Some((scheme, _)) = value.split_once(':') else { + return false; + }; + !scheme.is_empty() + && scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) + && scheme + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic()) +} + fn looks_like_browser_location(value: &str) -> bool { if is_local_browser_target(value) || value.parse::().is_ok() { return true; @@ -3386,7 +3506,7 @@ mod tests { use super::{ BootstrapModel, BrowserMountSpec, Direction, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, - SurfaceMountSpec, default_preview_app_state, resolved_browser_uri, + SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, resolved_browser_uri, }; fn bootstrap() -> BootstrapModel { @@ -3545,6 +3665,10 @@ mod tests { let browser = snapshot.browser_chrome.expect("active browser chrome"); assert!(!browser.url.trim().is_empty()); + core.dispatch_shell_action(ShellAction::NavigateBrowser { + surface_id: browser.surface_id, + url: "about:blank".into(), + }); core.dispatch_shell_action(ShellAction::BrowserReload { surface_id: browser.surface_id, }); @@ -3555,6 +3679,10 @@ mod tests { assert_eq!( core.drain_host_commands(), vec![ + HostCommand::BrowserNavigate { + surface_id: browser.surface_id, + url: "about:blank".into() + }, HostCommand::BrowserReload { surface_id: browser.surface_id }, @@ -3660,11 +3788,17 @@ mod tests { #[test] fn horizontal_scroll_host_events_pan_workspace_outside_overview() { let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { + direction: WorkspaceDirection::Right, + }); + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindow { + direction: WorkspaceDirection::Right, + }); let before = core.snapshot().current_workspace.viewport_x; core.apply_host_event(HostEvent::ViewportScrolled { dx: 180, dy: 0 }); let after = core.snapshot().current_workspace.viewport_x; - assert_eq!(after, before + 180); + assert!(after > before); core.dispatch_shell_action(ShellAction::ToggleOverview); core.apply_host_event(HostEvent::ViewportScrolled { dx: 180, dy: 0 }); @@ -3676,6 +3810,11 @@ mod tests { #[test] fn browser_address_bar_normalizes_search_queries() { assert_eq!(resolved_browser_uri(""), "about:blank"); + assert_eq!(resolved_browser_uri("about:blank"), "about:blank"); + assert_eq!( + resolved_browser_uri("file:///tmp/index.html"), + "file:///tmp/index.html" + ); assert_eq!( resolved_browser_uri("rust"), "https://duckduckgo.com/?q=rust" @@ -3691,6 +3830,22 @@ mod tests { ); } + #[test] + fn scroll_viewport_is_clamped_to_canvas_bounds() { + let core = SharedCore::bootstrap(bootstrap()); + + core.dispatch_shell_action(ShellAction::ScrollViewport { + dx: 50_000, + dy: 50_000, + }); + + let snapshot = core.snapshot(); + assert!(snapshot.current_workspace.viewport_x >= 0); + assert!(snapshot.current_workspace.viewport_x <= snapshot.current_workspace.canvas_width); + assert!(snapshot.current_workspace.viewport_y >= 0); + assert!(snapshot.current_workspace.viewport_y <= snapshot.current_workspace.canvas_height); + } + #[test] fn move_surface_shell_action_transfers_surface_between_panes() { let core = SharedCore::bootstrap(bootstrap()); @@ -3788,4 +3943,54 @@ mod tests { ); assert_eq!(snapshot.current_workspace.active_pane, new_pane_id); } + + #[test] + fn move_surface_to_workspace_shell_action_switches_workspace_and_keeps_surface() { + let core = SharedCore::bootstrap(bootstrap()); + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let target_workspace_id = core + .snapshot() + .workspaces + .iter() + .find(|workspace| workspace.active) + .map(|workspace| workspace.id) + .expect("target workspace"); + + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id, + surface_id: moved_surface_id, + target_workspace_id, + }); + + let snapshot = core.snapshot(); + assert_eq!(snapshot.current_workspace.id, target_workspace_id); + + let mut pane_ids = Vec::new(); + collect_pane_ids(&snapshot.current_workspace.layout, &mut pane_ids); + let moved_pane_id = pane_ids + .into_iter() + .find(|pane_id| { + find_pane(&snapshot.current_workspace.layout, *pane_id) + .is_some_and(|pane| pane.active_surface == moved_surface_id) + }) + .expect("moved pane"); + let moved_pane = + find_pane(&snapshot.current_workspace.layout, moved_pane_id).expect("moved pane"); + + assert_eq!( + moved_pane.surfaces.first().map(|surface| surface.id), + Some(moved_surface_id) + ); + assert_eq!(snapshot.current_workspace.active_pane, moved_pane_id); + } } diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index f452f19..bac8b6e 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -188,6 +188,11 @@ impl TaskersHost { pub fn handle_command(&mut self, command: HostCommand) -> Result<()> { match command { + HostCommand::BrowserNavigate { surface_id, url } => { + self.with_browser_surface(surface_id, "browser navigate", |surface| { + surface.navigate(&url) + }) + } HostCommand::BrowserBack { surface_id } => { self.with_browser_surface(surface_id, "browser back", |surface| surface.go_back()) } @@ -361,6 +366,8 @@ struct BrowserSurface { surface_id: SurfaceId, webview: WebView, url: String, + active: bool, + interactive: bool, devtools_open: Rc>, event_sink: HostEventSink, diagnostics: Option, @@ -396,26 +403,6 @@ impl BrowserSurface { position_widget(fixed, webview.upcast_ref(), plan.frame); let devtools_open = Rc::new(Cell::new(false)); - let pane_id = plan.pane_id; - let surface_id = plan.surface_id; - let focus_sink = event_sink.clone(); - let focus_diagnostics = diagnostics.clone(); - let click = GestureClick::new(); - click.connect_pressed(move |_, _, _, _| { - emit_diagnostic( - focus_diagnostics.as_ref(), - DiagnosticRecord::new( - DiagnosticCategory::HostEvent, - None, - "browser click focus event received", - ) - .with_pane(pane_id) - .with_surface(surface_id), - ); - (focus_sink)(HostEvent::PaneFocused { pane_id }); - }); - webview.add_controller(click); - let pane_id = plan.pane_id; let surface_id = plan.surface_id; let focus_sink = event_sink.clone(); @@ -548,6 +535,8 @@ impl BrowserSurface { surface_id: plan.surface_id, webview, url, + active: plan.active, + interactive, devtools_open, event_sink: url_sink, diagnostics, @@ -570,9 +559,11 @@ impl BrowserSurface { self.webview.load_uri(url); self.url = url.clone(); } - if plan.active && interactive { + if plan.active && interactive && (!self.active || !self.interactive) { self.webview.grab_focus(); } + self.active = plan.active; + self.interactive = interactive; emit_diagnostic( diagnostics, @@ -588,6 +579,15 @@ impl BrowserSurface { Ok(()) } + fn navigate(&mut self, url: &str) { + if self.url != url { + self.webview.load_uri(url); + self.url = url.to_string(); + } + self.webview.grab_focus(); + self.emit_navigation_state(); + } + fn go_back(&mut self) { if self.webview.can_go_back() { self.webview.go_back(); @@ -640,6 +640,8 @@ impl BrowserSurface { struct TerminalSurface { widget: Widget, + active: bool, + interactive: bool, } impl TerminalSurface { @@ -680,7 +682,11 @@ impl TerminalSurface { .with_surface(plan.surface_id), ); - Ok(Self { widget }) + Ok(Self { + widget, + active: plan.active, + interactive, + }) } fn sync( @@ -695,9 +701,11 @@ impl TerminalSurface { ) { self.widget.set_can_target(interactive); position_widget(fixed, &self.widget, frame); - if active && interactive { + if active && interactive && (!self.active || !self.interactive) { let _ = host.focus_surface(&self.widget); } + self.active = active; + self.interactive = interactive; emit_diagnostic( diagnostics, diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index a2fdb00..6493a05 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -189,6 +189,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { let drag_target = use_signal(|| None::); let surface_drag_source = use_signal(|| None::); let surface_drop_target = use_signal(|| None::); + let surface_workspace_target = use_signal(|| None::); let window_drag_source = use_signal(|| None::); let window_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); @@ -234,6 +235,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { core.clone(), drag_source, drag_target, + surface_drag_source, + surface_workspace_target, &workspace_ids, )} } @@ -324,6 +327,8 @@ fn render_workspace_item( core: SharedCore, mut drag_source: Signal>, mut drag_target: Signal>, + mut surface_drag_source: Signal>, + mut surface_workspace_target: Signal>, all_ids: &[WorkspaceId], ) -> Element { let tab_class = if workspace.active { @@ -393,8 +398,11 @@ fn render_workspace_item( .map(|color| format!("--workspace-accent: {color};")) .unwrap_or_default(); - let is_drag_target = *drag_target.read() == Some(workspace_id); - let outer_class = if is_drag_target { + let is_workspace_drag_target = *drag_target.read() == Some(workspace_id); + let is_surface_drag_target = *surface_workspace_target.read() == Some(workspace_id); + let outer_class = if is_surface_drag_target { + "workspace-button workspace-button-surface-drop" + } else if is_workspace_drag_target { "workspace-button workspace-button-drag-over" } else { "workspace-button" @@ -406,21 +414,42 @@ fn render_workspace_item( }; let on_dragover = move |event: Event| { event.prevent_default(); - drag_target.set(Some(workspace_id)); + if surface_drag_source.read().is_some() { + surface_workspace_target.set(Some(workspace_id)); + drag_target.set(None); + } else { + drag_target.set(Some(workspace_id)); + surface_workspace_target.set(None); + } }; let on_dragleave = move |_: Event| { if *drag_target.read() == Some(workspace_id) { drag_target.set(None); } + if *surface_workspace_target.read() == Some(workspace_id) { + surface_workspace_target.set(None); + } }; let on_drop = { let core = core.clone(); let all_ids = all_ids.clone(); move |event: Event| { event.prevent_default(); + let dragged_surface = *surface_drag_source.read(); let source = *drag_source.read(); drag_source.set(None); drag_target.set(None); + surface_workspace_target.set(None); + if let Some(dragged_surface) = dragged_surface { + surface_drag_source.set(None); + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id: dragged_surface.pane_id, + surface_id: dragged_surface.surface_id, + target_workspace_id: workspace_id, + }); + core.dispatch_shell_action(ShellAction::EndDrag); + return; + } if let Some(source_id) = source { if source_id != workspace_id { let mut new_order = all_ids.clone(); @@ -584,14 +613,16 @@ fn render_workspace_strip( if overview_scale < 1.0 { return; } - event.prevent_default(); let delta = event.delta().strip_units(); + if delta.x.abs() < 1.0 || delta.x.abs() < delta.y.abs() { + return; + } let dx = delta.x.round() as i32; - let dy = delta.y.round() as i32; - if dx == 0 && dy == 0 { + if dx == 0 { return; } - core.dispatch_shell_action(ShellAction::ScrollViewport { dx, dy }); + event.prevent_default(); + core.dispatch_shell_action(ShellAction::ScrollViewport { dx, dy: 0 }); } }; let canvas_style = format!( diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 3f1a372..322baf6 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -323,6 +323,11 @@ button {{ border-top: 2px solid var(--workspace-accent, {accent}); }} +.workspace-button-surface-drop .workspace-tab {{ + background: {accent_12}; + border-color: {accent_24}; +}} + .workspace-tab {{ position: relative; padding: 8px 10px 8px 14px; From 83de48984560ba15ca2eeaee04b674d745c1b317 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 18:48:03 +0100 Subject: [PATCH 45/63] fix: align greenfield portal frames with shell chrome --- greenfield/crates/taskers-core/src/lib.rs | 68 +++++++++++++++++++- greenfield/crates/taskers-shell/src/theme.rs | 6 +- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 5fe7c2d..2d00486 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -577,9 +577,11 @@ pub struct LayoutMetrics { pub activity_width: i32, pub toolbar_height: i32, pub workspace_padding: i32, + pub window_border_width: i32, pub window_toolbar_height: i32, pub window_body_padding: i32, pub split_gap: i32, + pub pane_border_width: i32, pub pane_header_height: i32, pub browser_toolbar_height: i32, pub surface_tab_height: i32, @@ -592,9 +594,11 @@ impl Default for LayoutMetrics { activity_width: 312, toolbar_height: 42, workspace_padding: 16, + window_border_width: 2, window_toolbar_height: 28, window_body_padding: 10, split_gap: 8, + pane_border_width: 1, pane_header_height: 26, browser_toolbar_height: 34, surface_tab_height: 28, @@ -3154,11 +3158,13 @@ fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Fra PaneKind::Browser => metrics.browser_toolbar_height, }; frame + .inset(metrics.pane_border_width) .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) } fn workspace_window_content_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { frame + .inset(metrics.window_border_width) .inset_top(metrics.window_toolbar_height) .inset(metrics.window_body_padding) } @@ -3506,7 +3512,8 @@ mod tests { use super::{ BootstrapModel, BrowserMountSpec, Direction, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, - SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, resolved_browser_uri, + SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, pane_body_frame, + resolved_browser_uri, split_frame, workspace_window_content_frame, }; fn bootstrap() -> BootstrapModel { @@ -3536,6 +3543,28 @@ mod tests { } } + fn find_pane_frame( + node: &super::LayoutNodeSnapshot, + pane_id: taskers_domain::PaneId, + frame: super::Frame, + gap: i32, + ) -> Option { + match node { + super::LayoutNodeSnapshot::Pane(pane) => (pane.id == pane_id).then_some(frame), + super::LayoutNodeSnapshot::Split { + axis, + ratio, + first, + second, + } => { + let ratio = (ratio.clamp(0.15, 0.85) * 1000.0).round() as u16; + let (first_frame, second_frame) = split_frame(frame, *axis, ratio, gap); + find_pane_frame(first, pane_id, first_frame, gap) + .or_else(|| find_pane_frame(second, pane_id, second_frame, gap)) + } + } + } + fn collect_pane_ids( node: &super::LayoutNodeSnapshot, pane_ids: &mut Vec, @@ -3591,6 +3620,43 @@ mod tests { ); } + #[test] + fn active_portal_surface_frame_matches_layout_insets() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let workspace = &snapshot.current_workspace; + let metrics = LayoutMetrics::default(); + let active_window = workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == workspace.active_window_id) + .expect("active window"); + let active_plan = snapshot + .portal + .panes + .iter() + .find(|plan| plan.pane_id == workspace.active_pane) + .expect("active portal plan"); + let pane_frame = find_pane_frame( + &active_window.layout, + workspace.active_pane, + workspace_window_content_frame(active_window.frame, metrics), + metrics.split_gap, + ) + .expect("active pane frame"); + let pane_kind = match &active_plan.mount { + SurfaceMountSpec::Browser(_) => taskers_domain::PaneKind::Browser, + SurfaceMountSpec::Terminal(_) => taskers_domain::PaneKind::Terminal, + }; + + assert_eq!( + active_plan.frame, + pane_body_frame(pane_frame, metrics, &pane_kind), + "expected native surface frame to match shell pane-body insets" + ); + } + #[test] fn split_browser_creates_real_browser_pane() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 322baf6..2619d85 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -175,8 +175,10 @@ pub fn generate_css(p: &ThemePalette) -> String { let sidebar_width = metrics.sidebar_width; let activity_width = metrics.activity_width; let workspace_toolbar_height = metrics.toolbar_height; + let window_border_width = metrics.window_border_width; let window_toolbar_height = metrics.window_toolbar_height; let window_body_padding = metrics.window_body_padding; + let pane_border_width = metrics.pane_border_width; let pane_header_height = metrics.pane_header_height; let surface_tab_height = metrics.surface_tab_height; let browser_toolbar_height = metrics.browser_toolbar_height; @@ -816,7 +818,7 @@ button {{ display: flex; flex-direction: column; background: {surface}; - border: 2px solid {border_07}; + border: {window_border_width}px solid {border_07}; overflow: hidden; }} @@ -879,7 +881,7 @@ button {{ display: flex; flex-direction: column; background: {elevated}; - border: 1px solid {border_10}; + border: {pane_border_width}px solid {border_10}; overflow: hidden; }} From c34c3fe1da4bd8057fd7899428cb9b8bf688a7e4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 19:58:12 +0100 Subject: [PATCH 46/63] fix: align greenfield terminal surfaces with pane bodies --- vendor/ghostty/src/taskers_bridge.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index a3d30cc..51d52a7 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -156,6 +156,12 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti cloned.@"shell-integration" = .none; cloned.@"shell-integration-features" = .{}; cloned.@"linux-cgroup" = .never; + // Embedded Taskers panes already supply their own chrome and spacing. + // Ghostty's default window padding makes the terminal grid float inside + // the pane body and visibly misalign with the shell layout. + cloned.@"window-padding-x" = .{ .top_left = 0, .bottom_right = 0 }; + cloned.@"window-padding-y" = .{ .top_left = 0, .bottom_right = 0 }; + cloned.@"window-padding-balance" = false; for (ptr.env_entries) |entry| { try cloned.env.parseCLI(alloc, entry); } From 31052da6639f2d09dcd31bea5d53fe241132d974 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 19:59:53 +0100 Subject: [PATCH 47/63] refactor: wrap greenfield native surfaces in host shells --- greenfield/crates/taskers-host/src/lib.rs | 68 ++++++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index bac8b6e..11c37aa 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow, bail}; use gtk::{ - EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, Fixed, GestureClick, - Overlay, Widget, glib, prelude::*, + Align, Box as GtkBox, EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, + Fixed, GestureClick, Orientation, Overlay, Widget, glib, prelude::*, }; use std::{ cell::Cell, @@ -255,7 +255,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.browser_surfaces.remove(&surface_id) { - detach_from_fixed(&self.surface_layer, surface.webview.upcast_ref()); + surface.shell.detach(&self.surface_layer); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -315,7 +315,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.terminal_surfaces.remove(&surface_id) { - detach_from_fixed(&self.surface_layer, &surface.widget); + surface.shell.detach(&self.surface_layer); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -363,6 +363,7 @@ impl TaskersHost { } struct BrowserSurface { + shell: NativeSurfaceShell, surface_id: SurfaceId, webview: WebView, url: String, @@ -394,13 +395,17 @@ impl BrowserSurface { .focusable(true) .settings(&settings) .build(); + webview.add_css_class("native-surface-widget"); + webview.add_css_class("native-surface-browser-widget"); webview.set_can_target(interactive); webview.load_uri(&url); (event_sink)(HostEvent::SurfaceUrlChanged { surface_id: plan.surface_id, url: url.clone(), }); - position_widget(fixed, webview.upcast_ref(), plan.frame); + let shell = NativeSurfaceShell::new("native-surface-browser"); + shell.mount_child(webview.upcast_ref()); + shell.position(fixed, plan.frame); let devtools_open = Rc::new(Cell::new(false)); let pane_id = plan.pane_id; @@ -532,6 +537,7 @@ impl BrowserSurface { ); Ok(Self { + shell, surface_id: plan.surface_id, webview, url, @@ -551,7 +557,7 @@ impl BrowserSurface { interactive: bool, diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { - position_widget(fixed, self.webview.upcast_ref(), plan.frame); + self.shell.position(fixed, plan.frame); self.webview.set_can_target(interactive); let BrowserMountSpec { url } = browser_spec(plan)?; @@ -639,6 +645,7 @@ impl BrowserSurface { } struct TerminalSurface { + shell: NativeSurfaceShell, widget: Widget, active: bool, interactive: bool, @@ -661,9 +668,16 @@ impl TerminalSurface { .map_err(|error| anyhow!(error.to_string()))?; widget.set_hexpand(true); widget.set_vexpand(true); + widget.set_halign(Align::Fill); + widget.set_valign(Align::Fill); widget.set_focusable(true); + widget.add_css_class("native-surface-widget"); + widget.add_css_class("native-surface-terminal-widget"); + widget.add_css_class("terminal-output"); widget.set_can_target(interactive); - position_widget(fixed, &widget, plan.frame); + let shell = NativeSurfaceShell::new("native-surface-terminal"); + shell.mount_child(&widget); + shell.position(fixed, plan.frame); connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); @@ -683,6 +697,7 @@ impl TerminalSurface { ); Ok(Self { + shell, widget, active: plan.active, interactive, @@ -700,7 +715,7 @@ impl TerminalSurface { diagnostics: Option<&DiagnosticsSink>, ) { self.widget.set_can_target(interactive); - position_widget(fixed, &self.widget, frame); + self.shell.position(fixed, frame); if active && interactive && (!self.active || !self.interactive) { let _ = host.focus_surface(&self.widget); } @@ -718,6 +733,43 @@ impl TerminalSurface { } } +struct NativeSurfaceShell { + root: GtkBox, +} + +impl NativeSurfaceShell { + fn new(kind_class: &'static str) -> Self { + let root = GtkBox::new(Orientation::Vertical, 0); + root.set_hexpand(true); + root.set_vexpand(true); + root.set_halign(Align::Fill); + root.set_valign(Align::Fill); + root.set_focusable(false); + root.set_can_target(false); + root.add_css_class("native-surface-host"); + root.add_css_class(kind_class); + Self { root } + } + + fn mount_child(&self, child: &Widget) { + child.set_hexpand(true); + child.set_vexpand(true); + child.set_halign(Align::Fill); + child.set_valign(Align::Fill); + if child.parent().is_none() { + self.root.append(child); + } + } + + fn position(&self, fixed: &Fixed, frame: taskers_core::Frame) { + position_widget(fixed, self.root.upcast_ref(), frame); + } + + fn detach(&self, fixed: &Fixed) { + detach_from_fixed(fixed, self.root.upcast_ref()); + } +} + fn connect_ghostty_widget( host: &GhosttyHost, widget: &Widget, From ef7e8d77c26d6c7a4bdbbb90a1a8c03e0b8b0283 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 21:27:04 +0100 Subject: [PATCH 48/63] fix: restore greenfield terminal pane host styling --- greenfield/crates/taskers-host/src/lib.rs | 43 +++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 11c37aa..9bc7258 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -1,7 +1,8 @@ use anyhow::{Result, anyhow, bail}; use gtk::{ - Align, Box as GtkBox, EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, - Fixed, GestureClick, Orientation, Overlay, Widget, glib, prelude::*, + Align, Box as GtkBox, CssProvider, EventControllerFocus, EventControllerScroll, + EventControllerScrollFlags, Fixed, GestureClick, Orientation, Overflow, Overlay, + STYLE_PROVIDER_PRIORITY_APPLICATION, Widget, glib, prelude::*, }; use std::{ cell::Cell, @@ -110,6 +111,7 @@ impl TaskersHost { root.set_hexpand(true); root.set_vexpand(true); root.set_child(Some(shell_widget)); + install_native_surface_css(); let surface_layer = Fixed::new(); surface_layer.set_hexpand(true); @@ -744,6 +746,7 @@ impl NativeSurfaceShell { root.set_vexpand(true); root.set_halign(Align::Fill); root.set_valign(Align::Fill); + root.set_overflow(Overflow::Hidden); root.set_focusable(false); root.set_can_target(false); root.add_css_class("native-surface-host"); @@ -770,6 +773,42 @@ impl NativeSurfaceShell { } } +fn install_native_surface_css() { + let provider = CssProvider::new(); + provider.load_from_data(native_surface_css()); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} + +fn native_surface_css() -> &'static str { + r#" +.native-surface-host, +.native-surface-widget, +.terminal-output { + margin: 0; + padding: 0; + border-radius: 0; + box-shadow: none; +} + +.native-surface-terminal, +.native-surface-terminal-widget, +.terminal-output { + background: #0f1117; +} + +.native-surface-browser, +.native-surface-browser-widget { + background: transparent; +} +"# +} + fn connect_ghostty_widget( host: &GhosttyHost, widget: &Widget, From 791f02085810d445e5f8b43fae1e3d8bf804a759 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 20 Mar 2026 21:28:02 +0100 Subject: [PATCH 49/63] test: cover greenfield native terminal host mounting --- greenfield/crates/taskers-host/src/lib.rs | 44 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/greenfield/crates/taskers-host/src/lib.rs b/greenfield/crates/taskers-host/src/lib.rs index 9bc7258..c3e9e98 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/greenfield/crates/taskers-host/src/lib.rs @@ -397,15 +397,16 @@ impl BrowserSurface { .focusable(true) .settings(&settings) .build(); + let (shell_class, widget_class) = native_surface_classes(PaneKind::Browser); webview.add_css_class("native-surface-widget"); - webview.add_css_class("native-surface-browser-widget"); + webview.add_css_class(widget_class); webview.set_can_target(interactive); webview.load_uri(&url); (event_sink)(HostEvent::SurfaceUrlChanged { surface_id: plan.surface_id, url: url.clone(), }); - let shell = NativeSurfaceShell::new("native-surface-browser"); + let shell = NativeSurfaceShell::new(shell_class); shell.mount_child(webview.upcast_ref()); shell.position(fixed, plan.frame); let devtools_open = Rc::new(Cell::new(false)); @@ -673,11 +674,12 @@ impl TerminalSurface { widget.set_halign(Align::Fill); widget.set_valign(Align::Fill); widget.set_focusable(true); + let (shell_class, widget_class) = native_surface_classes(PaneKind::Terminal); widget.add_css_class("native-surface-widget"); - widget.add_css_class("native-surface-terminal-widget"); + widget.add_css_class(widget_class); widget.add_css_class("terminal-output"); widget.set_can_target(interactive); - let shell = NativeSurfaceShell::new("native-surface-terminal"); + let shell = NativeSurfaceShell::new(shell_class); shell.mount_child(&widget); shell.position(fixed, plan.frame); @@ -785,6 +787,13 @@ fn install_native_surface_css() { } } +fn native_surface_classes(kind: PaneKind) -> (&'static str, &'static str) { + match kind { + PaneKind::Terminal => ("native-surface-terminal", "native-surface-terminal-widget"), + PaneKind::Browser => ("native-surface-browser", "native-surface-browser-widget"), + } +} + fn native_surface_css() -> &'static str { r#" .native-surface-host, @@ -1069,8 +1078,12 @@ fn current_timestamp_ms() -> u128 { #[cfg(test)] mod tests { - use super::{browser_plans, native_surfaces_interactive, terminal_plans, workspace_pan_delta}; + use super::{ + browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, + terminal_plans, workspace_pan_delta, + }; use taskers_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; + use taskers_domain::PaneKind; #[test] fn partitions_portal_plans_by_surface_kind() { @@ -1100,4 +1113,25 @@ mod tests { assert!(!native_surfaces_interactive(ShellDragMode::Window)); assert!(!native_surfaces_interactive(ShellDragMode::Surface)); } + + #[test] + fn native_surface_classes_distinguish_terminal_and_browser_hosts() { + assert_eq!( + native_surface_classes(PaneKind::Terminal), + ("native-surface-terminal", "native-surface-terminal-widget") + ); + assert_eq!( + native_surface_classes(PaneKind::Browser), + ("native-surface-browser", "native-surface-browser-widget") + ); + } + + #[test] + fn native_surface_css_restores_terminal_background_contract() { + let css = native_surface_css(); + assert!(css.contains(".native-surface-terminal")); + assert!(css.contains(".native-surface-terminal-widget")); + assert!(css.contains(".terminal-output")); + assert!(css.contains("background: #0f1117;")); + } } From 74b58244f0c25705af507707e302b8d6c1e91ddf Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 09:50:22 +0100 Subject: [PATCH 50/63] fix: contain greenfield shell chrome by grid track --- greenfield/crates/taskers-shell/src/theme.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index 2619d85..c1c3143 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -219,6 +219,10 @@ button {{ display: flex; flex-direction: column; min-height: 0; + position: relative; + overflow: hidden; + isolation: isolate; + contain: paint; }} .workspace-sidebar {{ @@ -644,6 +648,10 @@ button {{ display: flex; flex-direction: column; background: {base}; + position: relative; + overflow: hidden; + isolation: isolate; + contain: paint; }} .workspace-main-overview .workspace-canvas {{ From b4489459c0ccee29997e955d054fabfcc71c06af Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:06:49 +0100 Subject: [PATCH 51/63] fix: point desktop entry at greenfield dev launcher --- greenfield/README.md | 11 ++++ .../scripts/install-dev-desktop-entry.sh | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100755 greenfield/scripts/install-dev-desktop-entry.sh diff --git a/greenfield/README.md b/greenfield/README.md index 071b33c..4c0db62 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -19,6 +19,17 @@ Run it from this workspace: cargo run -p taskers ``` +To keep the desktop launcher pointed at the repo-local greenfield app instead of the installed +`taskers` launcher, run: + +```bash +greenfield/scripts/install-dev-desktop-entry.sh +``` + +This writes `~/.local/bin/taskers-greenfield` and updates +`~/.local/share/applications/dev.taskers.app.desktop` to launch greenfield through `cargo run`, +so desktop launches pick up the latest local code. + Run the scripted baseline smoke with isolated XDG and runtime paths: ```bash diff --git a/greenfield/scripts/install-dev-desktop-entry.sh b/greenfield/scripts/install-dev-desktop-entry.sh new file mode 100755 index 0000000..36ed6a7 --- /dev/null +++ b/greenfield/scripts/install-dev-desktop-entry.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}" +launcher_home="${HOME}/.local/bin" +desktop_entry_path="${xdg_data_home}/applications/dev.taskers.app.desktop" +launcher_path="${launcher_home}/taskers-greenfield" + +if [[ -n "${CARGO:-}" ]]; then + cargo_bin="${CARGO}" +elif [[ -n "${CARGO_HOME:-}" ]]; then + cargo_bin="${CARGO_HOME}/bin/cargo" +else + cargo_bin="${HOME}/.cargo/bin/cargo" +fi + +if [[ ! -x "${cargo_bin}" ]]; then + cargo_bin="$(command -v cargo)" +fi + +mkdir -p "${launcher_home}" "$(dirname -- "${desktop_entry_path}")" + +cat > "${launcher_path}" < "${desktop_entry_path}" </dev/null 2>&1; then + update-desktop-database "${xdg_data_home}/applications" +fi + +echo "installed ${desktop_entry_path}" From ff9b97590dec13747243168f351aa4ca4e60836d Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:12:16 +0100 Subject: [PATCH 52/63] fix: make greenfield panes fill workspace windows --- greenfield/crates/taskers-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 2d00486..91316ff 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -596,7 +596,7 @@ impl Default for LayoutMetrics { workspace_padding: 16, window_border_width: 2, window_toolbar_height: 28, - window_body_padding: 10, + window_body_padding: 0, split_gap: 8, pane_border_width: 1, pane_header_height: 26, From 2390a0bded73a4dcc7af3fa60efbc2984a7fdbd6 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:20:06 +0100 Subject: [PATCH 53/63] refactor: split greenfield normal and overview geometry --- greenfield/crates/taskers-core/src/lib.rs | 51 +++++++++++++---------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 91316ff..4c4064a 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -1072,6 +1072,7 @@ struct CanvasMetrics { struct WorkspaceRenderContext { overview_mode: bool, overview_scale: f64, + outer_padding: i32, viewport_width: i32, viewport_height: i32, } @@ -1139,9 +1140,10 @@ impl TaskersCore { self.ui.overview_mode, viewport.width, viewport.height, + self.metrics, ); let placements = workspace_display_window_placements(workspace, render_context); - let canvas_metrics = workspace_canvas_metrics(&placements); + let canvas_metrics = workspace_canvas_metrics(&placements, render_context.outer_padding); let window_frames = placements .iter() .map(|placement| { @@ -1217,13 +1219,11 @@ impl TaskersCore { let metrics = self.metrics; let width = (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width).max(640); - let height = self.ui.window_size.height.max(320); - let inset = metrics.workspace_padding; Frame::new( - metrics.sidebar_width + inset, - metrics.toolbar_height + inset, - (width - inset * 2).max(320), - (height - metrics.toolbar_height - inset * 2).max(220), + metrics.sidebar_width, + metrics.toolbar_height, + width, + (self.ui.window_size.height - metrics.toolbar_height).max(220), ) } @@ -2783,11 +2783,13 @@ fn workspace_render_context( overview_mode: bool, viewport_width: i32, viewport_height: i32, + metrics: LayoutMetrics, ) -> WorkspaceRenderContext { if !overview_mode { return WorkspaceRenderContext { overview_mode: false, overview_scale: 1.0, + outer_padding: 0, viewport_width, viewport_height, }; @@ -2797,16 +2799,18 @@ fn workspace_render_context( .into_iter() .map(|placement| placement.frame) .collect::>(); - let base_metrics = canvas_metrics_from_frames(&base_frames); - let content_width = (base_metrics.width - 4).max(1); - let content_height = (base_metrics.height - 4).max(1); - let overview_scale = (f64::from(viewport_width.max(1)) / f64::from(content_width)) - .min(f64::from(viewport_height.max(1)) / f64::from(content_height)) + let base_metrics = canvas_metrics_from_frames(&base_frames, 0); + let outer_padding = metrics.workspace_padding; + let available_width = (viewport_width - outer_padding * 2).max(1); + let available_height = (viewport_height - outer_padding * 2).max(1); + let overview_scale = (f64::from(available_width) / f64::from(base_metrics.width.max(1))) + .min(f64::from(available_height) / f64::from(base_metrics.height.max(1))) .clamp(0.05, 1.0); WorkspaceRenderContext { overview_mode: true, overview_scale, + outer_padding, viewport_width, viewport_height, } @@ -2903,12 +2907,15 @@ fn workspace_window_placements( placements } -fn workspace_canvas_metrics(placements: &[WorkspaceWindowPlacement]) -> CanvasMetrics { +fn workspace_canvas_metrics( + placements: &[WorkspaceWindowPlacement], + outer_padding: i32, +) -> CanvasMetrics { let frames = placements .iter() .map(|placement| placement.frame) .collect::>(); - canvas_metrics_from_frames(&frames) + canvas_metrics_from_frames(&frames, outer_padding) } fn clamped_workspace_viewport( @@ -2918,7 +2925,7 @@ fn clamped_workspace_viewport( viewport: taskers_domain::WorkspaceViewport, ) -> taskers_domain::WorkspaceViewport { let placements = workspace_window_placements(workspace, viewport_width, viewport_height); - let canvas = workspace_canvas_metrics(&placements); + let canvas = workspace_canvas_metrics(&placements, 0); let max_x = (canvas.width - viewport_width).max(0); let max_y = (canvas.height - viewport_height).max(0); @@ -2928,21 +2935,21 @@ fn clamped_workspace_viewport( } } -fn canvas_metrics_from_frames(frames: &[WindowFrame]) -> CanvasMetrics { +fn canvas_metrics_from_frames(frames: &[WindowFrame], outer_padding: i32) -> CanvasMetrics { let min_x = frames.iter().map(|frame| frame.x).min().unwrap_or(0); let min_y = frames.iter().map(|frame| frame.y).min().unwrap_or(0); - let offset_x = 2 - min_x; - let offset_y = 2 - min_y; + let offset_x = outer_padding - min_x; + let offset_y = outer_padding - min_y; let width = frames .iter() - .map(|frame| frame.right() + offset_x + 2) + .map(|frame| frame.right() + offset_x + outer_padding) .max() - .unwrap_or(4); + .unwrap_or(outer_padding.saturating_mul(2)); let height = frames .iter() - .map(|frame| frame.bottom() + offset_y + 2) + .map(|frame| frame.bottom() + offset_y + outer_padding) .max() - .unwrap_or(4); + .unwrap_or(outer_padding.saturating_mul(2)); CanvasMetrics { offset_x, From c9064fd12a73b103fde434c262fcb7f13e7899fa Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:23:06 +0100 Subject: [PATCH 54/63] feat: collapse empty greenfield attention rail --- greenfield/crates/taskers-core/src/lib.rs | 44 ++++++++++--- greenfield/crates/taskers-shell/src/lib.rs | 66 +++++++++++--------- greenfield/crates/taskers-shell/src/theme.rs | 14 ++++- 3 files changed, 81 insertions(+), 43 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 4c4064a..52099ba 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -883,6 +883,7 @@ pub struct ShellSnapshot { pub section: ShellSection, pub overview_mode: bool, pub drag_mode: ShellDragMode, + pub attention_panel_visible: bool, pub workspaces: Vec, pub current_workspace: WorkspaceViewSnapshot, pub browser_chrome: Option, @@ -1118,6 +1119,11 @@ impl TaskersCore { fn snapshot(&self) -> ShellSnapshot { let model = self.app_state.snapshot_model(); + let agents = self.agent_sessions_snapshot(&model); + let activity = self.activity_snapshot(&model); + let done_activity = self.done_activity_snapshot(&model); + let attention_panel_visible = + !agents.is_empty() || !activity.is_empty() || !done_activity.is_empty(); let workspace_id = model .active_workspace_id() .expect("active workspace should exist"); @@ -1128,7 +1134,7 @@ impl TaskersCore { let active_window = workspace .active_window_record() .expect("active workspace window should exist"); - let viewport = self.workspace_viewport_frame(); + let viewport = self.workspace_viewport_frame(attention_panel_visible); let clamped_viewport = clamped_workspace_viewport( workspace, viewport.width, @@ -1169,6 +1175,7 @@ impl TaskersCore { section: self.ui.section, overview_mode: self.ui.overview_mode, drag_mode: self.ui.drag_mode, + attention_panel_visible, workspaces: self.workspace_summaries(&model), current_workspace: WorkspaceViewSnapshot { id: workspace_id, @@ -1195,9 +1202,9 @@ impl TaskersCore { layout: self.snapshot_layout(workspace, &active_window.layout), }, browser_chrome: self.browser_chrome_snapshot(workspace), - agents: self.agent_sessions_snapshot(&model), - activity: self.activity_snapshot(&model), - done_activity: self.done_activity_snapshot(&model), + agents, + activity, + done_activity, portal: SurfacePortalPlan { window: Frame::new(0, 0, self.ui.window_size.width, self.ui.window_size.height), content: viewport, @@ -1215,10 +1222,14 @@ impl TaskersCore { } } - fn workspace_viewport_frame(&self) -> Frame { + fn workspace_viewport_frame(&self, attention_panel_visible: bool) -> Frame { let metrics = self.metrics; - let width = - (self.ui.window_size.width - metrics.sidebar_width - metrics.activity_width).max(640); + let activity_width = if attention_panel_visible { + metrics.activity_width + } else { + 0 + }; + let width = (self.ui.window_size.width - metrics.sidebar_width - activity_width).max(640); Frame::new( metrics.sidebar_width, metrics.toolbar_height, @@ -2053,7 +2064,7 @@ impl TaskersCore { let Some(workspace) = model.workspaces.get(&workspace_id) else { return false; }; - let viewport_frame = self.workspace_viewport_frame(); + let viewport_frame = self.workspace_viewport_frame(attention_panel_visible(&model)); let current_viewport = clamped_workspace_viewport( workspace, viewport_frame.width, @@ -2479,7 +2490,7 @@ impl TaskersCore { let Some(workspace) = model.workspaces.get(&workspace_id) else { return false; }; - let viewport_frame = self.workspace_viewport_frame(); + let viewport_frame = self.workspace_viewport_frame(attention_panel_visible(&model)); let current_viewport = clamped_workspace_viewport( workspace, viewport_frame.width, @@ -3410,6 +3421,21 @@ fn next_workspace_label(model: &AppModel) -> String { format!("Workspace {}", model.workspaces.len() + 1) } +fn attention_panel_visible(model: &AppModel) -> bool { + model + .workspace_summaries(model.active_window) + .map(|summaries| { + summaries + .iter() + .any(|summary| !summary.agent_summaries.is_empty()) + }) + .unwrap_or(false) + || model + .workspaces + .values() + .any(|workspace| !workspace.notifications.is_empty()) +} + fn fallback_surface_descriptor(surface: &SurfaceRecord) -> SurfaceDescriptor { SurfaceDescriptor { cols: 120, diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/greenfield/crates/taskers-shell/src/lib.rs index 6493a05..2583ffa 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/greenfield/crates/taskers-shell/src/lib.rs @@ -130,9 +130,11 @@ fn apply_surface_drop( } fn app_css(snapshot: &ShellSnapshot) -> String { - theme::generate_css(&theme::resolve_palette( - &snapshot.settings.selected_theme_id, - )) + theme::generate_css( + &theme::resolve_palette(&snapshot.settings.selected_theme_id), + snapshot.metrics, + snapshot.attention_panel_visible, + ) } #[component] @@ -280,40 +282,42 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } - aside { class: "attention-panel", - div { class: "notification-header", - div { class: "sidebar-heading", "Notifications" } - div { class: "notification-counts", - if !snapshot.agents.is_empty() { - span { class: "notification-count-pill notification-count-agents", - "{snapshot.agents.len()} agents" + if snapshot.attention_panel_visible { + aside { class: "attention-panel", + div { class: "notification-header", + div { class: "sidebar-heading", "Notifications" } + div { class: "notification-counts", + if !snapshot.agents.is_empty() { + span { class: "notification-count-pill notification-count-agents", + "{snapshot.agents.len()} agents" + } } - } - if snapshot.activity.len() > 0 { - span { class: "notification-count-pill notification-count-unread", - "{snapshot.activity.len()} unread" + if snapshot.activity.len() > 0 { + span { class: "notification-count-pill notification-count-unread", + "{snapshot.activity.len()} unread" + } } } } - } - if !snapshot.agents.is_empty() { - div { class: "agent-session-list", - for agent in &snapshot.agents { - {render_agent_item(agent, core.clone(), &snapshot.current_workspace)} + if !snapshot.agents.is_empty() { + div { class: "agent-session-list", + for agent in &snapshot.agents { + {render_agent_item(agent, core.clone(), &snapshot.current_workspace)} + } } } - } - div { class: "notification-timeline", - if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { - div { class: "notification-empty", - div { class: "notification-empty-title", "No notifications" } - } - } else { - for item in &snapshot.activity { - {render_notification_row(item, core.clone(), &snapshot.current_workspace)} - } - for item in snapshot.done_activity.iter().take(8) { - {render_notification_row(item, core.clone(), &snapshot.current_workspace)} + div { class: "notification-timeline", + if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { + div { class: "notification-empty", + div { class: "notification-empty-title", "No notifications" } + } + } else { + for item in &snapshot.activity { + {render_notification_row(item, core.clone(), &snapshot.current_workspace)} + } + for item in snapshot.done_activity.iter().take(8) { + {render_notification_row(item, core.clone(), &snapshot.current_workspace)} + } } } } diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/greenfield/crates/taskers-shell/src/theme.rs index c1c3143..7e1c8bd 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/greenfield/crates/taskers-shell/src/theme.rs @@ -170,8 +170,11 @@ fn rgba(color: Color, alpha: f32) -> String { format!("rgba({},{},{},{alpha:.2})", color.r, color.g, color.b) } -pub fn generate_css(p: &ThemePalette) -> String { - let metrics = LayoutMetrics::default(); +pub fn generate_css( + p: &ThemePalette, + metrics: LayoutMetrics, + attention_panel_visible: bool, +) -> String { let sidebar_width = metrics.sidebar_width; let activity_width = metrics.activity_width; let workspace_toolbar_height = metrics.toolbar_height; @@ -183,6 +186,11 @@ pub fn generate_css(p: &ThemePalette) -> String { let surface_tab_height = metrics.surface_tab_height; let browser_toolbar_height = metrics.browser_toolbar_height; let split_gap = metrics.split_gap; + let app_shell_columns = if attention_panel_visible { + format!("{sidebar_width}px minmax(0, 1fr) {activity_width}px") + } else { + format!("{sidebar_width}px minmax(0, 1fr)") + }; let mut css = String::with_capacity(18_000); let _ = write!( css, @@ -209,7 +217,7 @@ button {{ height: 100vh; background: {base}; display: grid; - grid-template-columns: {sidebar_width}px minmax(0, 1fr) {activity_width}px; + grid-template-columns: {app_shell_columns}; overflow: hidden; }} From 9fa1283c670466a4252c2da07f743e40fb763fd4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:24:15 +0100 Subject: [PATCH 55/63] test: lock greenfield workspace fit invariants --- greenfield/crates/taskers-core/src/lib.rs | 128 +++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/greenfield/crates/taskers-core/src/lib.rs b/greenfield/crates/taskers-core/src/lib.rs index 52099ba..09f67ef 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/greenfield/crates/taskers-core/src/lib.rs @@ -3540,13 +3540,21 @@ fn is_local_browser_target(value: &str) -> bool { #[cfg(test)] mod tests { + use taskers_app_core::AppState; use taskers_control::ControlCommand; + use taskers_domain::{ + AppModel, AttentionState as DomainAttentionState, NotificationItem, SignalKind, + }; + use taskers_ghostty::BackendChoice; + use taskers_runtime::ShellLaunchSpec; + use time::OffsetDateTime; use super::{ BootstrapModel, BrowserMountSpec, Direction, HostCommand, HostEvent, LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, - SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, pane_body_frame, - resolved_browser_uri, split_frame, workspace_window_content_frame, + SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, + default_session_path_for_preview, pane_body_frame, resolved_browser_uri, split_frame, + workspace_window_content_frame, }; fn bootstrap() -> BootstrapModel { @@ -3564,6 +3572,53 @@ mod tests { } } + fn bootstrap_with_notification(cleared: bool) -> BootstrapModel { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let (pane_id, surface_id) = { + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let pane_id = workspace.active_pane; + let surface_id = workspace + .panes + .get(&pane_id) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + (pane_id, surface_id) + }; + let now = OffsetDateTime::now_utc(); + model + .workspaces + .get_mut(&workspace_id) + .expect("workspace") + .notifications + .push(NotificationItem { + pane_id, + surface_id, + kind: SignalKind::Notification, + state: DomainAttentionState::WaitingInput, + title: Some("Heads up".into()), + message: "Needs attention".into(), + created_at: now, + cleared_at: cleared.then_some(now), + }); + + BootstrapModel { + app_state: AppState::new( + model, + default_session_path_for_preview(if cleared { + "greenfield-preview-done-activity" + } else { + "greenfield-preview-activity" + }), + BackendChoice::Mock, + ShellLaunchSpec::fallback(), + ) + .expect("preview app state"), + ..bootstrap() + } + } + fn find_pane<'a>( node: &'a super::LayoutNodeSnapshot, pane_id: taskers_domain::PaneId, @@ -3884,6 +3939,75 @@ mod tests { assert!(snapshot.portal.panes.is_empty()); } + #[test] + fn single_window_fills_normal_mode_viewport_when_attention_panel_hidden() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let workspace = &snapshot.current_workspace; + let active_window = workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == workspace.active_window_id) + .expect("active window"); + + assert!(!snapshot.attention_panel_visible); + assert_eq!(snapshot.portal.content.x, snapshot.metrics.sidebar_width); + assert_eq!(snapshot.portal.content.y, snapshot.metrics.toolbar_height); + assert_eq!(active_window.frame.x, snapshot.portal.content.x); + assert_eq!(active_window.frame.y, snapshot.portal.content.y); + assert_eq!(active_window.frame.width, snapshot.portal.content.width); + assert_eq!(active_window.frame.height, snapshot.portal.content.height); + } + + #[test] + fn normal_mode_canvas_offsets_are_zero() { + let snapshot = SharedCore::bootstrap(bootstrap()).snapshot(); + + assert_eq!(snapshot.current_workspace.canvas_offset_x, 0); + assert_eq!(snapshot.current_workspace.canvas_offset_y, 0); + } + + #[test] + fn overview_mode_uses_outer_padding() { + let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::ToggleOverview); + let snapshot = core.snapshot(); + let workspace = &snapshot.current_workspace; + let active_window = workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == workspace.active_window_id) + .expect("active window"); + + assert!(snapshot.overview_mode); + assert!(workspace.canvas_offset_x > 0); + assert!(workspace.canvas_offset_y > 0); + assert!(active_window.frame.x > workspace.viewport_origin_x); + assert!(active_window.frame.y > workspace.viewport_origin_y); + } + + #[test] + fn attention_panel_visibility_tracks_activity_content() { + let empty_snapshot = SharedCore::bootstrap(bootstrap()).snapshot(); + let unread_snapshot = SharedCore::bootstrap(bootstrap_with_notification(false)).snapshot(); + let done_snapshot = SharedCore::bootstrap(bootstrap_with_notification(true)).snapshot(); + + assert!(!empty_snapshot.attention_panel_visible); + assert!(empty_snapshot.activity.is_empty()); + assert!(empty_snapshot.done_activity.is_empty()); + + assert!(unread_snapshot.attention_panel_visible); + assert!(!unread_snapshot.activity.is_empty()); + assert!(unread_snapshot.portal.content.width < empty_snapshot.portal.content.width); + + assert!(done_snapshot.attention_panel_visible); + assert!(done_snapshot.activity.is_empty()); + assert!(!done_snapshot.done_activity.is_empty()); + assert!(done_snapshot.portal.content.width < empty_snapshot.portal.content.width); + } + #[test] fn horizontal_scroll_host_events_pan_workspace_outside_overview() { let core = SharedCore::bootstrap(bootstrap()); From 80bcdbbc73c01e9aacd94094ec7a379d9c6bdf77 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 10:39:07 +0100 Subject: [PATCH 56/63] fix: tighten greenfield workspace window gaps --- crates/taskers-domain/src/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 745d7b6..d460a8d 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -13,7 +13,7 @@ use crate::{ pub const SESSION_SCHEMA_VERSION: u32 = 4; pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280; pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860; -pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 16; +pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 10; pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720; pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420; pub const KEYBOARD_RESIZE_STEP: i32 = 80; From b0ead5a3788f9fd81bd057fba99c9ebbc4e43962 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 11:23:19 +0100 Subject: [PATCH 57/63] chore: archive legacy product paths under taskers-old --- Cargo.lock | 259 +----------------- Cargo.toml | 2 - .../.github}/workflows/macos-preview.yml | 0 taskers-old/README.md | 9 + .../crates}/taskers-app/Cargo.toml | 0 .../crates}/taskers-app/src/crash_reporter.rs | 0 .../crates}/taskers-app/src/main.rs | 0 .../crates}/taskers-app/src/settings_store.rs | 0 .../taskers-app/src/terminal_transitions.rs | 0 .../crates}/taskers-app/src/theme.rs | 0 .../crates}/taskers-app/src/themes.rs | 0 .../crates}/taskers-macos-ffi/Cargo.toml | 0 .../include/taskers_macos_ffi.h | 0 .../crates}/taskers-macos-ffi/src/lib.rs | 0 {docs => taskers-old/docs}/release.md | 0 .../docs}/screenshots/demo-attention.png | Bin .../docs}/screenshots/demo-layout.png | Bin .../macos}/TaskersMac/Info.plist | 0 .../Sources/TaskersBrowserView.swift | 0 .../Sources/TaskersCoreBridge.swift | 0 .../Sources/TaskersDefaultSurfaceHost.swift | 0 .../Sources/TaskersEnvironment.swift | 0 .../Sources/TaskersGhosttyHost.swift | 0 .../Sources/TaskersMac-Bridging-Header.h | 0 .../Sources/TaskersMockSurfaceHost.swift | 0 .../TaskersMac/Sources/TaskersSnapshot.swift | 0 .../Sources/TaskersSurfaceHosting.swift | 0 .../Sources/TaskersTerminalView.swift | 0 .../Sources/TaskersWorkspaceController.swift | 0 .../macos}/TaskersMac/Sources/main.swift | 0 .../TaskersBrowserViewTests.swift | 0 .../TaskersCoreBridgeTests.swift | 0 .../TaskersMacTests/TaskersSmokeTests.swift | 0 .../TaskersSnapshotTests.swift | 0 {macos => taskers-old/macos}/project.yml | 0 .../scripts}/build_macos_dmg.sh | 0 .../scripts}/capture_demo_screenshots.sh | 0 .../scripts}/generate_macos_project.sh | 0 .../install_macos_codesign_certificate.sh | 0 .../scripts}/macos-build-preview-deps.sh | 0 .../scripts}/notarize_macos_dmg.sh | 0 .../scripts}/sign_macos_app.sh | 0 .../scripts}/smoke_taskers_focus_churn.sh | 0 .../scripts}/smoke_taskers_ui.sh | 0 .../scripts}/stage_macos_bundle_support.sh | 0 45 files changed, 12 insertions(+), 258 deletions(-) rename {.github => taskers-old/.github}/workflows/macos-preview.yml (100%) create mode 100644 taskers-old/README.md rename {crates => taskers-old/crates}/taskers-app/Cargo.toml (100%) rename {crates => taskers-old/crates}/taskers-app/src/crash_reporter.rs (100%) rename {crates => taskers-old/crates}/taskers-app/src/main.rs (100%) rename {crates => taskers-old/crates}/taskers-app/src/settings_store.rs (100%) rename {crates => taskers-old/crates}/taskers-app/src/terminal_transitions.rs (100%) rename {crates => taskers-old/crates}/taskers-app/src/theme.rs (100%) rename {crates => taskers-old/crates}/taskers-app/src/themes.rs (100%) rename {crates => taskers-old/crates}/taskers-macos-ffi/Cargo.toml (100%) rename {crates => taskers-old/crates}/taskers-macos-ffi/include/taskers_macos_ffi.h (100%) rename {crates => taskers-old/crates}/taskers-macos-ffi/src/lib.rs (100%) rename {docs => taskers-old/docs}/release.md (100%) rename {docs => taskers-old/docs}/screenshots/demo-attention.png (100%) rename {docs => taskers-old/docs}/screenshots/demo-layout.png (100%) rename {macos => taskers-old/macos}/TaskersMac/Info.plist (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersBrowserView.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersCoreBridge.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersEnvironment.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersGhosttyHost.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersMac-Bridging-Header.h (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersMockSurfaceHost.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersSnapshot.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersSurfaceHosting.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersTerminalView.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/TaskersWorkspaceController.swift (100%) rename {macos => taskers-old/macos}/TaskersMac/Sources/main.swift (100%) rename {macos => taskers-old/macos}/TaskersMacTests/TaskersBrowserViewTests.swift (100%) rename {macos => taskers-old/macos}/TaskersMacTests/TaskersCoreBridgeTests.swift (100%) rename {macos => taskers-old/macos}/TaskersMacTests/TaskersSmokeTests.swift (100%) rename {macos => taskers-old/macos}/TaskersMacTests/TaskersSnapshotTests.swift (100%) rename {macos => taskers-old/macos}/project.yml (100%) rename {scripts => taskers-old/scripts}/build_macos_dmg.sh (100%) rename {scripts => taskers-old/scripts}/capture_demo_screenshots.sh (100%) rename {scripts => taskers-old/scripts}/generate_macos_project.sh (100%) rename {scripts => taskers-old/scripts}/install_macos_codesign_certificate.sh (100%) rename {scripts => taskers-old/scripts}/macos-build-preview-deps.sh (100%) rename {scripts => taskers-old/scripts}/notarize_macos_dmg.sh (100%) rename {scripts => taskers-old/scripts}/sign_macos_app.sh (100%) rename {scripts => taskers-old/scripts}/smoke_taskers_focus_churn.sh (100%) rename {scripts => taskers-old/scripts}/smoke_taskers_ui.sh (100%) rename {scripts => taskers-old/scripts}/stage_macos_bundle_support.sh (100%) diff --git a/Cargo.lock b/Cargo.lock index 631e1c5..6ae365d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,12 +64,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "autocfg" version = "1.5.0" @@ -297,15 +291,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -871,29 +856,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "javascriptcore6" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" -dependencies = [ - "glib", - "javascriptcore6-sys", - "libc", -] - -[[package]] -name = "javascriptcore6-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "js-sys" version = "0.3.91" @@ -904,17 +866,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kurbo" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -927,37 +878,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "libadwaita" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" -dependencies = [ - "gdk4", - "gio", - "glib", - "gtk4", - "libadwaita-sys", - "libc", - "pango", -] - -[[package]] -name = "libadwaita-sys" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" -dependencies = [ - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "libc", - "pango-sys", - "system-deps", -] - [[package]] name = "libc" version = "0.2.183" @@ -1069,15 +989,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1190,7 +1101,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit", ] [[package]] @@ -1352,15 +1263,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.0.4" @@ -1420,12 +1322,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" @@ -1448,32 +1344,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "soup3" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1492,16 +1362,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svgtypes" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" -dependencies = [ - "kurbo", - "siphasher", -] - [[package]] name = "syn" version = "2.0.117" @@ -1533,7 +1393,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 0.9.12+spec-1.1.0", + "toml", "version-compare", ] @@ -1641,45 +1501,6 @@ dependencies = [ "xz2", ] -[[package]] -name = "taskers-gtk" -version = "0.3.0" -dependencies = [ - "anyhow", - "clap", - "gtk4", - "libadwaita", - "serde", - "serde_json", - "svgtypes", - "taskers-control", - "taskers-core", - "taskers-domain", - "taskers-ghostty", - "taskers-paths", - "taskers-runtime", - "tempfile", - "time", - "tokio", - "toml 0.8.23", - "webkit6", -] - -[[package]] -name = "taskers-macos-ffi" -version = "0.3.0" -dependencies = [ - "serde", - "serde_json", - "taskers-control", - "taskers-core", - "taskers-domain", - "taskers-ghostty", - "taskers-paths", - "taskers-runtime", - "tempfile", -] - [[package]] name = "taskers-paths" version = "0.3.0" @@ -1816,18 +1637,6 @@ dependencies = [ "syn", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -1836,22 +1645,13 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1870,20 +1670,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - [[package]] name = "toml_edit" version = "0.25.4+spec-1.1.0" @@ -1905,12 +1691,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" @@ -2108,39 +1888,6 @@ dependencies = [ "semver", ] -[[package]] -name = "webkit6" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" -dependencies = [ - "gdk4", - "gio", - "glib", - "gtk4", - "javascriptcore6", - "libc", - "soup3", - "webkit6-sys", -] - -[[package]] -name = "webkit6-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" -dependencies = [ - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "javascriptcore6-sys", - "libc", - "soup3-sys", - "system-deps", -] - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 7e9cdb5..2abf670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,11 @@ [workspace] members = [ - "crates/taskers-app", "crates/taskers-launcher", "crates/taskers-core", "crates/taskers-cli", "crates/taskers-control", "crates/taskers-domain", "crates/taskers-ghostty", - "crates/taskers-macos-ffi", "crates/taskers-paths", "crates/taskers-runtime", ] diff --git a/.github/workflows/macos-preview.yml b/taskers-old/.github/workflows/macos-preview.yml similarity index 100% rename from .github/workflows/macos-preview.yml rename to taskers-old/.github/workflows/macos-preview.yml diff --git a/taskers-old/README.md b/taskers-old/README.md new file mode 100644 index 0000000..0773e77 --- /dev/null +++ b/taskers-old/README.md @@ -0,0 +1,9 @@ +# Taskers Old + +This directory contains the archived pre-greenfield product layers. + +It is kept for reference and selective cherry-picks only. It is not part of the +active root workspace, root CI, or the default contributor workflow. + +The shared crates at the repo root remain canonical. Code under `taskers-old/` +may still reference them instead of carrying a fully duplicated copy. diff --git a/crates/taskers-app/Cargo.toml b/taskers-old/crates/taskers-app/Cargo.toml similarity index 100% rename from crates/taskers-app/Cargo.toml rename to taskers-old/crates/taskers-app/Cargo.toml diff --git a/crates/taskers-app/src/crash_reporter.rs b/taskers-old/crates/taskers-app/src/crash_reporter.rs similarity index 100% rename from crates/taskers-app/src/crash_reporter.rs rename to taskers-old/crates/taskers-app/src/crash_reporter.rs diff --git a/crates/taskers-app/src/main.rs b/taskers-old/crates/taskers-app/src/main.rs similarity index 100% rename from crates/taskers-app/src/main.rs rename to taskers-old/crates/taskers-app/src/main.rs diff --git a/crates/taskers-app/src/settings_store.rs b/taskers-old/crates/taskers-app/src/settings_store.rs similarity index 100% rename from crates/taskers-app/src/settings_store.rs rename to taskers-old/crates/taskers-app/src/settings_store.rs diff --git a/crates/taskers-app/src/terminal_transitions.rs b/taskers-old/crates/taskers-app/src/terminal_transitions.rs similarity index 100% rename from crates/taskers-app/src/terminal_transitions.rs rename to taskers-old/crates/taskers-app/src/terminal_transitions.rs diff --git a/crates/taskers-app/src/theme.rs b/taskers-old/crates/taskers-app/src/theme.rs similarity index 100% rename from crates/taskers-app/src/theme.rs rename to taskers-old/crates/taskers-app/src/theme.rs diff --git a/crates/taskers-app/src/themes.rs b/taskers-old/crates/taskers-app/src/themes.rs similarity index 100% rename from crates/taskers-app/src/themes.rs rename to taskers-old/crates/taskers-app/src/themes.rs diff --git a/crates/taskers-macos-ffi/Cargo.toml b/taskers-old/crates/taskers-macos-ffi/Cargo.toml similarity index 100% rename from crates/taskers-macos-ffi/Cargo.toml rename to taskers-old/crates/taskers-macos-ffi/Cargo.toml diff --git a/crates/taskers-macos-ffi/include/taskers_macos_ffi.h b/taskers-old/crates/taskers-macos-ffi/include/taskers_macos_ffi.h similarity index 100% rename from crates/taskers-macos-ffi/include/taskers_macos_ffi.h rename to taskers-old/crates/taskers-macos-ffi/include/taskers_macos_ffi.h diff --git a/crates/taskers-macos-ffi/src/lib.rs b/taskers-old/crates/taskers-macos-ffi/src/lib.rs similarity index 100% rename from crates/taskers-macos-ffi/src/lib.rs rename to taskers-old/crates/taskers-macos-ffi/src/lib.rs diff --git a/docs/release.md b/taskers-old/docs/release.md similarity index 100% rename from docs/release.md rename to taskers-old/docs/release.md diff --git a/docs/screenshots/demo-attention.png b/taskers-old/docs/screenshots/demo-attention.png similarity index 100% rename from docs/screenshots/demo-attention.png rename to taskers-old/docs/screenshots/demo-attention.png diff --git a/docs/screenshots/demo-layout.png b/taskers-old/docs/screenshots/demo-layout.png similarity index 100% rename from docs/screenshots/demo-layout.png rename to taskers-old/docs/screenshots/demo-layout.png diff --git a/macos/TaskersMac/Info.plist b/taskers-old/macos/TaskersMac/Info.plist similarity index 100% rename from macos/TaskersMac/Info.plist rename to taskers-old/macos/TaskersMac/Info.plist diff --git a/macos/TaskersMac/Sources/TaskersBrowserView.swift b/taskers-old/macos/TaskersMac/Sources/TaskersBrowserView.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersBrowserView.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersBrowserView.swift diff --git a/macos/TaskersMac/Sources/TaskersCoreBridge.swift b/taskers-old/macos/TaskersMac/Sources/TaskersCoreBridge.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersCoreBridge.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersCoreBridge.swift diff --git a/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift b/taskers-old/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersDefaultSurfaceHost.swift diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/taskers-old/macos/TaskersMac/Sources/TaskersEnvironment.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersEnvironment.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersEnvironment.swift diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/taskers-old/macos/TaskersMac/Sources/TaskersGhosttyHost.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersGhosttyHost.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersGhosttyHost.swift diff --git a/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h b/taskers-old/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h similarity index 100% rename from macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h rename to taskers-old/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h diff --git a/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift b/taskers-old/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift diff --git a/macos/TaskersMac/Sources/TaskersSnapshot.swift b/taskers-old/macos/TaskersMac/Sources/TaskersSnapshot.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersSnapshot.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersSnapshot.swift diff --git a/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift b/taskers-old/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersSurfaceHosting.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/taskers-old/macos/TaskersMac/Sources/TaskersTerminalView.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersTerminalView.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersTerminalView.swift diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/taskers-old/macos/TaskersMac/Sources/TaskersWorkspaceController.swift similarity index 100% rename from macos/TaskersMac/Sources/TaskersWorkspaceController.swift rename to taskers-old/macos/TaskersMac/Sources/TaskersWorkspaceController.swift diff --git a/macos/TaskersMac/Sources/main.swift b/taskers-old/macos/TaskersMac/Sources/main.swift similarity index 100% rename from macos/TaskersMac/Sources/main.swift rename to taskers-old/macos/TaskersMac/Sources/main.swift diff --git a/macos/TaskersMacTests/TaskersBrowserViewTests.swift b/taskers-old/macos/TaskersMacTests/TaskersBrowserViewTests.swift similarity index 100% rename from macos/TaskersMacTests/TaskersBrowserViewTests.swift rename to taskers-old/macos/TaskersMacTests/TaskersBrowserViewTests.swift diff --git a/macos/TaskersMacTests/TaskersCoreBridgeTests.swift b/taskers-old/macos/TaskersMacTests/TaskersCoreBridgeTests.swift similarity index 100% rename from macos/TaskersMacTests/TaskersCoreBridgeTests.swift rename to taskers-old/macos/TaskersMacTests/TaskersCoreBridgeTests.swift diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/taskers-old/macos/TaskersMacTests/TaskersSmokeTests.swift similarity index 100% rename from macos/TaskersMacTests/TaskersSmokeTests.swift rename to taskers-old/macos/TaskersMacTests/TaskersSmokeTests.swift diff --git a/macos/TaskersMacTests/TaskersSnapshotTests.swift b/taskers-old/macos/TaskersMacTests/TaskersSnapshotTests.swift similarity index 100% rename from macos/TaskersMacTests/TaskersSnapshotTests.swift rename to taskers-old/macos/TaskersMacTests/TaskersSnapshotTests.swift diff --git a/macos/project.yml b/taskers-old/macos/project.yml similarity index 100% rename from macos/project.yml rename to taskers-old/macos/project.yml diff --git a/scripts/build_macos_dmg.sh b/taskers-old/scripts/build_macos_dmg.sh similarity index 100% rename from scripts/build_macos_dmg.sh rename to taskers-old/scripts/build_macos_dmg.sh diff --git a/scripts/capture_demo_screenshots.sh b/taskers-old/scripts/capture_demo_screenshots.sh similarity index 100% rename from scripts/capture_demo_screenshots.sh rename to taskers-old/scripts/capture_demo_screenshots.sh diff --git a/scripts/generate_macos_project.sh b/taskers-old/scripts/generate_macos_project.sh similarity index 100% rename from scripts/generate_macos_project.sh rename to taskers-old/scripts/generate_macos_project.sh diff --git a/scripts/install_macos_codesign_certificate.sh b/taskers-old/scripts/install_macos_codesign_certificate.sh similarity index 100% rename from scripts/install_macos_codesign_certificate.sh rename to taskers-old/scripts/install_macos_codesign_certificate.sh diff --git a/scripts/macos-build-preview-deps.sh b/taskers-old/scripts/macos-build-preview-deps.sh similarity index 100% rename from scripts/macos-build-preview-deps.sh rename to taskers-old/scripts/macos-build-preview-deps.sh diff --git a/scripts/notarize_macos_dmg.sh b/taskers-old/scripts/notarize_macos_dmg.sh similarity index 100% rename from scripts/notarize_macos_dmg.sh rename to taskers-old/scripts/notarize_macos_dmg.sh diff --git a/scripts/sign_macos_app.sh b/taskers-old/scripts/sign_macos_app.sh similarity index 100% rename from scripts/sign_macos_app.sh rename to taskers-old/scripts/sign_macos_app.sh diff --git a/scripts/smoke_taskers_focus_churn.sh b/taskers-old/scripts/smoke_taskers_focus_churn.sh similarity index 100% rename from scripts/smoke_taskers_focus_churn.sh rename to taskers-old/scripts/smoke_taskers_focus_churn.sh diff --git a/scripts/smoke_taskers_ui.sh b/taskers-old/scripts/smoke_taskers_ui.sh similarity index 100% rename from scripts/smoke_taskers_ui.sh rename to taskers-old/scripts/smoke_taskers_ui.sh diff --git a/scripts/stage_macos_bundle_support.sh b/taskers-old/scripts/stage_macos_bundle_support.sh similarity index 100% rename from scripts/stage_macos_bundle_support.sh rename to taskers-old/scripts/stage_macos_bundle_support.sh From 9daeca652f4f0fb5382295883b78fd546800c8c1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 11:27:00 +0100 Subject: [PATCH 58/63] refactor: promote greenfield workspace into the root workspace --- Cargo.lock | 1321 ++++++- Cargo.toml | 19 +- .../taskers => crates/taskers-app}/Cargo.toml | 10 +- .../taskers-app}/src/main.rs | 4 +- .../crates => crates}/taskers-host/Cargo.toml | 6 +- .../crates => crates}/taskers-host/src/lib.rs | 3 +- .../taskers-shell-core}/Cargo.toml | 8 +- .../taskers-shell-core}/src/lib.rs | 4 +- crates/taskers-shell/Cargo.toml | 14 + .../taskers-shell/src/lib.rs | 1 + .../taskers-shell/src/theme.rs | 1 + greenfield/Cargo.lock | 3465 ----------------- greenfield/Cargo.toml | 37 - greenfield/crates/taskers-shell/Cargo.toml | 10 - .../scripts => scripts}/headless-smoke.sh | 0 .../install-dev-desktop-entry.sh | 0 16 files changed, 1358 insertions(+), 3545 deletions(-) rename {greenfield/crates/taskers => crates/taskers-app}/Cargo.toml (74%) rename {greenfield/crates/taskers => crates/taskers-app}/src/main.rs (99%) rename {greenfield/crates => crates}/taskers-host/Cargo.toml (61%) rename {greenfield/crates => crates}/taskers-host/src/lib.rs (99%) rename {greenfield/crates/taskers-core => crates/taskers-shell-core}/Cargo.toml (60%) rename {greenfield/crates/taskers-core => crates/taskers-shell-core}/src/lib.rs (99%) create mode 100644 crates/taskers-shell/Cargo.toml rename {greenfield/crates => crates}/taskers-shell/src/lib.rs (99%) rename {greenfield/crates => crates}/taskers-shell/src/theme.rs (99%) delete mode 100644 greenfield/Cargo.lock delete mode 100644 greenfield/Cargo.toml delete mode 100644 greenfield/crates/taskers-shell/Cargo.toml rename {greenfield/scripts => scripts}/headless-smoke.sh (100%) rename {greenfield/scripts => scripts}/install-dev-desktop-entry.sh (100%) diff --git a/Cargo.lock b/Cargo.lock index 6ae365d..ccfbce7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,12 +64,84 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -87,6 +159,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -108,6 +183,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cairo-rs" @@ -210,6 +288,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -238,6 +345,46 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -258,6 +405,284 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "dioxus-stores", + "subsecond", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite 0.27.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", +] + +[[package]] +name = "dioxus-liveview" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" +dependencies = [ + "axum", + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "futures-channel", + "futures-util", + "generational-box", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "slab", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -275,6 +700,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -291,6 +737,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -345,6 +801,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -403,6 +865,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -417,6 +885,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -479,6 +948,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -500,6 +979,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -508,7 +999,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -707,22 +1198,103 @@ dependencies = [ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hashbrown" -version = "0.16.1" +name = "hyper" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] [[package]] -name = "heck" -version = "0.5.0" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] [[package]] name = "icu_collections" @@ -811,6 +1383,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -856,6 +1434,29 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "javascriptcore6" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" +dependencies = [ + "glib", + "javascriptcore6-sys", + "libc", +] + +[[package]] +name = "javascriptcore6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -866,6 +1467,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", +] + +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + [[package]] name = "lazy_static" version = "1.5.0" @@ -878,6 +1495,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libadwaita" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libc" version = "0.2.183" @@ -903,7 +1551,7 @@ dependencies = [ "bitflags 2.11.0", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.3", ] [[package]] @@ -918,12 +1566,27 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + [[package]] name = "lzma-sys" version = "0.1.20" @@ -935,12 +1598,36 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -950,6 +1637,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -989,6 +1682,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1025,18 +1727,67 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1085,6 +1836,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1113,6 +1873,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + [[package]] name = "quote" version = "1.0.45" @@ -1122,12 +1894,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_syscall" version = "0.7.3" @@ -1151,6 +1967,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1214,6 +2042,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -1263,6 +2103,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -1272,6 +2134,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial2" version = "0.2.34" @@ -1283,6 +2157,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1314,19 +2199,57 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] [[package]] -name = "simd-adler32" -version = "0.3.8" +name = "sledgehammer_utils" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] [[package]] -name = "slab" -version = "0.4.12" +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] [[package]] name = "smallvec" @@ -1344,6 +2267,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1356,6 +2305,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subsecond" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +dependencies = [ + "serde", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1373,6 +2350,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1501,6 +2484,42 @@ dependencies = [ "xz2", ] +[[package]] +name = "taskers-gtk" +version = "0.3.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "dioxus", + "dioxus-liveview", + "gtk4", + "libadwaita", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-host", + "taskers-paths", + "taskers-runtime", + "taskers-shell", + "taskers-shell-core", + "tokio", + "webkit6", +] + +[[package]] +name = "taskers-host" +version = "0.3.0" +dependencies = [ + "anyhow", + "gtk4", + "taskers-domain", + "taskers-ghostty", + "taskers-shell-core", + "webkit6", +] + [[package]] name = "taskers-paths" version = "0.3.0" @@ -1517,6 +2536,28 @@ dependencies = [ "taskers-paths", ] +[[package]] +name = "taskers-shell" +version = "0.3.0" +dependencies = [ + "dioxus", + "taskers-shell-core", +] + +[[package]] +name = "taskers-shell-core" +version = "0.3.0" +dependencies = [ + "parking_lot", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-runtime", + "time", + "tokio", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1637,6 +2678,43 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -1697,6 +2775,100 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1709,6 +2881,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1749,6 +2927,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1785,6 +2969,28 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1822,6 +3028,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -1888,6 +3108,49 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit6" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "javascriptcore6", + "libc", + "soup3", + "webkit6-sys", +] + +[[package]] +name = "webkit6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "javascriptcore6-sys", + "libc", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2170,6 +3433,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 2abf670..a432f64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,17 @@ [workspace] members = [ + "crates/taskers-app", "crates/taskers-launcher", "crates/taskers-core", "crates/taskers-cli", "crates/taskers-control", "crates/taskers-domain", "crates/taskers-ghostty", + "crates/taskers-host", "crates/taskers-paths", "crates/taskers-runtime", + "crates/taskers-shell", + "crates/taskers-shell-core", ] resolver = "2" @@ -21,25 +25,34 @@ version = "0.3.0" [workspace.dependencies] adw = { package = "libadwaita", version = "0.9.1" } anyhow = "1" +axum = { version = "0.8.4", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } +dioxus = { version = "0.7.3", default-features = false, features = ["hooks", "html", "macro", "signals"] } +dioxus-liveview = { version = "0.7.3", features = ["axum"] } gtk = { package = "gtk4", version = "0.11.0" } indexmap = { version = "2", features = ["serde"] } libc = "0.2" +parking_lot = "0.12" portable-pty = "0.9.0" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -svgtypes = "0.15" tar = "0.4" tempfile = "3" -toml = "0.8" thiserror = "2" time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde", "serde-human-readable"] } -tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } webkit6 = { version = "0.6.1", features = ["v2_50"] } xz2 = "0.1" taskers-core = { version = "0.3.0", path = "crates/taskers-core" } +taskers-control = { version = "0.3.0", path = "crates/taskers-control" } +taskers-domain = { version = "0.3.0", path = "crates/taskers-domain" } +taskers-ghostty = { version = "0.3.0", path = "crates/taskers-ghostty" } +taskers-host = { version = "0.3.0", path = "crates/taskers-host" } taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } +taskers-runtime = { version = "0.3.0", path = "crates/taskers-runtime" } +taskers-shell = { version = "0.3.0", path = "crates/taskers-shell" } +taskers-shell-core = { version = "0.3.0", path = "crates/taskers-shell-core" } diff --git a/greenfield/crates/taskers/Cargo.toml b/crates/taskers-app/Cargo.toml similarity index 74% rename from greenfield/crates/taskers/Cargo.toml rename to crates/taskers-app/Cargo.toml index ade6e4e..152e92f 100644 --- a/greenfield/crates/taskers/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -1,12 +1,16 @@ [package] -name = "taskers" +name = "taskers-gtk" +description = "GTK host for the mainline Taskers workspace shell." edition.workspace = true +homepage.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true version.workspace = true +publish = false [[bin]] -name = "taskers" +name = "taskers-gtk" path = "src/main.rs" [dependencies] @@ -17,7 +21,6 @@ clap.workspace = true dioxus.workspace = true dioxus-liveview.workspace = true gtk.workspace = true -taskers-app-core.workspace = true taskers-control.workspace = true taskers-core.workspace = true taskers-domain.workspace = true @@ -26,5 +29,6 @@ taskers-host.workspace = true taskers-paths.workspace = true taskers-runtime.workspace = true taskers-shell.workspace = true +taskers-shell-core.workspace = true tokio.workspace = true webkit6.workspace = true diff --git a/greenfield/crates/taskers/src/main.rs b/crates/taskers-app/src/main.rs similarity index 99% rename from greenfield/crates/taskers/src/main.rs rename to crates/taskers-app/src/main.rs index 2d1f785..6f26d6f 100644 --- a/greenfield/crates/taskers/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -16,9 +16,9 @@ use std::{ thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use taskers_app_core::{AppState, load_or_bootstrap}; +use taskers_core::{AppState, load_or_bootstrap}; use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; -use taskers_core::{ +use taskers_shell_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, }; diff --git a/greenfield/crates/taskers-host/Cargo.toml b/crates/taskers-host/Cargo.toml similarity index 61% rename from greenfield/crates/taskers-host/Cargo.toml rename to crates/taskers-host/Cargo.toml index f1acbbb..c55c461 100644 --- a/greenfield/crates/taskers-host/Cargo.toml +++ b/crates/taskers-host/Cargo.toml @@ -1,14 +1,18 @@ [package] name = "taskers-host" +description = "GTK-native surface host for the mainline Taskers shell." edition.workspace = true +homepage.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true version.workspace = true +publish = false [dependencies] anyhow.workspace = true gtk.workspace = true -taskers-core.workspace = true taskers-domain.workspace = true taskers-ghostty.workspace = true +taskers-shell-core.workspace = true webkit6.workspace = true diff --git a/greenfield/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs similarity index 99% rename from greenfield/crates/taskers-host/src/lib.rs rename to crates/taskers-host/src/lib.rs index c3e9e98..13e2c8a 100644 --- a/greenfield/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -11,6 +11,7 @@ use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; +use taskers_shell_core as taskers_core; use taskers_core::{ BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellDragMode, ShellSnapshot, SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, @@ -1082,7 +1083,7 @@ mod tests { browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, terminal_plans, workspace_pan_delta, }; - use taskers_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; + use taskers_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; use taskers_domain::PaneKind; #[test] diff --git a/greenfield/crates/taskers-core/Cargo.toml b/crates/taskers-shell-core/Cargo.toml similarity index 60% rename from greenfield/crates/taskers-core/Cargo.toml rename to crates/taskers-shell-core/Cargo.toml index 36b27bc..040230e 100644 --- a/greenfield/crates/taskers-core/Cargo.toml +++ b/crates/taskers-shell-core/Cargo.toml @@ -1,14 +1,18 @@ [package] -name = "taskers-core" +name = "taskers-shell-core" +description = "Shared shell state and layout engine for the mainline Taskers GTK host." edition.workspace = true +homepage.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true version.workspace = true +publish = false [dependencies] parking_lot.workspace = true -taskers-app-core.workspace = true taskers-control.workspace = true +taskers-core.workspace = true taskers-domain.workspace = true taskers-ghostty.workspace = true taskers-runtime.workspace = true diff --git a/greenfield/crates/taskers-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs similarity index 99% rename from greenfield/crates/taskers-core/src/lib.rs rename to crates/taskers-shell-core/src/lib.rs index 09f67ef..b2a99c7 100644 --- a/greenfield/crates/taskers-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -5,7 +5,7 @@ use std::{ path::PathBuf, sync::Arc, }; -use taskers_app_core::{AppState, default_session_path}; +use taskers_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; use taskers_domain::{ ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, KEYBOARD_RESIZE_STEP, @@ -3540,7 +3540,7 @@ fn is_local_browser_target(value: &str) -> bool { #[cfg(test)] mod tests { - use taskers_app_core::AppState; + use taskers_core::AppState; use taskers_control::ControlCommand; use taskers_domain::{ AppModel, AttentionState as DomainAttentionState, NotificationItem, SignalKind, diff --git a/crates/taskers-shell/Cargo.toml b/crates/taskers-shell/Cargo.toml new file mode 100644 index 0000000..9c61910 --- /dev/null +++ b/crates/taskers-shell/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "taskers-shell" +description = "Dioxus UI shell for the mainline Taskers workspace app." +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme = "../../README.md" +repository.workspace = true +version.workspace = true +publish = false + +[dependencies] +dioxus.workspace = true +taskers-shell-core.workspace = true diff --git a/greenfield/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs similarity index 99% rename from greenfield/crates/taskers-shell/src/lib.rs rename to crates/taskers-shell/src/lib.rs index 2583ffa..4e88885 100644 --- a/greenfield/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1,6 +1,7 @@ mod theme; use dioxus::prelude::*; +use taskers_shell_core as taskers_core; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, diff --git a/greenfield/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs similarity index 99% rename from greenfield/crates/taskers-shell/src/theme.rs rename to crates/taskers-shell/src/theme.rs index 7e1c8bd..1b372f2 100644 --- a/greenfield/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1,4 +1,5 @@ use std::fmt::Write as _; +use taskers_shell_core as taskers_core; use taskers_core::LayoutMetrics; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/greenfield/Cargo.lock b/greenfield/Cargo.lock deleted file mode 100644 index de94552..0000000 --- a/greenfield/Cargo.lock +++ /dev/null @@ -1,3465 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" -dependencies = [ - "bitflags 2.11.0", - "cairo-sys-rs", - "glib", - "libc", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "cc" -version = "1.2.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-expr" -version = "0.20.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "convert_case" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dioxus" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" -dependencies = [ - "dioxus-config-macros", - "dioxus-core", - "dioxus-core-macro", - "dioxus-hooks", - "dioxus-html", - "dioxus-signals", - "dioxus-stores", - "subsecond", -] - -[[package]] -name = "dioxus-cli-config" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" - -[[package]] -name = "dioxus-config-macros" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" - -[[package]] -name = "dioxus-core" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" -dependencies = [ - "anyhow", - "const_format", - "dioxus-core-types", - "futures-channel", - "futures-util", - "generational-box", - "longest-increasing-subsequence", - "rustc-hash 2.1.1", - "rustversion", - "serde", - "slab", - "slotmap", - "subsecond", - "tracing", -] - -[[package]] -name = "dioxus-core-macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" -dependencies = [ - "convert_case", - "dioxus-rsx", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dioxus-core-types" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" - -[[package]] -name = "dioxus-devtools" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" -dependencies = [ - "dioxus-cli-config", - "dioxus-core", - "dioxus-devtools-types", - "dioxus-signals", - "serde", - "serde_json", - "subsecond", - "thiserror 2.0.18", - "tracing", - "tungstenite 0.27.0", -] - -[[package]] -name = "dioxus-devtools-types" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" -dependencies = [ - "dioxus-core", - "serde", - "subsecond-types", -] - -[[package]] -name = "dioxus-document" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" -dependencies = [ - "dioxus-core", - "dioxus-core-macro", - "dioxus-core-types", - "dioxus-html", - "futures-channel", - "futures-util", - "generational-box", - "lazy-js-bundle", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "dioxus-history" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" -dependencies = [ - "dioxus-core", - "tracing", -] - -[[package]] -name = "dioxus-hooks" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" -dependencies = [ - "dioxus-core", - "dioxus-signals", - "futures-channel", - "futures-util", - "generational-box", - "rustversion", - "slab", - "tracing", -] - -[[package]] -name = "dioxus-html" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" -dependencies = [ - "async-trait", - "bytes", - "dioxus-core", - "dioxus-core-macro", - "dioxus-core-types", - "dioxus-hooks", - "dioxus-html-internal-macro", - "enumset", - "euclid", - "futures-channel", - "futures-util", - "generational-box", - "keyboard-types", - "lazy-js-bundle", - "rustversion", - "serde", - "serde_json", - "serde_repr", - "tracing", -] - -[[package]] -name = "dioxus-html-internal-macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dioxus-interpreter-js" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" -dependencies = [ - "dioxus-core", - "dioxus-core-types", - "dioxus-html", - "lazy-js-bundle", - "rustc-hash 2.1.1", - "sledgehammer_bindgen", - "sledgehammer_utils", -] - -[[package]] -name = "dioxus-liveview" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" -dependencies = [ - "axum", - "dioxus-cli-config", - "dioxus-core", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-html", - "dioxus-interpreter-js", - "futures-channel", - "futures-util", - "generational-box", - "rustc-hash 2.1.1", - "serde", - "serde_json", - "slab", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", -] - -[[package]] -name = "dioxus-rsx" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "dioxus-signals" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" -dependencies = [ - "dioxus-core", - "futures-channel", - "futures-util", - "generational-box", - "parking_lot", - "rustc-hash 2.1.1", - "tracing", - "warnings", -] - -[[package]] -name = "dioxus-stores" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" -dependencies = [ - "dioxus-core", - "dioxus-signals", - "dioxus-stores-macro", - "generational-box", -] - -[[package]] -name = "dioxus-stores-macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "enumset" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" -dependencies = [ - "enumset_derive", -] - -[[package]] -name = "enumset_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", - "serde", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-macro", - "futures-sink", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk4" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk4-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk4-sys" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "generational-box" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" -dependencies = [ - "parking_lot", - "tracing", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gio" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "pin-project-lite", - "smallvec", -] - -[[package]] -name = "gio-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "windows-sys 0.61.2", -] - -[[package]] -name = "glib" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" -dependencies = [ - "bitflags 2.11.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "smallvec", -] - -[[package]] -name = "glib-macros" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "glib-sys" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "gobject-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "graphene-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" -dependencies = [ - "glib", - "graphene-sys", - "libc", -] - -[[package]] -name = "graphene-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" -dependencies = [ - "glib-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gsk4" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" -dependencies = [ - "cairo-rs", - "gdk4", - "glib", - "graphene-rs", - "gsk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gsk4-sys" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" -dependencies = [ - "cairo-sys-rs", - "gdk4-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk4" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" -dependencies = [ - "cairo-rs", - "field-offset", - "futures-channel", - "gdk-pixbuf", - "gdk4", - "gio", - "glib", - "graphene-rs", - "gsk4", - "gtk4-macros", - "gtk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gtk4-macros" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gtk4-sys" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "gsk4-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "javascriptcore6" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" -dependencies = [ - "glib", - "javascriptcore6-sys", - "libc", -] - -[[package]] -name = "javascriptcore6-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.0", - "serde", -] - -[[package]] -name = "lazy-js-bundle" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libadwaita" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" -dependencies = [ - "gdk4", - "gio", - "glib", - "gtk4", - "libadwaita-sys", - "libc", - "pango", -] - -[[package]] -name = "libadwaita-sys" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" -dependencies = [ - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libredox" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" -dependencies = [ - "bitflags 2.11.0", - "libc", - "plain", - "redox_syscall 0.7.3", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "longest-increasing-subsequence" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix", -] - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "pango" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" -dependencies = [ - "gio", - "glib", - "libc", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serial2" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "sledgehammer_bindgen" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" -dependencies = [ - "sledgehammer_bindgen_macro", -] - -[[package]] -name = "sledgehammer_bindgen_macro" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "sledgehammer_utils" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" -dependencies = [ - "rustc-hash 1.1.0", -] - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "serde", - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subsecond" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" -dependencies = [ - "js-sys", - "libc", - "libloading", - "memfd", - "memmap2", - "serde", - "subsecond-types", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "subsecond-types" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" -dependencies = [ - "serde", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-deps" -version = "7.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml", - "version-compare", -] - -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" - -[[package]] -name = "taskers" -version = "0.1.0-alpha.1" -dependencies = [ - "anyhow", - "axum", - "clap", - "dioxus", - "dioxus-liveview", - "gtk4", - "libadwaita", - "taskers-control", - "taskers-core 0.1.0-alpha.1", - "taskers-core 0.3.0", - "taskers-domain", - "taskers-ghostty", - "taskers-host", - "taskers-paths", - "taskers-runtime", - "taskers-shell", - "tokio", - "webkit6", -] - -[[package]] -name = "taskers-control" -version = "0.3.0" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "taskers-domain", - "taskers-paths", - "thiserror 2.0.18", - "tokio", - "uuid", -] - -[[package]] -name = "taskers-core" -version = "0.1.0-alpha.1" -dependencies = [ - "parking_lot", - "taskers-control", - "taskers-core 0.3.0", - "taskers-domain", - "taskers-ghostty", - "taskers-runtime", - "time", - "tokio", -] - -[[package]] -name = "taskers-core" -version = "0.3.0" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "taskers-control", - "taskers-domain", - "taskers-ghostty", - "taskers-paths", - "taskers-runtime", -] - -[[package]] -name = "taskers-domain" -version = "0.3.0" -dependencies = [ - "indexmap", - "serde", - "serde_json", - "thiserror 2.0.18", - "time", - "uuid", -] - -[[package]] -name = "taskers-ghostty" -version = "0.3.0" -dependencies = [ - "gtk4", - "libloading", - "serde", - "tar", - "taskers-domain", - "taskers-paths", - "taskers-runtime", - "thiserror 2.0.18", - "ureq", - "xz2", -] - -[[package]] -name = "taskers-host" -version = "0.1.0-alpha.1" -dependencies = [ - "anyhow", - "gtk4", - "taskers-core 0.1.0-alpha.1", - "taskers-domain", - "taskers-ghostty", - "webkit6", -] - -[[package]] -name = "taskers-paths" -version = "0.3.0" - -[[package]] -name = "taskers-runtime" -version = "0.3.0" -dependencies = [ - "anyhow", - "base64", - "libc", - "portable-pty", - "taskers-domain", - "taskers-paths", -] - -[[package]] -name = "taskers-shell" -version = "0.1.0-alpha.1" -dependencies = [ - "dioxus", - "taskers-core 0.1.0-alpha.1", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.0.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" -dependencies = [ - "indexmap", - "toml_datetime 1.0.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.0", -] - -[[package]] -name = "toml_parser" -version = "1.0.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" -dependencies = [ - "winnow 1.0.0", -] - -[[package]] -name = "toml_writer" -version = "1.0.7+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tungstenite" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "warnings" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" -dependencies = [ - "pin-project", - "tracing", - "warnings-macro", -] - -[[package]] -name = "warnings-macro" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webkit6" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" -dependencies = [ - "gdk4", - "gio", - "glib", - "gtk4", - "javascriptcore6", - "libc", - "soup3", - "webkit6-sys", -] - -[[package]] -name = "webkit6-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" -dependencies = [ - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "javascriptcore6-sys", - "libc", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/greenfield/Cargo.toml b/greenfield/Cargo.toml deleted file mode 100644 index 757d5de..0000000 --- a/greenfield/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[workspace] -members = [ - "crates/taskers", - "crates/taskers-core", - "crates/taskers-host", - "crates/taskers-shell", -] -resolver = "2" - -[workspace.package] -edition = "2024" -license = "MIT OR Apache-2.0" -repository = "https://github.com/OneNoted/taskers" -version = "0.1.0-alpha.1" - -[workspace.dependencies] -adw = { package = "libadwaita", version = "0.9.1" } -anyhow = "1" -axum = { version = "0.8.4", features = ["ws"] } -clap = { version = "4", features = ["derive"] } -dioxus = { version = "0.7.3", default-features = false, features = ["hooks", "html", "macro", "signals"] } -dioxus-liveview = { version = "0.7.3", features = ["axum"] } -gtk = { package = "gtk4", version = "0.11.0" } -indexmap = "2" -parking_lot = "0.12" -time = { version = "0.3", features = ["formatting", "macros"] } -tokio = { version = "1.50.0", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } -webkit6 = { version = "0.6.1", features = ["v2_50"] } -taskers-app-core = { package = "taskers-core", path = "../crates/taskers-core" } -taskers-control = { path = "../crates/taskers-control" } -taskers-core = { path = "crates/taskers-core" } -taskers-domain = { path = "../crates/taskers-domain" } -taskers-ghostty = { path = "../crates/taskers-ghostty" } -taskers-host = { path = "crates/taskers-host" } -taskers-paths = { path = "../crates/taskers-paths" } -taskers-runtime = { path = "../crates/taskers-runtime" } -taskers-shell = { path = "crates/taskers-shell" } diff --git a/greenfield/crates/taskers-shell/Cargo.toml b/greenfield/crates/taskers-shell/Cargo.toml deleted file mode 100644 index a5b02b8..0000000 --- a/greenfield/crates/taskers-shell/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "taskers-shell" -edition.workspace = true -license.workspace = true -repository.workspace = true -version.workspace = true - -[dependencies] -dioxus.workspace = true -taskers-core.workspace = true diff --git a/greenfield/scripts/headless-smoke.sh b/scripts/headless-smoke.sh similarity index 100% rename from greenfield/scripts/headless-smoke.sh rename to scripts/headless-smoke.sh diff --git a/greenfield/scripts/install-dev-desktop-entry.sh b/scripts/install-dev-desktop-entry.sh similarity index 100% rename from greenfield/scripts/install-dev-desktop-entry.sh rename to scripts/install-dev-desktop-entry.sh From cf6b78e3fba3f522d79ba6564efb4f0f81384e13 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 11:36:31 +0100 Subject: [PATCH 59/63] feat: retarget launcher, scripts, ci, and docs to mainline taskers --- .github/workflows/release-assets.yml | 121 +------ README.md | 44 ++- crates/taskers-app/src/main.rs | 30 +- crates/taskers-launcher/src/lib.rs | 2 +- crates/taskers-shell-core/src/lib.rs | 8 +- docs/release.md | 101 ++++++ greenfield/README.md | 80 ----- scripts/headless-smoke.sh | 8 +- scripts/install-dev-desktop-entry.sh | 6 +- scripts/smoke_linux_release_launcher.sh | 294 ++---------------- .../.github/workflows/release-assets.yml | 227 ++++++++++++++ 11 files changed, 417 insertions(+), 504 deletions(-) create mode 100644 docs/release.md delete mode 100644 greenfield/README.md mode change 100644 => 100755 scripts/smoke_linux_release_launcher.sh create mode 100644 taskers-old/.github/workflows/release-assets.yml diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index f4f0645..c3643ef 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -48,14 +48,23 @@ jobs: echo "${prefix}/bin" >> "$GITHUB_PATH" "${prefix}/bin/blueprint-compiler" --version + - name: Build debug app for smoke + run: cargo build -p taskers-gtk --bin taskers-gtk + + - name: Run headless app smoke + run: | + TASKERS_TERMINAL_BACKEND=mock \ + bash scripts/headless-smoke.sh \ + ./target/debug/taskers-gtk \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 + - name: Build Linux bundle run: bash scripts/build_linux_bundle.sh - - name: Run Linux smoke checks - run: | - bash scripts/smoke_taskers_ui.sh - bash scripts/smoke_taskers_focus_churn.sh - bash scripts/smoke_linux_release_launcher.sh + - name: Run launcher smoke + run: bash scripts/smoke_linux_release_launcher.sh - name: Upload Linux bundle uses: actions/upload-artifact@v4 @@ -63,104 +72,6 @@ jobs: name: linux-bundle path: dist/taskers-linux-bundle-v*.tar.xz - macos-universal-dmg: - runs-on: macos-15 - env: - TASKERS_MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.TASKERS_MACOS_CERTIFICATE_P12_BASE64 }} - TASKERS_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.TASKERS_MACOS_CERTIFICATE_PASSWORD }} - TASKERS_MACOS_CODESIGN_IDENTITY: ${{ secrets.TASKERS_MACOS_CODESIGN_IDENTITY }} - TASKERS_MACOS_NOTARY_APPLE_ID: ${{ secrets.TASKERS_MACOS_NOTARY_APPLE_ID }} - TASKERS_MACOS_NOTARY_TEAM_ID: ${{ secrets.TASKERS_MACOS_NOTARY_TEAM_ID }} - TASKERS_MACOS_NOTARY_PASSWORD: ${{ secrets.TASKERS_MACOS_NOTARY_PASSWORD }} - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Decide whether to build signed macOS release - id: release-gate - run: | - signed_release=true - missing=() - for name in \ - TASKERS_MACOS_CERTIFICATE_P12_BASE64 \ - TASKERS_MACOS_CERTIFICATE_PASSWORD \ - TASKERS_MACOS_CODESIGN_IDENTITY \ - TASKERS_MACOS_NOTARY_APPLE_ID \ - TASKERS_MACOS_NOTARY_TEAM_ID \ - TASKERS_MACOS_NOTARY_PASSWORD; do - if [[ -z "${!name:-}" ]]; then - missing+=("${name}") - signed_release=false - fi - done - - echo "signed_release=${signed_release}" >> "$GITHUB_OUTPUT" - - if [[ "${signed_release}" != "true" ]]; then - echo "Building unsigned macOS DMG; missing signing/notary secrets: ${missing[*]}" - fi - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: | - aarch64-apple-darwin - x86_64-apple-darwin - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@v2 - - - name: Install Zig - uses: goto-bus-stop/setup-zig@v2 - with: - version: 0.15.2 - - - name: Install macOS build tools - run: | - brew update - brew install xcodegen - - - name: Install Developer ID certificate - if: steps.release-gate.outputs.signed_release == 'true' - run: bash scripts/install_macos_codesign_certificate.sh - - - name: Build universal macOS dependencies - run: TASKERS_MACOS_DEP_MODE=universal bash scripts/macos-build-preview-deps.sh - - - name: Generate Xcode project - run: TASKERS_MACOS_DEP_MODE=universal bash scripts/generate_macos_project.sh - - - name: Build universal Taskers.app - run: | - TASKERS_SKIP_MACOS_PREBUILD_DEPS=1 xcodebuild build \ - -project macos/Taskers.xcodeproj \ - -scheme TaskersMac \ - -configuration Release \ - -derivedDataPath build/macos/DerivedData \ - ARCHS="arm64 x86_64" \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO - - - name: Sign universal Taskers.app - run: bash scripts/sign_macos_app.sh - - - name: Build universal DMG - run: bash scripts/build_macos_dmg.sh - - - name: Notarize and staple universal DMG - if: steps.release-gate.outputs.signed_release == 'true' - run: | - version="$(sed -n 's/^version = \"\\(.*\\)\"/\\1/p' Cargo.toml | head -n1)" - bash scripts/notarize_macos_dmg.sh "dist/Taskers-v${version}-universal2.dmg" - - - name: Upload universal DMG - uses: actions/upload-artifact@v4 - with: - name: macos-universal-dmg - path: dist/Taskers-v*-universal2.dmg - release-manifest: needs: - linux-bundle @@ -193,7 +104,6 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') needs: - release-manifest - - macos-universal-dmg runs-on: ubuntu-latest steps: @@ -214,9 +124,6 @@ jobs: echo 'files< /dev/null; then - echo 'dist/release/Taskers-v*-universal2.dmg' - fi echo 'EOF' } >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 4851a5c..44c5dab 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # taskers -Taskers is a cross-platform terminal workspace for agent-heavy work. It gives you Niri-style top-level windows, local pane splits, and an attention sidebar so active, waiting, and completed terminal work stays visible. +Taskers is a Linux-first terminal workspace for agent-heavy work. It provides +Niri-style top-level windows, local pane splits, tabs inside panes, and an +attention rail for active and completed work. -![Taskers workspace list and attention sidebar](docs/screenshots/demo-attention.png) - -![Taskers split workspace window](docs/screenshots/demo-layout.png) +The active product lives at the repo root. Archived pre-cutover GTK/AppKit code +is kept under `taskers-old/` for reference only. ## Try it @@ -12,27 +13,44 @@ Linux (`x86_64-unknown-linux-gnu`): ```bash cargo install taskers --locked -taskers --demo +taskers ``` -The first launch downloads the exact version-matched Linux bundle from the tagged GitHub release. -The Linux app now requires the host WebKitGTK 6.0 runtime in addition to GTK4/libadwaita. - -macOS: +The first launch downloads the exact version-matched Linux bundle from the tagged +GitHub release. The Linux app requires GTK4/libadwaita plus the host WebKitGTK +6.0 runtime. -- Download the signed `Taskers-v-universal2.dmg` from [GitHub Releases](https://github.com/OneNoted/taskers/releases). -- Drag `Taskers.app` into `Applications`, then launch it normally from Finder or Spotlight. +Mainline macOS support is currently not shipped from this repo root. ## Develop On Ubuntu 24.04, install the Linux UI dependencies first: ```bash -sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev +sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev xvfb +``` + +Run the app directly: + +```bash +cargo run -p taskers-gtk --bin taskers-gtk ``` +Point the desktop launcher at the repo-local dev build: + +```bash +bash scripts/install-dev-desktop-entry.sh +``` + +Run the headless baseline smoke: + ```bash -cargo run -p taskers-gtk --bin taskers-gtk -- --demo +TASKERS_TERMINAL_BACKEND=mock \ +bash scripts/headless-smoke.sh \ + ./target/debug/taskers-gtk \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 ``` Release checklist: [docs/release.md](docs/release.md) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 6f26d6f..fa0178e 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -16,7 +16,7 @@ use std::{ thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use taskers_core::{AppState, load_or_bootstrap}; +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; use taskers_shell_core::{ BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, @@ -28,11 +28,11 @@ use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, Tasker use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; -const APP_ID: &str = "dev.onenoted.Taskers.Greenfield"; +const APP_ID: &str = taskers_paths::APP_ID; #[derive(Debug, Clone, Parser)] #[command(name = "taskers")] -#[command(about = "Greenfield Taskers unified shell")] +#[command(about = "Taskers workspace shell")] struct Cli { #[arg(long, value_enum)] smoke_script: Option, @@ -89,7 +89,7 @@ fn main() -> glib::ExitCode { let bootstrap = match bootstrap_runtime(None) { Ok(bootstrap) => bootstrap, Err(error) => { - eprintln!("failed to bootstrap greenfield Taskers host: {error:?}"); + eprintln!("failed to bootstrap Taskers host: {error:?}"); return glib::ExitCode::FAILURE; } }; @@ -126,7 +126,7 @@ fn build_ui( cli: Cli, ) { if let Err(error) = build_ui_result(app, bootstrap, hold_guard, cli) { - eprintln!("failed to launch greenfield Taskers host: {error:?}"); + eprintln!("failed to launch Taskers host: {error:?}"); } } @@ -350,10 +350,10 @@ fn connect_navigation_shortcuts( fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result { let runtime = resolve_runtime_bootstrap(); let mut startup_notes = runtime.startup_notes; - let session_path = greenfield_session_path(); + let session_path = default_session_path(); let initial_model = load_or_bootstrap(&session_path, false).with_context(|| { format!( - "failed to load or bootstrap greenfield session at {}", + "failed to load or bootstrap Taskers session at {}", session_path.display() ) })?; @@ -404,7 +404,7 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result RuntimeBootstrap { } } -fn greenfield_session_path() -> PathBuf { - taskers_paths::TaskersPaths::detect() - .state_dir() - .join("greenfield-session.json") -} - -fn greenfield_probe_session_path(mode: GhosttyProbeMode) -> PathBuf { +fn taskers_probe_session_path(mode: GhosttyProbeMode) -> PathBuf { std::env::temp_dir().join(format!( - "taskers-greenfield-probe-{}-{}.json", + "taskers-probe-{}-{}.json", mode.as_arg(), std::process::id() )) @@ -539,7 +533,7 @@ fn run_internal_surface_probe( let app_state = match AppState::new( AppModel::new("Ghostty Probe"), - greenfield_probe_session_path(mode), + taskers_probe_session_path(mode), BackendChoice::GhosttyEmbedded, shell_launch, ) { @@ -1075,7 +1069,7 @@ impl DiagnosticsWriter { .diagnostic_log .clone() .or_else(|| { - std::env::var("TASKERS_GREENFIELD_DIAGNOSTIC_LOG") + std::env::var("TASKERS_DIAGNOSTIC_LOG") .ok() .filter(|value| !value.is_empty()) }) diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 06e3962..9078e85 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -1,6 +1,6 @@ #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] compile_error!( - "taskers on crates.io currently supports x86_64 Linux only. Download the macOS DMG from https://github.com/OneNoted/taskers/releases if you are on macOS." + "taskers on crates.io currently supports x86_64 Linux only. Mainline macOS support is not shipped from this repo root." ); use std::{ diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index b2a99c7..159abdf 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -2614,7 +2614,7 @@ impl TaskersCore { Some(response) } Err(error) => { - eprintln!("greenfield control dispatch failed: {error}"); + eprintln!("taskers control dispatch failed: {error}"); None } } @@ -3119,7 +3119,7 @@ fn default_preview_app_state() -> AppState { AppState::new( model, - default_session_path_for_preview("greenfield-preview-bootstrap"), + default_session_path_for_preview("taskers-preview-bootstrap"), BackendChoice::Mock, ShellLaunchSpec::fallback(), ) @@ -3607,9 +3607,9 @@ mod tests { app_state: AppState::new( model, default_session_path_for_preview(if cleared { - "greenfield-preview-done-activity" + "taskers-preview-done-activity" } else { - "greenfield-preview-activity" + "taskers-preview-activity" }), BackendChoice::Mock, ShellLaunchSpec::fallback(), diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..ac0f6d2 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,101 @@ +# Release Prep + +Use this checklist before publishing a new `taskers` Linux release. + +## 1. Finalize The Repo State + +- Make sure the release work is recorded clearly in `jj`. +- Describe the current change with `jj desc -m ": "` if needed. +- Split unrelated work into separate changes before publishing. +- Bump the workspace version in `Cargo.toml` and update any internal dependency version pins that still reference the previous release. + +## 2. Run Local Verification + +- On Ubuntu 24.04, install the Linux UI dependencies first: + +```bash +sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev xvfb +``` + +- Run the full active workspace test suite: + +```bash +cargo test +``` + +- Build the mainline app and run the headless smoke: + +```bash +cargo build -p taskers-gtk --bin taskers-gtk +TASKERS_TERMINAL_BACKEND=mock \ + bash scripts/headless-smoke.sh \ + ./target/debug/taskers-gtk \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 +``` + +- Build the Linux bundle and verify the published launcher path: + +```bash +bash scripts/build_linux_bundle.sh +bash scripts/smoke_linux_release_launcher.sh +``` + +The output asset name must match: + +```text +taskers-linux-bundle-v-.tar.xz +``` + +- Build the release manifest from the generated assets: + +```bash +python3 scripts/build_release_manifest.py +``` + +- Dry-run the leaf crates that do not depend on unpublished workspace siblings: + +```bash +cargo publish --dry-run -p taskers-domain +cargo publish --dry-run -p taskers-paths +``` + +- After you bump the workspace to a new unpublished version, `cargo publish --dry-run` for dependent crates will still resolve dependencies from crates.io and fail until the earlier crates are actually published. That failure is expected for: + - `taskers-control` + - `taskers-runtime` + - `taskers-ghostty` + - `taskers-cli` + - `taskers` + +## 3. Publish + +- Push the release tag so GitHub Actions can assemble the assets and attach them to a draft GitHub release. +- Confirm the draft release tagged `v` contains: + - `taskers-manifest-v.json` + - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` +- Publish the GitHub release so the launcher assets are publicly downloadable before publishing the crates. +- Publish the crates to crates.io in dependency order: + +```bash +cargo publish -p taskers-domain +cargo publish -p taskers-paths +cargo publish -p taskers-control +cargo publish -p taskers-runtime +cargo publish -p taskers-ghostty +cargo publish -p taskers-cli +cargo publish -p taskers +``` + +## 4. Post-Publish Check + +- Verify the Linux launcher install: + +```bash +cargo install taskers --locked +taskers +``` + +- Confirm the published Linux launcher downloads the exact version-matched bundle on first launch. +- Confirm `cargo install taskers-cli --bin taskersctl --locked` still works as the standalone helper path. +- Confirm `cargo install taskers --locked` on macOS fails with the Linux-only guidance from the launcher crate. diff --git a/greenfield/README.md b/greenfield/README.md deleted file mode 100644 index 4c0db62..0000000 --- a/greenfield/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Taskers Greenfield Rewrite - -This nested workspace is the bootstrap implementation for the shared Dioxus shell rewrite. - -It is intentionally isolated from the legacy GTK/AppKit workspace at the repo root so the -new architecture can evolve without disturbing the existing product. - -Current scope: - -- shared Rust core for workspace/pane/surface state -- shared Dioxus shell rendered through LiveView inside a GTK4/libadwaita host -- Linux GTK4 portal runtime that mounts browser and Ghostty pane bodies into the shared shell -- legacy Taskers-inspired shell styling instead of the old greenfield prototype chrome -- startup/runtime bootstrap that scrubs inherited terminal env, installs shell integration, and probes Ghostty runtime availability - -Run it from this workspace: - -```bash -cargo run -p taskers -``` - -To keep the desktop launcher pointed at the repo-local greenfield app instead of the installed -`taskers` launcher, run: - -```bash -greenfield/scripts/install-dev-desktop-entry.sh -``` - -This writes `~/.local/bin/taskers-greenfield` and updates -`~/.local/share/applications/dev.taskers.app.desktop` to launch greenfield through `cargo run`, -so desktop launches pick up the latest local code. - -Run the scripted baseline smoke with isolated XDG and runtime paths: - -```bash -TMPDIR="$(mktemp -d)" -XDG_CONFIG_HOME="$TMPDIR/config" \ -XDG_DATA_HOME="$TMPDIR/data" \ -XDG_STATE_HOME="$TMPDIR/state" \ -XDG_CACHE_HOME="$TMPDIR/cache" \ -TASKERS_RUNTIME_DIR="$TMPDIR/runtime" \ -cargo run -p taskers -- --smoke-script baseline --diagnostic-log stderr --quit-after-ms 5000 -``` - -Diagnostics can also be written to a file: - -```bash -cargo run -p taskers -- --smoke-script baseline --diagnostic-log /tmp/taskers-greenfield.log -``` - -For a CI-like launch check that avoids the current interactive Ghostty abort on this machine, use the headless helper: - -```bash -greenfield/scripts/headless-smoke.sh \ - ./greenfield/target/debug/taskers \ - --smoke-script baseline \ - --diagnostic-log stderr \ - --quit-after-ms 5000 -``` - -Baseline comparison checklist: - -- Startup logs runtime capability states instead of silently falling back. -- Initial GTK4 host attach and snapshot sync are recorded. -- Browser pane creation is logged through the GTK4 portal runtime. -- Browser title metadata is observed from the native surface. -- Terminal pane creation is attempted through the Ghostty host path. -- Final smoke output records pane counts, active pane, and exit timing. - -Manual interactive checklist: - -- Launch `cargo run -p taskers`. -- Split one browser pane and one terminal pane. -- Resize the window and confirm the native surfaces stay aligned with the shell chrome. -- Move focus between panes and confirm the active-pane highlight follows. - -Current blocker: - -- On this machine, direct interactive launch can still abort inside the Ghostty bridge during startup before the shared shell becomes usable. This matches the current comparator result and appears to be a Ghostty runtime issue rather than a GTK3/GTK4 host mismatch. -- Headless smoke with `dbus-run-session + xvfb-run + LIBGL_ALWAYS_SOFTWARE=1` is the current reliable validation path until that Ghostty startup abort is fixed. diff --git a/scripts/headless-smoke.sh b/scripts/headless-smoke.sh index 4e797a1..2bea9ae 100755 --- a/scripts/headless-smoke.sh +++ b/scripts/headless-smoke.sh @@ -6,8 +6,10 @@ if [[ $# -lt 1 ]]; then exit 2 fi -timeout "${TIMEOUT_SECONDS:-8}" \ - dbus-run-session -- \ - env LIBGL_ALWAYS_SOFTWARE=1 \ +timeout "${TIMEOUT_SECONDS:-20}" \ + env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + GTK_USE_PORTAL=0 \ + NO_AT_BRIDGE=1 \ xvfb-run -a \ "$@" diff --git a/scripts/install-dev-desktop-entry.sh b/scripts/install-dev-desktop-entry.sh index 36ed6a7..2412d7c 100755 --- a/scripts/install-dev-desktop-entry.sh +++ b/scripts/install-dev-desktop-entry.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}" launcher_home="${HOME}/.local/bin" desktop_entry_path="${xdg_data_home}/applications/dev.taskers.app.desktop" -launcher_path="${launcher_home}/taskers-greenfield" +launcher_path="${launcher_home}/taskers-dev" if [[ -n "${CARGO:-}" ]]; then cargo_bin="${CARGO}" @@ -25,7 +25,7 @@ cat > "${launcher_path}" <&2 - return 1 -} - -choose_http_port() { - python3 - <<'PY' -import socket - -with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - print(sock.getsockname()[1]) -PY -} - -wait_for_path() { - local path=$1 - local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) - - while (( attempts > 0 )); do - if [[ -e "$path" ]]; then - return 0 - fi - sleep "$POLL_INTERVAL_SECONDS" - attempts=$((attempts - 1)) - done - - printf 'timed out waiting for %s\n' "$path" >&2 - return 1 -} - -wait_for_tcp() { - local port=$1 - local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) - - while (( attempts > 0 )); do - if python3 - <<'PY' "$port" -import socket -import sys - -port = int(sys.argv[1]) -with socket.socket() as sock: - sock.settimeout(0.2) - try: - sock.connect(("127.0.0.1", port)) - except OSError: - raise SystemExit(1) -raise SystemExit(0) -PY - then - return 0 - fi - sleep "$POLL_INTERVAL_SECONDS" - attempts=$((attempts - 1)) - done - - printf 'timed out waiting for localhost:%s\n' "$port" >&2 - return 1 -} - -wait_for_browser_state() { - local control_bin=$1 - local socket_path=$2 - local status_path=$3 - local integrity_path=$4 - local expected_surface_id=$5 - local expected_url=$6 - local expected_title=$7 - local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) - - while (( attempts > 0 )); do - "$control_bin" query status --socket "$socket_path" >"$status_path" - if python3 - <<'PY' "$status_path" "$integrity_path" "$expected_surface_id" "$expected_url" "$expected_title" -import json -import sys - -status_path, integrity_path, expected_surface_id, expected_url, expected_title = sys.argv[1:6] -with open(status_path, encoding="utf-8") as handle: - payload = json.load(handle) -with open(integrity_path, encoding="utf-8") as handle: - integrity = json.load(handle) - -model = payload["response"]["Ok"]["session"]["model"] -active_window_id = model["active_window"] -active_workspace_id = model["windows"][active_window_id]["active_workspace"] -workspace = model["workspaces"][active_workspace_id] -pane = workspace["panes"][workspace["active_pane"]] -surface = pane["surfaces"][expected_surface_id] - -ok = ( - pane["active_surface"] == expected_surface_id - and surface["kind"] == "browser" - and surface["metadata"].get("url") == expected_url - and surface["metadata"].get("title") == expected_title - and integrity.get("active_workspace_surface_id") == expected_surface_id - and integrity.get("active_displayed_surface_id") == expected_surface_id - and expected_surface_id in integrity.get("cached_browser_surface_ids", []) - and expected_surface_id in integrity.get("attached_browser_surface_ids", []) - and integrity.get("active_browser_uri") == expected_url -) -raise SystemExit(0 if ok else 1) -PY - then - return 0 - fi - sleep "$POLL_INTERVAL_SECONDS" - attempts=$((attempts - 1)) - done - - printf 'timed out waiting for browser state for surface %s\n' "$expected_surface_id" >&2 - cat "$status_path" >&2 || true - cat "$integrity_path" >&2 || true - return 1 -} - -cleanup() { - local status=$? - if [[ -n "${app_pid:-}" ]]; then - kill "$app_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${http_pid:-}" ]]; then - kill "$http_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${xvfb_pid:-}" ]]; then - kill "$xvfb_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${temp_dir:-}" ]] && [[ -d "$temp_dir" ]]; then - rm -rf "$temp_dir" - fi - exit "$status" -} - -trap cleanup EXIT - -if ! command -v Xvfb >/dev/null 2>&1; then - printf '%s\n' 'Xvfb is required for the launcher smoke test.' >&2 - exit 1 -fi - temp_dir=$(mktemp -d -t taskers-release-launcher.XXXXXX) -display_number=$(choose_display_number) -display=":${display_number}" -socket_path="$temp_dir/taskers.sock" -session_path="$temp_dir/session.json" install_root="$temp_dir/install" -xdg_data_home="$temp_dir/data" -xdg_bin_home="$temp_dir/bin" -integrity_path="$temp_dir/ui-integrity.json" -status_path="$temp_dir/status.json" -browser_split_path="$temp_dir/browser-split.json" -site_dir="$temp_dir/site" -http_port=$(choose_http_port) -browser_url="http://127.0.0.1:${http_port}/index.html" +manifest_path="$temp_dir/taskers-manifest.json" version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" target="$(rustc -vV | sed -n 's/^host: //p')" -manifest_path="$temp_dir/taskers-manifest-v${version}.json" -bundle_taskersctl="$install_root/$version/$target/bin/taskersctl" -mkdir -p "$site_dir" -cat >"$site_dir/index.html" <<'HTML' - - - - - Taskers Browser Smoke - - -
browser smoke page
- - -HTML +cleanup() { + rm -rf "$temp_dir" +} +trap cleanup EXIT ( cd "$REPO_ROOT" - cargo build -p taskers --bin taskers + cargo build -p taskers --bin taskers >/dev/null python3 scripts/build_release_manifest.py \ --dist-dir "$REPO_ROOT/dist" \ --base-url "$REPO_ROOT/dist" \ - --output "$manifest_path" -) >/dev/null - -python3 -m http.server "$http_port" --bind 127.0.0.1 --directory "$site_dir" \ - >"$temp_dir/http.log" 2>&1 & -http_pid=$! -wait_for_tcp "$http_port" - -Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & -xvfb_pid=$! -wait_for_path "/tmp/.X11-unix/X${display_number}" - -( - cd "$REPO_ROOT" - export DISPLAY="$display" - export GDK_BACKEND=x11 - export GSK_RENDERER=cairo - export LIBGL_ALWAYS_SOFTWARE=1 - export TASKERS_INSTALL_ROOT="$install_root" - export TASKERS_NON_UNIQUE=1 - export TASKERS_RELEASE_MANIFEST_URL="$manifest_path" - export TASKERS_SKIP_DESKTOP_INTEGRATION=1 - export TASKERS_TERMINAL_BACKEND=mock - export XDG_BIN_HOME="$xdg_bin_home" - export XDG_DATA_HOME="$xdg_data_home" - export TASKERS_UI_INTEGRITY_PATH="$integrity_path" - exec "$TARGET_DIR/taskers" \ - --demo \ - --socket "$socket_path" \ - --session "$session_path" -) >"$temp_dir/app.log" 2>&1 & -app_pid=$! - -wait_for_path "$socket_path" -wait_for_path "$session_path" -wait_for_path "$integrity_path" - -if [[ ! -x "$bundle_taskersctl" ]]; then - printf 'expected bundled taskersctl at %s\n' "$bundle_taskersctl" >&2 - exit 1 -fi - -sleep 5 -kill -0 "$app_pid" - -"$bundle_taskersctl" query status --socket "$socket_path" >"$status_path" -readarray -t ids < <( - python3 - <<'PY' "$status_path" -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - payload = json.load(handle) - -model = payload["response"]["Ok"]["session"]["model"] -active_window_id = model["active_window"] -active_workspace_id = model["windows"][active_window_id]["active_workspace"] -workspace = model["workspaces"][active_workspace_id] - -print(active_workspace_id) -print(workspace["active_pane"]) -PY + --output "$manifest_path" >/dev/null ) -workspace_id=${ids[0]} -pane_id=${ids[1]} - -"$bundle_taskersctl" pane split \ - --socket "$socket_path" \ - --workspace "$workspace_id" \ - --pane "$pane_id" \ - --axis horizontal \ - --kind browser \ - --url "$browser_url" >"$browser_split_path" - -browser_surface_id=$( - python3 - <<'PY' "$browser_split_path" -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - payload = json.load(handle) - -print(payload["surface_id"]) -PY -) - -wait_for_browser_state \ - "$bundle_taskersctl" \ - "$socket_path" \ - "$status_path" \ - "$integrity_path" \ - "$browser_surface_id" \ - "$browser_url" \ - "Taskers Browser Smoke" - -kill -0 "$app_pid" - -printf '%s\n' 'Taskers launcher smoke passed: release bundle installed and loaded an embedded browser split.' +TASKERS_INSTALL_ROOT="$install_root" \ +TASKERS_RELEASE_MANIFEST_URL="$manifest_path" \ +TASKERS_SKIP_DESKTOP_INTEGRATION=1 \ +TASKERS_TERMINAL_BACKEND=mock \ + bash "$REPO_ROOT/scripts/headless-smoke.sh" \ + "$TARGET_DIR/taskers" \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 + +test -x "$install_root/$version/$target/bin/taskers" +test -x "$install_root/$version/$target/bin/taskersctl" diff --git a/taskers-old/.github/workflows/release-assets.yml b/taskers-old/.github/workflows/release-assets.yml new file mode 100644 index 0000000..f4f0645 --- /dev/null +++ b/taskers-old/.github/workflows/release-assets.yml @@ -0,0 +1,227 @@ +name: Release Assets + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux-bundle: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.2 + + - name: Install Linux bundle tools + run: | + sudo apt-get update + sudo apt-get install -y \ + meson \ + ninja-build \ + python3-gi \ + xvfb \ + libgtk-4-dev \ + libadwaita-1-dev \ + libjavascriptcoregtk-6.0-dev \ + libwebkitgtk-6.0-dev + + - name: Install pinned blueprint-compiler + run: | + prefix="${RUNNER_TEMP}/taskers-blueprint-compiler" + bash scripts/install_blueprint_compiler.sh "$prefix" + echo "${prefix}/bin" >> "$GITHUB_PATH" + "${prefix}/bin/blueprint-compiler" --version + + - name: Build Linux bundle + run: bash scripts/build_linux_bundle.sh + + - name: Run Linux smoke checks + run: | + bash scripts/smoke_taskers_ui.sh + bash scripts/smoke_taskers_focus_churn.sh + bash scripts/smoke_linux_release_launcher.sh + + - name: Upload Linux bundle + uses: actions/upload-artifact@v4 + with: + name: linux-bundle + path: dist/taskers-linux-bundle-v*.tar.xz + + macos-universal-dmg: + runs-on: macos-15 + env: + TASKERS_MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.TASKERS_MACOS_CERTIFICATE_P12_BASE64 }} + TASKERS_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.TASKERS_MACOS_CERTIFICATE_PASSWORD }} + TASKERS_MACOS_CODESIGN_IDENTITY: ${{ secrets.TASKERS_MACOS_CODESIGN_IDENTITY }} + TASKERS_MACOS_NOTARY_APPLE_ID: ${{ secrets.TASKERS_MACOS_NOTARY_APPLE_ID }} + TASKERS_MACOS_NOTARY_TEAM_ID: ${{ secrets.TASKERS_MACOS_NOTARY_TEAM_ID }} + TASKERS_MACOS_NOTARY_PASSWORD: ${{ secrets.TASKERS_MACOS_NOTARY_PASSWORD }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Decide whether to build signed macOS release + id: release-gate + run: | + signed_release=true + missing=() + for name in \ + TASKERS_MACOS_CERTIFICATE_P12_BASE64 \ + TASKERS_MACOS_CERTIFICATE_PASSWORD \ + TASKERS_MACOS_CODESIGN_IDENTITY \ + TASKERS_MACOS_NOTARY_APPLE_ID \ + TASKERS_MACOS_NOTARY_TEAM_ID \ + TASKERS_MACOS_NOTARY_PASSWORD; do + if [[ -z "${!name:-}" ]]; then + missing+=("${name}") + signed_release=false + fi + done + + echo "signed_release=${signed_release}" >> "$GITHUB_OUTPUT" + + if [[ "${signed_release}" != "true" ]]; then + echo "Building unsigned macOS DMG; missing signing/notary secrets: ${missing[*]}" + fi + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: | + aarch64-apple-darwin + x86_64-apple-darwin + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.2 + + - name: Install macOS build tools + run: | + brew update + brew install xcodegen + + - name: Install Developer ID certificate + if: steps.release-gate.outputs.signed_release == 'true' + run: bash scripts/install_macos_codesign_certificate.sh + + - name: Build universal macOS dependencies + run: TASKERS_MACOS_DEP_MODE=universal bash scripts/macos-build-preview-deps.sh + + - name: Generate Xcode project + run: TASKERS_MACOS_DEP_MODE=universal bash scripts/generate_macos_project.sh + + - name: Build universal Taskers.app + run: | + TASKERS_SKIP_MACOS_PREBUILD_DEPS=1 xcodebuild build \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Release \ + -derivedDataPath build/macos/DerivedData \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + - name: Sign universal Taskers.app + run: bash scripts/sign_macos_app.sh + + - name: Build universal DMG + run: bash scripts/build_macos_dmg.sh + + - name: Notarize and staple universal DMG + if: steps.release-gate.outputs.signed_release == 'true' + run: | + version="$(sed -n 's/^version = \"\\(.*\\)\"/\\1/p' Cargo.toml | head -n1)" + bash scripts/notarize_macos_dmg.sh "dist/Taskers-v${version}-universal2.dmg" + + - name: Upload universal DMG + uses: actions/upload-artifact@v4 + with: + name: macos-universal-dmg + path: dist/Taskers-v*-universal2.dmg + + release-manifest: + needs: + - linux-bundle + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download built assets + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten downloaded artifacts + run: | + mkdir -p dist/release + find dist -type f -exec cp {} dist/release/ \; + + - name: Build release manifest + run: python3 scripts/build_release_manifest.py --dist-dir dist/release + + - name: Upload release manifest + uses: actions/upload-artifact@v4 + with: + name: release-manifest + path: dist/release/taskers-manifest-v*.json + + upload-github-release: + if: startsWith(github.ref, 'refs/tags/v') + needs: + - release-manifest + - macos-universal-dmg + runs-on: ubuntu-latest + + steps: + - name: Download built assets + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten downloaded artifacts + run: | + mkdir -p dist/release + find dist -type f -exec cp {} dist/release/ \; + + - name: Prepare release asset list + id: release-files + run: | + { + echo 'files< /dev/null; then + echo 'dist/release/Taskers-v*-universal2.dmg' + fi + echo 'EOF' + } >> "$GITHUB_OUTPUT" + + - name: Create draft GitHub release with assets + uses: softprops/action-gh-release@v2 + with: + draft: true + files: ${{ steps.release-files.outputs.files }} From db03934468802606becec07dd5ec6f47d723ee55 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 11:56:43 +0100 Subject: [PATCH 60/63] fix: restore embedded browser interactivity --- crates/taskers-host/src/lib.rs | 34 ++++++++++++++++++++++++++++----- crates/taskers-shell/src/lib.rs | 32 ++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index 13e2c8a..66f73d7 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -142,7 +142,7 @@ impl TaskersHost { (pan_sink)(HostEvent::ViewportScrolled { dx, dy }); glib::Propagation::Proceed }); - root.add_controller(workspace_pan); + shell_widget.add_controller(workspace_pan); emit_diagnostic( diagnostics.as_ref(), @@ -407,7 +407,7 @@ impl BrowserSurface { surface_id: plan.surface_id, url: url.clone(), }); - let shell = NativeSurfaceShell::new(shell_class); + let shell = NativeSurfaceShell::new(shell_class, interactive); shell.mount_child(webview.upcast_ref()); shell.position(fixed, plan.frame); let devtools_open = Rc::new(Cell::new(false)); @@ -432,6 +432,24 @@ impl BrowserSurface { }); webview.add_controller(focus); + let click_sink = event_sink.clone(); + let click_diagnostics = diagnostics.clone(); + let click = GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + emit_diagnostic( + click_diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + None, + "browser click focus event received", + ) + .with_pane(pane_id) + .with_surface(surface_id), + ); + (click_sink)(HostEvent::PaneFocused { pane_id }); + }); + webview.add_controller(click); + let surface_id = plan.surface_id; let title_sink = event_sink.clone(); let title_diagnostics = diagnostics.clone(); @@ -562,6 +580,7 @@ impl BrowserSurface { diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { self.shell.position(fixed, plan.frame); + self.shell.set_interactive(interactive); self.webview.set_can_target(interactive); let BrowserMountSpec { url } = browser_spec(plan)?; @@ -680,7 +699,7 @@ impl TerminalSurface { widget.add_css_class(widget_class); widget.add_css_class("terminal-output"); widget.set_can_target(interactive); - let shell = NativeSurfaceShell::new(shell_class); + let shell = NativeSurfaceShell::new(shell_class, interactive); shell.mount_child(&widget); shell.position(fixed, plan.frame); @@ -720,6 +739,7 @@ impl TerminalSurface { diagnostics: Option<&DiagnosticsSink>, ) { self.widget.set_can_target(interactive); + self.shell.set_interactive(interactive); self.shell.position(fixed, frame); if active && interactive && (!self.active || !self.interactive) { let _ = host.focus_surface(&self.widget); @@ -743,7 +763,7 @@ struct NativeSurfaceShell { } impl NativeSurfaceShell { - fn new(kind_class: &'static str) -> Self { + fn new(kind_class: &'static str, interactive: bool) -> Self { let root = GtkBox::new(Orientation::Vertical, 0); root.set_hexpand(true); root.set_vexpand(true); @@ -751,7 +771,7 @@ impl NativeSurfaceShell { root.set_valign(Align::Fill); root.set_overflow(Overflow::Hidden); root.set_focusable(false); - root.set_can_target(false); + root.set_can_target(interactive); root.add_css_class("native-surface-host"); root.add_css_class(kind_class); Self { root } @@ -771,6 +791,10 @@ impl NativeSurfaceShell { position_widget(fixed, self.root.upcast_ref(), frame); } + fn set_interactive(&self, interactive: bool) { + self.root.set_can_target(interactive); + } + fn detach(&self, fixed: &Fixed) { detach_from_fixed(fixed, self.root.upcast_ref()); } diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 4e88885..ffa55b1 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -90,6 +90,10 @@ fn pane_allows_surface_split( dragged.is_some_and(|dragged| dragged.pane_id != pane_id || surface_count > 1) } +fn show_live_surface_backdrop(surface_kind: SurfaceKind, overview_mode: bool) -> bool { + overview_mode || !matches!(surface_kind, SurfaceKind::Browser) +} + fn apply_surface_drop( core: &SharedCore, dragged: DraggedSurface, @@ -267,6 +271,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, {render_workspace_strip( &snapshot.current_workspace, + snapshot.overview_mode, snapshot.browser_chrome.as_ref(), core.clone(), &snapshot.runtime_status, @@ -552,6 +557,7 @@ fn render_workspace_pull_requests(pull_requests: &[PullRequestSnapshot]) -> Elem fn render_layout( node: &LayoutNodeSnapshot, + overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, @@ -577,16 +583,17 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} + {render_layout(first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} + {render_layout(second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} } } } } LayoutNodeSnapshot::Pane(pane) => render_pane( pane, + overview_mode, browser_chrome, core, runtime_status, @@ -598,6 +605,7 @@ fn render_layout( fn render_workspace_strip( workspace: &WorkspaceViewSnapshot, + overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, @@ -643,6 +651,7 @@ fn render_workspace_strip( {render_workspace_window( window, workspace, + overview_mode, browser_chrome, core.clone(), runtime_status, @@ -661,6 +670,7 @@ fn render_workspace_strip( fn render_workspace_window( window: &WorkspaceWindowSnapshot, workspace: &WorkspaceViewSnapshot, + overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, @@ -762,6 +772,7 @@ fn render_workspace_window( div { class: "workspace-window-body", {render_layout( &window.layout, + overview_mode, browser_chrome, core.clone(), runtime_status, @@ -830,6 +841,7 @@ fn render_window_drop_zone( fn render_pane( pane: &PaneSnapshot, + overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, @@ -1005,7 +1017,9 @@ fn render_pane( } } div { class: "pane-body", - {render_surface_backdrop(active_surface, runtime_status)} + if show_live_surface_backdrop(active_surface.kind, overview_mode) { + {render_surface_backdrop(active_surface, runtime_status)} + } if surface_drag_active { div { class: "pane-drop-overlay", {render_surface_pane_drop_target( @@ -1481,6 +1495,18 @@ fn render_notification_row( } } +#[cfg(test)] +mod tests { + use super::{SurfaceKind, show_live_surface_backdrop}; + + #[test] + fn live_browser_panes_skip_decorative_backdrop_outside_overview() { + assert!(!show_live_surface_backdrop(SurfaceKind::Browser, false)); + assert!(show_live_surface_backdrop(SurfaceKind::Browser, true)); + assert!(show_live_surface_backdrop(SurfaceKind::Terminal, false)); + } +} + fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { rsx! { div { class: "settings-grid", From 707a401059efa083c9af11d0ca000292688f2f1f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 11:58:58 +0100 Subject: [PATCH 61/63] fix: replace blank browser startup with real home page --- crates/taskers-shell-core/src/lib.rs | 32 ++++++++++++++++++++++------ crates/taskers-shell/src/lib.rs | 7 ++++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 159abdf..7f4977d 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -790,6 +790,8 @@ pub struct BrowserChromeSnapshot { pub devtools_open: bool, } +pub const DEFAULT_BROWSER_HOME: &str = "https://duckduckgo.com/"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ShellDragMode { #[default] @@ -1532,7 +1534,8 @@ impl TaskersCore { pane_id: pane.id, surface_id: surface.id, title: display_surface_title(surface), - url: normalized_surface_url(surface).unwrap_or_else(|| "about:blank".into()), + url: normalized_surface_url(surface) + .unwrap_or_else(|| DEFAULT_BROWSER_HOME.into()), can_go_back: self .browser_navigation .get(&surface.id) @@ -3465,7 +3468,7 @@ fn mount_spec_from_descriptor( .url .as_deref() .map(resolved_browser_uri) - .unwrap_or_else(|| "about:blank".into()), + .unwrap_or_else(|| DEFAULT_BROWSER_HOME.into()), }), PaneKind::Terminal => SurfaceMountSpec::Terminal(TerminalMountSpec { title: descriptor @@ -3550,9 +3553,9 @@ mod tests { use time::OffsetDateTime; use super::{ - BootstrapModel, BrowserMountSpec, Direction, HostCommand, HostEvent, LayoutMetrics, - RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, - SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, + BootstrapModel, BrowserMountSpec, DEFAULT_BROWSER_HOME, Direction, HostCommand, HostEvent, + LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, + ShellSection, SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, default_session_path_for_preview, pane_body_frame, resolved_browser_uri, split_frame, workspace_window_content_frame, }; @@ -3758,7 +3761,7 @@ mod tests { matches!( &plan.mount, SurfaceMountSpec::Browser(BrowserMountSpec { url }) - if url.starts_with("http") + if url == DEFAULT_BROWSER_HOME ) })); } @@ -3817,7 +3820,7 @@ mod tests { let snapshot = core.snapshot(); let browser = snapshot.browser_chrome.expect("active browser chrome"); - assert!(!browser.url.trim().is_empty()); + assert_eq!(browser.url, DEFAULT_BROWSER_HOME); core.dispatch_shell_action(ShellAction::NavigateBrowser { surface_id: browser.surface_id, @@ -3874,6 +3877,21 @@ mod tests { assert!(browser.devtools_open); } + #[test] + fn explicit_about_blank_browser_urls_are_preserved() { + let core = SharedCore::bootstrap(bootstrap()); + core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); + + let browser = core.snapshot().browser_chrome.expect("active browser chrome"); + core.apply_host_event(HostEvent::SurfaceUrlChanged { + surface_id: browser.surface_id, + url: "about:blank".into(), + }); + + let browser = core.snapshot().browser_chrome.expect("active browser chrome"); + assert_eq!(browser.url, "about:blank"); + } + #[test] fn local_shell_state_revisions_advance_without_app_mutation() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index ffa55b1..3264e3b 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1270,7 +1270,7 @@ fn BrowserToolbar( .as_ref() .map(|chrome| chrome.url.clone()) .or_else(|| surface.url.clone()) - .unwrap_or_else(|| "about:blank".into()); + .unwrap_or_else(|| taskers_core::DEFAULT_BROWSER_HOME.into()); let mut address = use_signal(|| initial_url.clone()); let surface_id = surface.id; let can_go_back = chrome @@ -1358,7 +1358,10 @@ fn BrowserToolbar( fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { match surface.kind { SurfaceKind::Browser => { - let url = surface.url.clone().unwrap_or_else(|| "about:blank".into()); + let url = surface + .url + .clone() + .unwrap_or_else(|| taskers_core::DEFAULT_BROWSER_HOME.into()); rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", From 447b6a9fddf47bb2128a91e71678b16f95811fb0 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 12:00:41 +0100 Subject: [PATCH 62/63] docs: note GTK browser wrapper input gotcha From 2a231f29f32b23243047303c8be3d23c361e8c02 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 12:05:57 +0100 Subject: [PATCH 63/63] fix: mount native panes as direct overlays --- crates/taskers-host/src/lib.rs | 65 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index 66f73d7..f8d1a9c 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow, bail}; use gtk::{ Align, Box as GtkBox, CssProvider, EventControllerFocus, EventControllerScroll, - EventControllerScrollFlags, Fixed, GestureClick, Orientation, Overflow, Overlay, + EventControllerScrollFlags, GestureClick, Orientation, Overflow, Overlay, STYLE_PROVIDER_PRIORITY_APPLICATION, Widget, glib, prelude::*, }; use std::{ @@ -93,7 +93,6 @@ impl DiagnosticRecord { pub struct TaskersHost { root: Overlay, - surface_layer: Fixed, event_sink: HostEventSink, diagnostics: Option, ghostty_host: Option, @@ -114,15 +113,6 @@ impl TaskersHost { root.set_child(Some(shell_widget)); install_native_surface_css(); - let surface_layer = Fixed::new(); - surface_layer.set_hexpand(true); - surface_layer.set_vexpand(true); - // The surface layer spans the full window, but only mounted native pane - // bodies should intercept pointer events. Leaving the layer targetable - // blocks the shared shell webview underneath. - surface_layer.set_can_target(false); - root.add_overlay(&surface_layer); - let pan_sink = event_sink.clone(); let pan_diagnostics = diagnostics.clone(); let workspace_pan = EventControllerScroll::new(EventControllerScrollFlags::BOTH_AXES); @@ -155,7 +145,6 @@ impl TaskersHost { Self { root, - surface_layer, event_sink, diagnostics, ghostty_host, @@ -258,7 +247,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.browser_surfaces.remove(&surface_id) { - surface.shell.detach(&self.surface_layer); + surface.shell.detach(&self.root); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -274,7 +263,7 @@ impl TaskersHost { for plan in desired { match self.browser_surfaces.get_mut(&plan.surface_id) { Some(surface) => surface.sync( - &self.surface_layer, + &self.root, &plan, revision, interactive, @@ -282,7 +271,7 @@ impl TaskersHost { )?, None => { let surface = BrowserSurface::new( - &self.surface_layer, + &self.root, &plan, revision, interactive, @@ -318,7 +307,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.terminal_surfaces.remove(&surface_id) { - surface.shell.detach(&self.surface_layer); + surface.shell.detach(&self.root); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -338,7 +327,7 @@ impl TaskersHost { for plan in desired { match self.terminal_surfaces.get_mut(&plan.surface_id) { Some(surface) => surface.sync( - &self.surface_layer, + &self.root, plan.frame, plan.active, revision, @@ -348,7 +337,7 @@ impl TaskersHost { ), None => { let surface = TerminalSurface::new( - &self.surface_layer, + &self.root, &plan, revision, interactive, @@ -379,7 +368,7 @@ struct BrowserSurface { impl BrowserSurface { fn new( - fixed: &Fixed, + overlay: &Overlay, plan: &PortalSurfacePlan, revision: u64, interactive: bool, @@ -409,7 +398,7 @@ impl BrowserSurface { }); let shell = NativeSurfaceShell::new(shell_class, interactive); shell.mount_child(webview.upcast_ref()); - shell.position(fixed, plan.frame); + shell.position(overlay, plan.frame); let devtools_open = Rc::new(Cell::new(false)); let pane_id = plan.pane_id; @@ -573,13 +562,13 @@ impl BrowserSurface { fn sync( &mut self, - fixed: &Fixed, + overlay: &Overlay, plan: &PortalSurfacePlan, revision: u64, interactive: bool, diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { - self.shell.position(fixed, plan.frame); + self.shell.position(overlay, plan.frame); self.shell.set_interactive(interactive); self.webview.set_can_target(interactive); @@ -676,7 +665,7 @@ struct TerminalSurface { impl TerminalSurface { fn new( - fixed: &Fixed, + overlay: &Overlay, plan: &PortalSurfacePlan, revision: u64, interactive: bool, @@ -701,7 +690,7 @@ impl TerminalSurface { widget.set_can_target(interactive); let shell = NativeSurfaceShell::new(shell_class, interactive); shell.mount_child(&widget); - shell.position(fixed, plan.frame); + shell.position(overlay, plan.frame); connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); @@ -730,7 +719,7 @@ impl TerminalSurface { fn sync( &mut self, - fixed: &Fixed, + overlay: &Overlay, frame: taskers_core::Frame, active: bool, revision: u64, @@ -740,7 +729,7 @@ impl TerminalSurface { ) { self.widget.set_can_target(interactive); self.shell.set_interactive(interactive); - self.shell.position(fixed, frame); + self.shell.position(overlay, frame); if active && interactive && (!self.active || !self.interactive) { let _ = host.focus_surface(&self.widget); } @@ -787,16 +776,16 @@ impl NativeSurfaceShell { } } - fn position(&self, fixed: &Fixed, frame: taskers_core::Frame) { - position_widget(fixed, self.root.upcast_ref(), frame); + fn position(&self, overlay: &Overlay, frame: taskers_core::Frame) { + position_widget(overlay, self.root.upcast_ref(), frame); } fn set_interactive(&self, interactive: bool) { self.root.set_can_target(interactive); } - fn detach(&self, fixed: &Fixed) { - detach_from_fixed(fixed, self.root.upcast_ref()); + fn detach(&self, overlay: &Overlay) { + detach_from_overlay(overlay, self.root.upcast_ref()); } } @@ -1013,18 +1002,24 @@ fn terminal_spec(plan: &PortalSurfacePlan) -> Result<&TerminalMountSpec> { } } -fn position_widget(fixed: &Fixed, widget: &Widget, frame: taskers_core::Frame) { +fn position_widget(overlay: &Overlay, widget: &Widget, frame: taskers_core::Frame) { widget.set_size_request(frame.width.max(1), frame.height.max(1)); + widget.set_halign(Align::Start); + widget.set_valign(Align::Start); + widget.set_margin_start(frame.x.max(0)); + widget.set_margin_top(frame.y.max(0)); if widget.parent().is_some() { - fixed.move_(widget, f64::from(frame.x), f64::from(frame.y)); + widget.queue_allocate(); } else { - fixed.put(widget, f64::from(frame.x), f64::from(frame.y)); + overlay.add_overlay(widget); + overlay.set_measure_overlay(widget, false); + overlay.set_clip_overlay(widget, true); } } -fn detach_from_fixed(fixed: &Fixed, widget: &Widget) { +fn detach_from_overlay(overlay: &Overlay, widget: &Widget) { if widget.parent().is_some() { - fixed.remove(widget); + overlay.remove_overlay(widget); } }