diff --git a/src-tauri/src/linux_shortcut_manager.rs b/src-tauri/src/linux_shortcut_manager.rs index 9164321..e724174 100644 --- a/src-tauri/src/linux_shortcut_manager.rs +++ b/src-tauri/src/linux_shortcut_manager.rs @@ -860,12 +860,89 @@ impl ShortcutHandler for MateHandler { } } -// --- COSMIC --- +// --- COSMIC (Epoch 1.0+) --- + +// Indentation constants for COSMIC RON format +const COSMIC_ENTRY_INDENT: &str = " "; +const COSMIC_FIELD_INDENT: &str = " "; +const COSMIC_MODIFIER_INDENT: &str = " "; struct CosmicHandler; +impl CosmicHandler { + /// Escape special characters for RON string format + fn escape_ron_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") + } + + /// Format modifiers for COSMIC RON format - each on its own line + /// Input: "Super" or "Ctrl, Alt" -> properly formatted RON array entries + fn format_modifiers(mods: &str) -> String { + let formatted: Vec = mods + .split(',') + .map(|m| m.trim()) + .filter(|m| !m.is_empty()) + .map(|m| { + // Normalize modifier names to COSMIC's expected format + let normalized: String = match m.to_lowercase().as_str() { + "ctrl" | "control" => "Ctrl".to_string(), + "alt" => "Alt".to_string(), + "super" | "meta" => "Super".to_string(), + "shift" => "Shift".to_string(), + _ => { + // Fallback: normalize capitalization (First letter uppercase + rest lowercase) + let mut chars = m.chars(); + match chars.next() { + Some(first) => { + let mut result = first.to_uppercase().to_string(); + result.push_str(&chars.as_str().to_lowercase()); + result + } + None => String::new(), + } + } + }; + format!("{}{},", COSMIC_MODIFIER_INDENT, normalized) + }) + .collect(); + formatted.join("\n") + } + + /// Build a COSMIC shortcut entry in proper RON format + fn build_entry(s: &ShortcutConfig) -> String { + let mods_formatted = Self::format_modifiers(s.cosmic_mods); + let full_cmd = Self::escape_ron_string(&s.full_command()); + let name = Self::escape_ron_string(s.name); + let key = Self::escape_ron_string(s.cosmic_key); + + format!( + r#"{}( +{}modifiers: [ +{} +{}], +{}key: "{}", +{}description: Some("{}"), +{}): Spawn("{}"),"#, + COSMIC_ENTRY_INDENT, + COSMIC_FIELD_INDENT, + mods_formatted, + COSMIC_FIELD_INDENT, + COSMIC_FIELD_INDENT, + key, + COSMIC_FIELD_INDENT, + name, + COSMIC_ENTRY_INDENT, + full_cmd + ) + } +} + impl ShortcutHandler for CosmicHandler { fn name(&self) -> &str { - "COSMIC" + "COSMIC (Epoch)" } fn register(&self, s: &ShortcutConfig) -> Result<()> { @@ -875,40 +952,118 @@ impl ShortcutHandler for CosmicHandler { .join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom"); let full_cmd = s.full_command(); - // Naive but safer append - let entry = format!( - "(modifiers: [{}], key: \"{}\"): Spawn(\"{}\"),", - s.cosmic_mods, s.cosmic_key, full_cmd - ); + let entry = Self::build_entry(s); Utils::modify_file_atomic(&path, |content| { - if content.contains(&entry) { + // Check if this command is already registered to avoid duplicates + if content.contains(&format!("Spawn(\"{}\")", full_cmd)) { return Ok(None); } - let mut new_content = content.clone(); - if new_content.trim().is_empty() { - new_content = format!("(shortcuts: {{\n {}\n}})", entry); - } else { - // Find closing brace of 'shortcuts: { ... }' - match new_content.rfind('}') { - Some(pos) => { - new_content.insert_str(pos, &format!("\n {}\n", entry)); - } - None => { - return Err(ShortcutError::ParseError( - "Invalid COSMIC config format".into(), - )) - } - } + let trimmed = content.trim(); + + // If file is empty or doesn't start with '{', create new structure + if trimmed.is_empty() { + return Ok(Some(format!("{{\n{}\n}}", entry))); } - Ok(Some(new_content)) + + // File should be a RON map: { ... } + if !trimmed.starts_with('{') { + // Reject unexpected formats instead of trying to wrap potentially malformed content + return Err(ShortcutError::ParseError( + "Invalid COSMIC config format - expected RON map starting with '{'".into(), + )); + } + + // Find the last '}' and insert before it + if let Some(pos) = content.rfind('}') { + let mut new_content = content.to_string(); + new_content.insert_str(pos, &format!("{}\n", entry)); + return Ok(Some(new_content)); + } + + Err(ShortcutError::ParseError( + "Invalid COSMIC config format - missing closing brace".into(), + )) })?; Ok(()) } - fn unregister(&self, _s: &ShortcutConfig) -> Result<()> { - // Requires real RON parser + fn unregister(&self, s: &ShortcutConfig) -> Result<()> { + let home = env::var("HOME").unwrap_or_default(); + let path = PathBuf::from(home) + .join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom"); + + if !path.exists() { + return Ok(()); + } + + let full_cmd = s.full_command(); + let spawn_pattern = format!("Spawn(\"{}\")", full_cmd); + + Utils::modify_file_atomic(&path, |content| { + if !content.contains(&spawn_pattern) { + return Ok(None); + } + + // Parse and remove the entry block containing our command + // RON format: (key_tuple): Value, - we track depth to find entry boundaries + // depth starts at 0 before the opening '{'; depth 1 = inside outer map {}, depth 2+ = inside an entry + let mut result = String::new(); + let mut depth = 0; + let mut in_entry = false; + let mut entry_start = 0; + let mut prev_depth: i32; + + for c in content.chars() { + prev_depth = depth; + + // Update depth first + if c == '{' || c == '(' { + depth += 1; + } else if c == '}' || c == ')' { + depth -= 1; + } + + // Detect entry start: '(' that takes us from depth 1 to depth 2 + if c == '(' && prev_depth == 1 && depth == 2 { + entry_start = result.len(); + in_entry = true; + } + + result.push(c); + + // Detect entry end: ',' when we're at depth 1 (after the Spawn(...) closed) + if in_entry && depth == 1 && c == ',' { + // Check if this entry contains our command + let entry_content = &result[entry_start..]; + if entry_content.contains(&spawn_pattern) { + // Remove this entry (including leading whitespace) + let trim_start = result[..entry_start].trim_end().len(); + result.truncate(trim_start); + result.push('\n'); + } + in_entry = false; + } + } + + // Clean up sequences of more than two consecutive newlines in a single pass + let mut cleaned = String::with_capacity(result.len()); + let mut newline_count = 0; + for ch in result.chars() { + if ch == '\n' { + if newline_count < 2 { + cleaned.push('\n'); + } + newline_count += 1; + } else { + newline_count = 0; + cleaned.push(ch); + } + } + + Ok(Some(cleaned)) + })?; Ok(()) } }