From a134fb59c3e261b10cbd47477933a36cdcf73ba5 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 17 Jan 2026 13:19:26 -0400 Subject: [PATCH] Add dark mode and simple theming support to GUI - Add DARK_THEME stylesheet alongside existing LIGHT_THEME - Add load_theme() function to load themes by name - Support custom themes via ~/.cmdforge/theme.qss - Add View menu with Theme submenu for switching themes - Persist theme choice in QSettings across sessions - Update META_TOOLS.md to reflect implemented dependency commands Co-Authored-By: Claude Opus 4.5 --- olddocs/META_TOOLS.md | 7 +- src/cmdforge/gui/main_window.py | 57 +++- src/cmdforge/gui/styles.py | 537 +++++++++++++++++++++++++++++++- 3 files changed, 591 insertions(+), 10 deletions(-) diff --git a/olddocs/META_TOOLS.md b/olddocs/META_TOOLS.md index 7292a3c..6f56742 100644 --- a/olddocs/META_TOOLS.md +++ b/olddocs/META_TOOLS.md @@ -1,8 +1,11 @@ # Meta-Tools: Tools That Call Other Tools > **Document Status (January 2026)**: The `tool` step type is implemented and works as described. -> The CLI dependency commands (`cmdforge install --deps`, `cmdforge check`) are not yet implemented. -> Tools can still use `tool` steps, but dependency resolution is manual. +> The CLI dependency commands are now implemented: +> - `cmdforge check ` - Check if dependencies are satisfied +> - `cmdforge install` - Install dependencies from cmdforge.yaml manifest +> - `cmdforge add ` - Add a tool to project dependencies +> - `--auto-install` flag on tool execution for automatic dependency installation Meta-tools are CmdForge tools that can invoke other tools as steps in their workflow. This enables powerful composition and reuse of existing tools. diff --git a/src/cmdforge/gui/main_window.py b/src/cmdforge/gui/main_window.py index 9840bd5..e5e7724 100644 --- a/src/cmdforge/gui/main_window.py +++ b/src/cmdforge/gui/main_window.py @@ -6,9 +6,9 @@ from PySide6.QtWidgets import ( QStatusBar, QLabel, QSplitter, QMenuBar, QMenu ) from PySide6.QtCore import Qt, QSize, QSettings, QTimer -from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence, QAction +from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence, QAction, QActionGroup -from .styles import STYLESHEET +from .styles import load_theme, get_available_themes, get_custom_theme_path class MainWindow(QMainWindow): @@ -20,12 +20,13 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 700) self.resize(1200, 800) - # Apply stylesheet - self.setStyleSheet(STYLESHEET) - # Settings for persistence self._settings = QSettings("CmdForge", "CmdForge") + # Load and apply theme + self._current_theme = self._settings.value("theme", "light") + self._apply_theme(self._current_theme) + # Central widget central = QWidget() self.setCentralWidget(central) @@ -235,10 +236,54 @@ class MainWindow(QMainWindow): shortcut_help = QShortcut(QKeySequence("F1"), self) shortcut_help.activated.connect(self._show_getting_started) + def _apply_theme(self, theme_name: str): + """Apply a theme to the application.""" + stylesheet = load_theme(theme_name) + self.setStyleSheet(stylesheet) + self._current_theme = theme_name + self._settings.setValue("theme", theme_name) + def _setup_menu_bar(self): - """Set up the menu bar with Help menu.""" + """Set up the menu bar with View and Help menus.""" menubar = self.menuBar() + # View menu + view_menu = menubar.addMenu("&View") + + # Theme submenu + theme_menu = view_menu.addMenu("&Theme") + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + # Add theme options + for theme_name in get_available_themes(): + display_name = theme_name.capitalize() + if theme_name == "custom": + display_name = f"Custom ({get_custom_theme_path().name})" + + action = QAction(display_name, self) + action.setCheckable(True) + action.setChecked(theme_name == self._current_theme) + action.setData(theme_name) + # Use default argument to capture theme_name by value + action.triggered.connect(lambda checked=False, t=theme_name: self._apply_theme(t)) + theme_group.addAction(action) + theme_menu.addAction(action) + + # Add hint about custom themes + theme_menu.addSeparator() + hint_action = QAction(f"Custom: ~/.cmdforge/theme.qss", self) + hint_action.setEnabled(False) + theme_menu.addAction(hint_action) + + view_menu.addSeparator() + + # Refresh action + action_refresh = QAction("&Refresh", self) + action_refresh.setShortcut("Ctrl+R") + action_refresh.triggered.connect(self._shortcut_refresh) + view_menu.addAction(action_refresh) + # Help menu help_menu = menubar.addMenu("&Help") diff --git a/src/cmdforge/gui/styles.py b/src/cmdforge/gui/styles.py index 0ed31a5..112fc51 100644 --- a/src/cmdforge/gui/styles.py +++ b/src/cmdforge/gui/styles.py @@ -1,6 +1,9 @@ -"""Modern stylesheet for CmdForge GUI.""" +"""Theme stylesheets for CmdForge GUI.""" -STYLESHEET = """ +from pathlib import Path + +# Light theme (default) +LIGHT_THEME = """ /* Main Window */ QMainWindow { background-color: #f5f5f5; @@ -401,4 +404,534 @@ QRadioButton::indicator:checked { border-radius: 9px; background-color: #667eea; } + +/* Menu bar */ +QMenuBar { + background-color: #f7fafc; + border-bottom: 1px solid #e2e8f0; +} + +QMenuBar::item { + padding: 6px 12px; + background-color: transparent; +} + +QMenuBar::item:selected { + background-color: #e2e8f0; +} + +QMenu { + background-color: white; + border: 1px solid #e2e8f0; +} + +QMenu::item { + padding: 8px 24px; +} + +QMenu::item:selected { + background-color: #667eea; + color: white; +} + +QMenu::separator { + height: 1px; + background-color: #e2e8f0; + margin: 4px 8px; +} """ + +# Dark theme +DARK_THEME = """ +/* Main Window */ +QMainWindow { + background-color: #1a1a2e; +} + +/* Sidebar */ +#sidebar { + background-color: #16213e; + border: none; + min-width: 180px; + max-width: 180px; +} + +#sidebar::item { + color: #a0aec0; + padding: 12px 16px; + border: none; + border-left: 3px solid transparent; +} + +#sidebar::item:selected { + background-color: #1f3460; + border-left: 3px solid #667eea; + color: white; +} + +#sidebar::item:hover:!selected { + background-color: #1a2d50; +} + +/* Content area */ +QWidget#content_area { + background-color: #1a1a2e; +} + +/* Buttons */ +QPushButton { + background-color: #667eea; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-weight: 500; + min-height: 32px; +} + +QPushButton:hover { + background-color: #7c8ff0; +} + +QPushButton:pressed { + background-color: #5a67d8; +} + +QPushButton:disabled { + background-color: #4a5568; + color: #718096; +} + +QPushButton#secondary { + background-color: #2d3748; + color: #e2e8f0; +} + +QPushButton#secondary:hover { + background-color: #4a5568; +} + +QPushButton#danger { + background-color: #e53e3e; +} + +QPushButton#danger:hover { + background-color: #fc5c65; +} + +/* View toggle buttons */ +QPushButton#viewToggle { + background-color: #2d3748; + color: #a0aec0; + border-radius: 4px; + padding: 6px 12px; + min-height: 24px; + min-width: 60px; +} + +QPushButton#viewToggle:checked { + background-color: #667eea; + color: white; +} + +QPushButton#viewToggle:hover:!checked { + background-color: #4a5568; +} + +/* Input fields */ +QLineEdit, QTextEdit, QPlainTextEdit { + border: 1px solid #4a5568; + border-radius: 6px; + padding: 8px 12px; + background-color: #2d3748; + color: #e2e8f0; + selection-background-color: #667eea; +} + +QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus { + border-color: #667eea; + outline: none; +} + +/* Combo boxes */ +QComboBox { + border: 1px solid #4a5568; + border-radius: 6px; + padding: 8px 12px; + background-color: #2d3748; + color: #e2e8f0; + min-height: 20px; +} + +QComboBox:focus { + border-color: #667eea; +} + +QComboBox::drop-down { + border: none; + width: 24px; +} + +QComboBox::down-arrow { + image: none; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #a0aec0; + margin-right: 8px; +} + +QComboBox QAbstractItemView { + border: 1px solid #4a5568; + background-color: #2d3748; + color: #e2e8f0; + selection-background-color: #667eea; +} + +/* Lists and Trees */ +QListWidget, QTreeWidget, QTableWidget { + border: 1px solid #4a5568; + border-radius: 6px; + background-color: #2d3748; + color: #e2e8f0; + outline: none; +} + +QListWidget::item, QTreeWidget::item { + padding: 8px 12px; + border: none; +} + +QListWidget::item:selected, QTreeWidget::item:selected { + background-color: #667eea; + color: white; +} + +QListWidget::item:hover:!selected, QTreeWidget::item:hover:!selected { + background-color: #4a5568; +} + +/* Table Widget */ +QTableWidget { + gridline-color: #4a5568; +} + +QTableWidget::item { + padding: 8px; +} + +QTableWidget::item:selected { + background-color: #667eea; + color: white; +} + +QHeaderView::section { + background-color: #1a1a2e; + padding: 8px 12px; + border: none; + border-bottom: 1px solid #4a5568; + font-weight: 600; + color: #a0aec0; +} + +/* Group boxes */ +QGroupBox { + font-weight: 600; + border: 1px solid #4a5568; + border-radius: 8px; + margin-top: 12px; + padding-top: 12px; + background-color: #2d3748; + color: #e2e8f0; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + padding: 0 8px; + color: #e2e8f0; +} + +/* Labels */ +QLabel { + color: #e2e8f0; +} + +QLabel#heading { + font-size: 18px; + font-weight: 600; + color: #ffffff; +} + +QLabel#subheading { + font-size: 14px; + color: #a0aec0; +} + +QLabel#label { + font-weight: 500; + color: #cbd5e0; +} + +QLabel#sectionHeading { + font-weight: 600; + font-size: 13px; + color: #e2e8f0; +} + +/* Scrollbars */ +QScrollBar:vertical { + background-color: #1a1a2e; + width: 12px; + border-radius: 6px; +} + +QScrollBar::handle:vertical { + background-color: #4a5568; + border-radius: 6px; + min-height: 30px; +} + +QScrollBar::handle:vertical:hover { + background-color: #718096; +} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0; +} + +QScrollBar:horizontal { + background-color: #1a1a2e; + height: 12px; + border-radius: 6px; +} + +QScrollBar::handle:horizontal { + background-color: #4a5568; + border-radius: 6px; + min-width: 30px; +} + +QScrollBar::handle:horizontal:hover { + background-color: #718096; +} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0; +} + +/* Status bar */ +QStatusBar { + background-color: #16213e; + border-top: 1px solid #4a5568; + color: #a0aec0; +} + +/* Splitter */ +QSplitter::handle { + background-color: #4a5568; +} + +QSplitter::handle:horizontal { + width: 2px; +} + +QSplitter::handle:vertical { + height: 2px; +} + +/* Tabs */ +QTabWidget::pane { + border: 1px solid #4a5568; + border-radius: 6px; + background-color: #2d3748; +} + +QTabBar::tab { + background-color: #1a1a2e; + border: 1px solid #4a5568; + border-bottom: none; + padding: 8px 16px; + margin-right: 2px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + color: #a0aec0; +} + +QTabBar::tab:selected { + background-color: #2d3748; + border-bottom: 1px solid #2d3748; + color: #e2e8f0; +} + +QTabBar::tab:hover:!selected { + background-color: #16213e; +} + +/* Dialogs */ +QDialog { + background-color: #1a1a2e; +} + +/* Message boxes */ +QMessageBox { + background-color: #1a1a2e; +} + +QMessageBox QPushButton { + min-width: 80px; +} + +/* Tool tips */ +QToolTip { + background-color: #4a5568; + color: white; + border: none; + padding: 6px 10px; + border-radius: 4px; +} + +/* Progress bar */ +QProgressBar { + border: 1px solid #4a5568; + border-radius: 6px; + background-color: #2d3748; + text-align: center; + height: 20px; + color: #e2e8f0; +} + +QProgressBar::chunk { + background-color: #667eea; + border-radius: 5px; +} + +/* Spin boxes */ +QSpinBox, QDoubleSpinBox { + border: 1px solid #4a5568; + border-radius: 6px; + padding: 8px 12px; + background-color: #2d3748; + color: #e2e8f0; +} + +QSpinBox:focus, QDoubleSpinBox:focus { + border-color: #667eea; +} + +/* Check boxes and radio buttons */ +QCheckBox, QRadioButton { + spacing: 8px; + color: #e2e8f0; +} + +QCheckBox::indicator, QRadioButton::indicator { + width: 18px; + height: 18px; +} + +QCheckBox::indicator:unchecked { + border: 2px solid #4a5568; + border-radius: 4px; + background-color: #2d3748; +} + +QCheckBox::indicator:checked { + border: 2px solid #667eea; + border-radius: 4px; + background-color: #667eea; +} + +QRadioButton::indicator:unchecked { + border: 2px solid #4a5568; + border-radius: 9px; + background-color: #2d3748; +} + +QRadioButton::indicator:checked { + border: 2px solid #667eea; + border-radius: 9px; + background-color: #667eea; +} + +/* Menu bar */ +QMenuBar { + background-color: #16213e; + border-bottom: 1px solid #4a5568; + color: #e2e8f0; +} + +QMenuBar::item { + padding: 6px 12px; + background-color: transparent; +} + +QMenuBar::item:selected { + background-color: #1f3460; +} + +QMenu { + background-color: #2d3748; + border: 1px solid #4a5568; + color: #e2e8f0; +} + +QMenu::item { + padding: 8px 24px; +} + +QMenu::item:selected { + background-color: #667eea; + color: white; +} + +QMenu::separator { + height: 1px; + background-color: #4a5568; + margin: 4px 8px; +} +""" + +# Available themes +THEMES = { + "light": LIGHT_THEME, + "dark": DARK_THEME, +} + +# For backwards compatibility +STYLESHEET = LIGHT_THEME + + +def get_custom_theme_path() -> Path: + """Get path to custom theme file.""" + return Path.home() / ".cmdforge" / "theme.qss" + + +def load_theme(theme_name: str = "light") -> str: + """ + Load a theme stylesheet. + + Args: + theme_name: Name of theme ("light", "dark") or "custom" for external file + + Returns: + The stylesheet string + """ + # Check for custom theme file first + custom_path = get_custom_theme_path() + if theme_name == "custom" and custom_path.exists(): + try: + return custom_path.read_text() + except Exception: + pass # Fall back to light theme + + # Return built-in theme + return THEMES.get(theme_name, LIGHT_THEME) + + +def get_available_themes() -> list: + """Get list of available theme names.""" + themes = list(THEMES.keys()) + if get_custom_theme_path().exists(): + themes.append("custom") + return themes