feat: implement COSMIC shortcut handling with RON format support (#97)

* feat: implement COSMIC shortcut handling with RON format support

* chore: ajusts from copilot review

* feat: add RON string escaping

* chore: enhance COSMIC shortcut normalization
This commit is contained in:
Gustavo Carvalho
2026-01-03 17:20:02 -03:00
committed by GitHub
parent d50e207606
commit 72cb57c4e3

View File

@@ -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<String> = 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(())
}
}