commit 264e6490a0c40eefa7f50f266c361f05215f4e4a Author: rootiest Date: Mon Aug 3 16:52:16 2026 -0400 feat: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2345db7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ + +# ──────────────── Added by agents-init ────────────────── +# agents-init --agents +AGENTS/ +/AGENTS.md +/CLAUDE.md +# ──────────────────────────────────────────────────────── + +# ──────────────── Added by agents-init ────────────────── +# agents-init --plugins +docs/superpowers +docs/plans +docs/specs +docs/devlogs +# ──────────────────────────────────────────────────────── diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..0df73e5 Binary files /dev/null and b/__pycache__/config.cpython-314.pyc differ diff --git a/__pycache__/gui.cpython-314.pyc b/__pycache__/gui.cpython-314.pyc new file mode 100644 index 0000000..dca7e7f Binary files /dev/null and b/__pycache__/gui.cpython-314.pyc differ diff --git a/__pycache__/svg_parser.cpython-314.pyc b/__pycache__/svg_parser.cpython-314.pyc new file mode 100644 index 0000000..0b8ed1a Binary files /dev/null and b/__pycache__/svg_parser.cpython-314.pyc differ diff --git a/breeze-icons b/breeze-icons new file mode 160000 index 0000000..f8d5285 --- /dev/null +++ b/breeze-icons @@ -0,0 +1 @@ +Subproject commit f8d528544fae6b014033fb9ff5a9d2b888cb45b4 diff --git a/config.py b/config.py new file mode 100644 index 0000000..7234da6 --- /dev/null +++ b/config.py @@ -0,0 +1,45 @@ +import os +import sys +import subprocess +from pathlib import Path +import tomllib + +def get_config_dir(): + xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) + return Path(xdg_config) / 'svg-color-tool' + +def generate_default_config(config_path: Path): + default_path = os.path.expanduser("~/.local/share/breeze-icons") + config_content = f"""[settings] +breeze_path = "{default_path}" +""" + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w") as f: + f.write(config_content) + +def ensure_breeze_repo(breeze_path: str): + path = Path(breeze_path) + if not path.exists() or not any(path.iterdir()): + print(f"Breeze icons not found at {path}. Cloning repository...") + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "clone", "--depth", "1", "https://github.com/KDE/breeze-icons.git", str(path)], check=True) + +def load_config(): + env_path = os.environ.get('BREEZE_ICONS_PATH') + if env_path: + ensure_breeze_repo(env_path) + return env_path + + config_dir = get_config_dir() + config_file = config_dir / 'config.toml' + + if not config_file.exists(): + generate_default_config(config_file) + + with open(config_file, "rb") as f: + config_data = tomllib.load(f) + + breeze_path = config_data.get('settings', {}).get('breeze_path', os.path.expanduser("~/.local/share/breeze-icons")) + breeze_path = os.path.expanduser(breeze_path) + ensure_breeze_repo(breeze_path) + return breeze_path diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..2d79818 --- /dev/null +++ b/gui.py @@ -0,0 +1,141 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QColorDialog, QScrollArea, QFrame, QSplitter, QMainWindow, + QMessageBox +) +from PyQt6.QtGui import QColor, QAction, QKeySequence +from PyQt6.QtSvgWidgets import QSvgWidget +from PyQt6.QtCore import Qt + +from svg_parser import SvgColorParser + +class ColorSwatch(QFrame): + def __init__(self, tag, color_hex, on_click): + super().__init__() + self.tag = tag + self.color_hex = color_hex + self.on_click = on_click + + self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) + self.setLineWidth(1) + + layout = QHBoxLayout() + self.setLayout(layout) + + self.lbl_tag = QLabel(tag) + self.lbl_color = QLabel(color_hex) + + self.swatch = QLabel() + self.swatch.setFixedSize(30, 30) + self.swatch.setAutoFillBackground(True) + self.update_swatch_color(color_hex) + + layout.addWidget(self.swatch) + layout.addWidget(self.lbl_tag) + layout.addWidget(self.lbl_color) + layout.addStretch() + + def update_swatch_color(self, hex_color): + self.color_hex = hex_color + self.lbl_color.setText(hex_color) + self.swatch.setStyleSheet(f"background-color: {hex_color}; border: 1px solid black;") + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.on_click(self.tag, self.color_hex, self) + +class MainWindow(QMainWindow): + def __init__(self, svg_path): + super().__init__() + self.setWindowTitle(f"SVG Color Editor - {svg_path}") + self.resize(800, 600) + + self.parser = SvgColorParser(svg_path) + self.svg_path = svg_path + + self.setup_ui() + self.setup_menu() + + if not self.parser.colors: + QMessageBox.warning(self, "No Colors Found", "No ColorScheme tags were found in the SVG file.") + + def setup_menu(self): + menubar = self.menuBar() + edit_menu = menubar.addMenu("Edit") + + self.undo_action = QAction("Undo", self) + self.undo_action.setShortcut(QKeySequence.StandardKey.Undo) + self.undo_action.triggered.connect(self.undo) + edit_menu.addAction(self.undo_action) + + self.redo_action = QAction("Redo", self) + self.redo_action.setShortcut(QKeySequence.StandardKey.Redo) + self.redo_action.triggered.connect(self.redo) + edit_menu.addAction(self.redo_action) + + def setup_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + + splitter = QSplitter(Qt.Orientation.Horizontal) + main_layout.addWidget(splitter) + + # Left Panel - Colors + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + + self.colors_widget = QWidget() + self.colors_layout = QVBoxLayout(self.colors_widget) + scroll_area.setWidget(self.colors_widget) + + splitter.addWidget(scroll_area) + + # Right Panel - SVG Preview + preview_container = QWidget() + preview_layout = QVBoxLayout(preview_container) + + self.svg_widget = QSvgWidget(str(self.svg_path)) + self.svg_widget.renderer().setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + preview_layout.addWidget(self.svg_widget) + + splitter.addWidget(preview_container) + splitter.setSizes([350, 450]) + + self.populate_colors() + + def populate_colors(self): + # Clear layout + while self.colors_layout.count(): + item = self.colors_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + for tag, color in self.parser.colors.items(): + swatch = ColorSwatch(tag, color, self.on_swatch_click) + self.colors_layout.addWidget(swatch) + + self.colors_layout.addStretch() + + def on_swatch_click(self, tag, current_color, swatch_widget): + color = QColorDialog.getColor(QColor(current_color), self, f"Select Color for {tag}") + if color.isValid(): + new_hex = color.name() # Returns #RRGGBB + self.parser.update_color(tag, new_hex) + swatch_widget.update_swatch_color(new_hex) + self.refresh_preview() + self.populate_colors() + + def refresh_preview(self): + self.svg_widget.load(self.parser.content.encode('utf-8')) + + def undo(self): + if self.parser.undo(): + self.refresh_preview() + self.populate_colors() + + def redo(self): + if self.parser.redo(): + self.refresh_preview() + self.populate_colors() diff --git a/icon-color-tool b/icon-color-tool new file mode 100755 index 0000000..603b4df --- /dev/null +++ b/icon-color-tool @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python3 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..f57a70d --- /dev/null +++ b/main.py @@ -0,0 +1,89 @@ +import sys +import os +import subprocess +from pathlib import Path +from config import load_config +from gui import MainWindow +from PyQt6.QtWidgets import QApplication + + +def get_icon_with_fzf(breeze_path): + try: + preview_script = r''' + FILE="$1" + DIM="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" + if command -v kitten >/dev/null; then + kitten icat --clear --transfer-mode=memory --stdin=no --scale-up --place="${DIM}@${FZF_PREVIEW_LEFT:-0}x${FZF_PREVIEW_TOP:-0}" "$FILE" > /dev/tty + elif command -v wezterm >/dev/null; then + wezterm imgcat "$FILE" + elif command -v chafa >/dev/null; then + chafa -s "$DIM" "$FILE" + else + cat "$FILE" + fi + ''' + env = os.environ.copy() + env['FZF_PREVIEW_SCRIPT'] = preview_script + + find_proc = subprocess.Popen( + ["find", ".", "-type", "f", "-name", "*.svg"], + cwd=breeze_path, + stdout=subprocess.PIPE, + ) + fzf_proc = subprocess.Popen( + ["fzf", "--preview", "bash -c \"$FZF_PREVIEW_SCRIPT\" _ {}"], + stdin=find_proc.stdout, + stdout=subprocess.PIPE, + text=True, + cwd=breeze_path, + env=env + ) + find_proc.stdout.close() + out, _ = fzf_proc.communicate() + if fzf_proc.returncode == 0 and out.strip(): + return (Path(breeze_path) / out.strip()).resolve() + return None + except Exception as e: + print(f"Error running fzf: {e}") + return None + + +def main(): + breeze_path = load_config() + + icon_path = None + if len(sys.argv) > 1: + arg_path = Path(sys.argv[1]) + if arg_path.is_absolute(): + icon_path = arg_path + else: + # First try relative to current directory + if arg_path.exists(): + icon_path = arg_path.resolve() + else: + # Then try relative to breeze_path + icon_path = (Path(breeze_path) / arg_path).resolve() + + if not icon_path.exists(): + print(f"Error: Icon not found at {icon_path}") + sys.exit(1) + else: + print("Select an icon using fzf...") + icon_path = get_icon_with_fzf(breeze_path) + if not icon_path: + print("No icon selected.") + sys.exit(0) + + app = QApplication(sys.argv) + window = MainWindow(icon_path) + window.show() + exit_code = app.exec() + + if window.parser.history: + print(f"\nIcon colors updated: {icon_path.resolve()}") + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee397a4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyQt6 diff --git a/svg_parser.py b/svg_parser.py new file mode 100644 index 0000000..ca3af6a --- /dev/null +++ b/svg_parser.py @@ -0,0 +1,56 @@ +import re +from pathlib import Path + +class SvgColorParser: + def __init__(self, filepath): + self.filepath = Path(filepath) + self.content = self.filepath.read_text(encoding="utf-8") + self.colors = {} # tag -> hex + self.history = [] # list of contents for undo + self.redo_history = [] + self._parse() + + def _parse(self): + # Find the regardless of attribute order + style_match = re.search(r']*?id="current-color-scheme"[^>]*>(.*?)', self.content, re.DOTALL) + if style_match: + style_content = style_match.group(1) + # Find classes e.g. .ColorScheme-Text { color:#232629; } + class_matches = re.finditer(r'\.(ColorScheme-[a-zA-Z0-9]+)\s*\{\s*color:\s*(#[a-fA-F0-9]{3,6})', style_content) + self.colors = {m.group(1): m.group(2) for m in class_matches} + + def update_color(self, tag, new_hex): + if tag not in self.colors: return + old_hex = self.colors[tag] + self.history.append(self.content) + self.redo_history.clear() + + # Globally replace the exact hex string, case insensitive + # We need to compile a regex for the old hex. + hex_pattern = re.compile(re.escape(old_hex), re.IGNORECASE) + self.content = hex_pattern.sub(new_hex, self.content) + + self.colors[tag] = new_hex + self.save() + self._parse() # re-parse to ensure consistency + + def save(self): + self.filepath.write_text(self.content, encoding="utf-8") + + def undo(self): + if self.history: + self.redo_history.append(self.content) + self.content = self.history.pop() + self.save() + self._parse() + return True + return False + + def redo(self): + if self.redo_history: + self.history.append(self.content) + self.content = self.redo_history.pop() + self.save() + self._parse() + return True + return False