mirror of
https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux
synced 2026-04-26 01:25:11 +02:00
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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user