diff --git a/README.md b/README.md new file mode 100644 index 0000000..a558bdf --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# icon-color-tool + +A small GTK4 desktop app for editing the `ColorScheme-*` CSS colors +embedded in [Breeze icon](https://github.com/KDE/breeze-icons) SVGs. + +## Usage + +```sh +icon-color-tool # opens a searchable icon picker +icon-color-tool path/to/icon.svg # opens directly in the color editor +``` + +The picker's search box does a fuzzy (subsequence) match against each +icon's path. Selecting an icon (or passing one directly) opens the color +editor: click a swatch to pick a new color for that `ColorScheme-*` class; +changes save to disk immediately, and Undo/Redo are available from the +toolbar. + +## Configuration + +On first run, a config file is created at +`~/.config/icon-color-tool/config.toml`: + +```toml +[settings] +breeze_path = "~/.local/share/breeze-icons" +``` + +If the configured `breeze_path` doesn't exist (or is empty), the +[breeze-icons](https://github.com/KDE/breeze-icons) repository is cloned +there automatically (shallow clone via `git`, which must be installed and +on `PATH`). + +Set the `BREEZE_ICONS_PATH` environment variable to override the +configured path entirely (also auto-clones if missing). + +## Building + +Requires Rust (stable) and the GTK4 + librsvg development libraries +installed system-wide (e.g. `gtk4-devel`/`libgtk-4-dev` and +`librsvg2-devel`/`librsvg2-dev`, depending on your distro). + +```sh +cargo build --release +./target/release/icon-color-tool +``` diff --git a/config.py b/config.py deleted file mode 100644 index 7234da6..0000000 --- a/config.py +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 2d79818..0000000 --- a/gui.py +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100755 index 603b4df..0000000 --- a/icon-color-tool +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -python3 main.py diff --git a/main.py b/main.py deleted file mode 100644 index f57a70d..0000000 --- a/main.py +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index ee397a4..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -PyQt6 diff --git a/svg_parser.py b/svg_parser.py deleted file mode 100644 index ca3af6a..0000000 --- a/svg_parser.py +++ /dev/null @@ -1,56 +0,0 @@ -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