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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-17 13:19:26 -04:00
parent 9baddeef18
commit a134fb59c3
3 changed files with 591 additions and 10 deletions

View File

@ -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 <tool>` - Check if dependencies are satisfied
> - `cmdforge install` - Install dependencies from cmdforge.yaml manifest
> - `cmdforge add <owner/name>` - 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.

View File

@ -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")

View File

@ -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