diff --git a/Cargo.lock b/Cargo.lock index e07a6616..2f639887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1417,7 +1417,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1438,7 +1438,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "quote", "syn 2.0.109", @@ -1447,7 +1447,7 @@ dependencies = [ [[package]] name = "cosmic-files" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-files.git#e779fc3dac1dd1de2b5c8aa54c6bd5f872169635" +source = "git+https://github.com/pop-os/cosmic-files.git#d9b6404f1b68e8cef5d879c98ddf96c9e7207196" dependencies = [ "anyhow", "chrono", @@ -1541,6 +1541,7 @@ dependencies = [ "clap_lex", "cosmic-files", "cosmic-text", + "dirs 5.0.1", "fork", "hex_color", "i18n-embed", @@ -1549,6 +1550,7 @@ dependencies = [ "indexmap", "libcosmic", "log", + "nix 0.29.0", "open", "palette", "paste", @@ -1566,7 +1568,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.15.0" -source = "git+https://github.com/pop-os/cosmic-text.git#7051682e70defcab6b683d6e9db07124a6de0df7" +source = "git+https://github.com/pop-os/cosmic-text.git#8cd21a315a7eee77fdbfb00e516fb1fe2bfbd4ab" dependencies = [ "bitflags 2.10.0", "fontdb 0.23.0", @@ -1589,7 +1591,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "almost", "cosmic-config", @@ -2992,7 +2994,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "dnd", "iced_accessibility", @@ -3010,7 +3012,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "accesskit", "accesskit_winit", @@ -3019,7 +3021,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3043,7 +3045,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "futures", "iced_core", @@ -3069,7 +3071,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -3091,7 +3093,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3103,7 +3105,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -3118,7 +3120,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "bytemuck", "cosmic-text", @@ -3134,7 +3136,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -3165,7 +3167,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3184,7 +3186,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -4199,7 +4201,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#3b8ad45950f5d23c8550e18e628f6e70b7089d89" +source = "git+https://github.com/pop-os/libcosmic.git#dd3610b8ae4f1bcf2e2299e82f908913d1a4a57d" dependencies = [ "apply", "ashpd 0.12.0", @@ -4663,6 +4665,18 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + [[package]] name = "nix" version = "0.30.1" diff --git a/Cargo.toml b/Cargo.toml index 4ba2dc03..1b514ecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,16 +8,18 @@ rust-version = "1.85" [dependencies] alacritty_terminal = "0.25.1" +dirs = "5.0" hex_color = { version = "3", features = ["serde"] } indexmap = "2" log = "0.4" +nix = { version = "0.29", features = ["signal", "process", "fs"] } open = "5.3.2" palette = { version = "0.7", features = ["serde"] } paste = "1.0" ron = "0.11" serde = { version = "1", features = ["serde_derive"] } shlex = "1" -tokio = { version = "1", features = ["sync"] } +tokio = { version = "1", features = ["sync", "signal"] } # CLI arguments clap_lex = "0.7" # Internationalization diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 88ed8383..24695fbe 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -62,6 +62,11 @@ advanced = Advanced show-headerbar = Show header show-header-description = Reveal the header from the right-click menu. +# Drop-down +dropdown = Drop-down +dropdown-height = Drop-down height +dropdown-height-description = Height of the drop-down terminal in pixels + # Find find-placeholder = Find... find-previous = Find previous diff --git a/src/config.rs b/src/config.rs index 99639946..94a71cdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -236,6 +236,7 @@ pub struct Config { pub syntax_theme_light: String, pub focus_follow_mouse: bool, pub default_profile: Option, + pub dropdown_height: u32, } impl Default for Config { @@ -259,6 +260,7 @@ impl Default for Config { syntax_theme_light: COSMIC_THEME_LIGHT.to_string(), use_bright_bold: false, default_profile: None, + dropdown_height: 400, } } } diff --git a/src/main.rs b/src/main.rs index 33b717b7..06711233 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,10 @@ use cosmic::{ futures::SinkExt, keyboard::{Event as KeyEvent, Key, Modifiers}, mouse::{Button as MouseButton, Event as MouseEvent}, + platform_specific::shell::commands::layer_surface::{ + destroy_layer_surface, get_layer_surface, Anchor, KeyboardInteractivity, + }, + platform_specific::runtime::wayland::layer_surface::SctkLayerSurfaceSettings, stream, window, }, style, @@ -72,7 +76,11 @@ mod terminal_theme; mod dnd; +mod pid_file; +use pid_file::{PidFile, PidFileError}; + use clap_lex::RawArgs; +use nix::sys::signal::{self, Signal}; static ICON_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(IconCache::new())); @@ -90,6 +98,7 @@ fn main() -> Result<(), Box> { let mut shell_program_opt = None; let mut shell_args = Vec::new(); let mut daemonize = true; + let mut drop_down = false; // Parse the arguments using clap_lex while let Some(arg) = raw_args.next_os(&mut cursor) { match arg.to_str() { @@ -107,6 +116,9 @@ fn main() -> Result<(), Box> { Some("--no-daemon") => { daemonize = false; } + Some("--drop-down") => { + drop_down = true; + } Some("-e") | Some("--command") | Some("--") => { // Handle the '--command' or '-e' flag break; @@ -142,6 +154,37 @@ fn main() -> Result<(), Box> { localize::localize(); + // Drop-down mode single-instance handling + let _pid_file = if drop_down { + // Try to acquire the lock file + match PidFile::new() { + Ok(pid_file) => { + // We got the lock - we are the first instance + Some(pid_file) + } + Err(PidFileError::Locked(Some(pid))) => { + // Another instance is running, signal it to toggle and exit + log::info!( + "Found existing drop-down instance (PID {}), sending toggle signal and exiting", + pid + ); + let _ = signal::kill(pid, Signal::SIGUSR1); + return Ok(()); + } + Err(PidFileError::Locked(None)) => { + // Lock is held but couldn't read PID + log::warn!("Could not read PID from lock file, exiting"); + return Ok(()); + } + Err(PidFileError::Io(e)) => { + log::error!("Failed to access lock file: {}", e); + return Err(e.into()); + } + } + } else { + None + }; + let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { Ok(config_handler) => { let config = match Config::get_entry(&config_handler) { @@ -182,6 +225,10 @@ fn main() -> Result<(), Box> { let mut settings = Settings::default(); settings = settings.theme(config.app_theme.theme()); settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0)); + + if drop_down { + settings = settings.no_main_window(true); + } // Flags let flags = Flags { @@ -189,6 +236,7 @@ fn main() -> Result<(), Box> { config, startup_options, term_config, + drop_down, }; // Run the cosmic app @@ -215,6 +263,7 @@ pub struct Flags { config: Config, startup_options: Option, term_config: term::Config, + drop_down: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -354,6 +403,7 @@ pub enum Message { DefaultZoomStep(usize), DialogMessage(DialogMessage), Drop(Option<(pane_grid::Pane, segmented_button::Entity, DndDrop)>), + DropdownHeight(u32), Find(bool), FindNext, FindPrevious, @@ -407,6 +457,7 @@ pub enum Message { TabPrev, TermEvent(pane_grid::Pane, segmented_button::Entity, TermEvent), TermEventTx(mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>), + ToggleDropDown, ToggleFullscreen, ToggleContextPage(ContextPage), UpdateDefaultProfile((bool, ProfileId)), @@ -420,6 +471,12 @@ pub enum Message { ZoomReset, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalMode { + Normal, + DropDown { window_id: Option }, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { About, @@ -472,6 +529,7 @@ pub struct App { profile_expanded: Option, show_advanced_font_settings: bool, modifiers: Modifiers, + mode: TerminalMode, #[cfg(feature = "password_manager")] password_mgr: password_manager::PasswordManager, } @@ -484,6 +542,17 @@ impl App { } } + fn create_dropdown_layer_surface(id: window::Id, height: u32) -> Task { + get_layer_surface(SctkLayerSurfaceSettings { + id, + keyboard_interactivity: KeyboardInteractivity::OnDemand, + anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, + namespace: "cosmic-term-dropdown".into(), + size: Some((None, Some(height))), + ..Default::default() + }) + } + fn update_color_schemes(&mut self) { self.themes = terminal_theme::terminal_themes(); for &color_scheme_kind in &[ColorSchemeKind::Dark, ColorSchemeKind::Light] { @@ -1246,6 +1315,24 @@ impl App { .toggler(self.config.focus_follow_mouse, Message::FocusFollowMouse), ); + let dropdown_section = widget::settings::section().title(fl!("dropdown")) + .add( + widget::settings::item::builder(fl!("dropdown-height")) + .description(fl!("dropdown-height-description")) + .control(widget::slider( + 200..=1200, + self.config.dropdown_height, + |value| Message::DropdownHeight(value), + )), + ) + .add( + widget::text(format!("{}px", self.config.dropdown_height)) + .size(14) + .class(cosmic::style::Text::Color(cosmic::iced_core::Color::from( + [0.5, 0.5, 0.5], + ))), + ); + let advanced_section = widget::settings::section().title(fl!("advanced")).add( widget::settings::item::builder(fl!("show-headerbar")) .description(fl!("show-header-description")) @@ -1256,6 +1343,7 @@ impl App { appearance_section.into(), font_section.into(), splits_section.into(), + dropdown_section.into(), advanced_section.into(), ]) .into() @@ -1550,6 +1638,15 @@ impl Application for App { ), ]); + // Determine terminal mode + let mode = if flags.drop_down { + TerminalMode::DropDown { + window_id: Some(window::Id::unique()), + } + } else { + TerminalMode::Normal + }; + let mut app = Self { core, about, @@ -1592,12 +1689,18 @@ impl Application for App { modifiers: Modifiers::empty(), #[cfg(feature = "password_manager")] password_mgr: Default::default(), + mode, }; app.set_curr_font_weights_and_stretches(); - let command = Task::batch([app.update_config(), app.update_title(None)]); + + let mut commands = vec![app.update_config(), app.update_title(None)]; + // Create layer surface command if in drop-down mode + if let TerminalMode::DropDown { window_id: Some(id) } = app.mode { + commands.push(Self::create_dropdown_layer_surface(id, app.config.dropdown_height)); + } - (app, command) + (app, Task::batch(commands)) } //TODO: currently the first escape unfocuses, and the second calls this function @@ -1922,6 +2025,35 @@ impl Application for App { return cosmic::command::toggle_maximize(window_id); } } + Message::ToggleDropDown => { + match &mut self.mode { + TerminalMode::Normal => { + // Not in drop-down mode, do nothing + return Task::none(); + } + TerminalMode::DropDown { window_id } => { + if let Some(id) = window_id.take() { + // Hide the layer surface + return destroy_layer_surface(id); + } else { + // Show the layer surface + let id = window::Id::unique(); + *window_id = Some(id); + return Self::create_dropdown_layer_surface(id, self.config.dropdown_height); + } + } + } + } + Message::DropdownHeight(height) => { + config_set!(dropdown_height, height); + // If dropdown is currently visible, recreate it with new height + if let TerminalMode::DropDown { window_id: Some(id) } = self.mode { + return Task::batch([ + destroy_layer_surface(id), + Self::create_dropdown_layer_surface(id, height), + ]); + } + } Message::CopyPrimary(entity_opt) => { if let Some(tab_model) = self.pane_model.active() { let entity = entity_opt.unwrap_or_else(|| tab_model.active()); @@ -2424,6 +2556,9 @@ impl Application for App { //Last pane, closing window if let Some(window_id) = self.core.main_window_id() { return window::close(window_id); + } else if matches!(self.mode, TerminalMode::DropDown { .. }) { + // In drop-down mode with no_main_window, exit directly + std::process::exit(0); } } } @@ -2801,6 +2936,15 @@ impl Application for App { } fn view_window(&self, window_id: window::Id) -> Element<'_, Message> { + // Handle layer surface window for drop-down mode + match self.mode { + TerminalMode::DropDown { window_id: Some(id) } if window_id == id => { + return self.view(); + } + _ => {} + } + + // Handle dialog windows match &self.dialog_opt { Some(dialog) => dialog.view(window_id), None => widget::text("Unknown window ID").into(), @@ -2972,8 +3116,9 @@ impl Application for App { fn subscription(&self) -> Subscription { struct ConfigSubscription; struct TerminalEventSubscription; + struct DropDownSignalSubscription; - Subscription::batch([ + let mut subscriptions = vec![ event::listen_with(|event, _status, _window_id| match event { Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => { Some(Message::Key(modifiers, key)) @@ -3021,6 +3166,31 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => Subscription::none(), }, - ]) + ]; + + // Only subscribe to SIGUSR1 signal in drop-down mode + if matches!(self.mode, TerminalMode::DropDown { .. }) { + subscriptions.push(Subscription::run_with_id( + TypeId::of::(), + stream::channel(1, |mut output| async move { + let mut signal_stream = match tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::user_defined1() + ) { + Ok(stream) => stream, + Err(e) => { + log::error!("Failed to create signal stream: {}", e); + return; + } + }; + + loop { + signal_stream.recv().await; + let _ = output.send(Message::ToggleDropDown).await; + } + }), + )); + } + + Subscription::batch(subscriptions) } } diff --git a/src/pid_file.rs b/src/pid_file.rs new file mode 100644 index 00000000..ddac387d --- /dev/null +++ b/src/pid_file.rs @@ -0,0 +1,64 @@ +use nix::fcntl::Flock; +use nix::unistd::Pid; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; + +pub struct PidFile { + _flock: Flock, +} + +#[derive(Debug)] +pub enum PidFileError { + Locked(Option), + Io(io::Error), +} + +impl PidFile { + /// Create a new PidFile by acquiring the lock file + /// Returns Ok(PidFile) if this is the first instance + /// Returns Err(PidFileError::Locked) if another instance is running + /// Returns Err(PidFileError::Io) if there's an IO error + pub fn new() -> Result { + let path = dirs::runtime_dir() + .unwrap_or_else(std::env::temp_dir) + .join("cosmic-term-dropdown.pid"); + + // Open or create the file + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(PidFileError::Io)?; + + // Try to acquire an exclusive lock (non-blocking) + match Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) { + Ok(mut flock) => { + // We got the lock! Write our PID so other instances can signal us + let pid = std::process::id(); + flock.set_len(0).map_err(PidFileError::Io)?; + write!(flock, "{}", pid).map_err(PidFileError::Io)?; + flock.flush().map_err(PidFileError::Io)?; + log::info!("Acquired lock file at {:?} with PID {}", path, pid); + + Ok(Self { _flock: flock }) + } + Err((mut file, err)) => { + // Lock is held by another process - read its PID so we can signal it + log::info!("Lock file is held by another process: {}", err); + + let mut content = String::new(); + file.read_to_string(&mut content).map_err(PidFileError::Io)?; + + let existing_pid = content + .trim() + .parse::() + .ok() + .map(Pid::from_raw); + + Err(PidFileError::Locked(existing_pid)) + } + } + } +}