diff --git a/docs/MEDHA-v2-PLAN.md b/docs/MEDHA-v2-PLAN.md index 4f10989..dd3830c 100644 --- a/docs/MEDHA-v2-PLAN.md +++ b/docs/MEDHA-v2-PLAN.md @@ -22,6 +22,8 @@ Release rule: fix and push all month, publish to the Store **once** at the end ( | B5 | Low | 8 `except Exception: pass` hide real errors. | `grep -rn "except Exception" medha` | Log with `logging.exception` and show a status-bar message. | | B6 | Low | Test suite fails offline (real network call). | `tests/test_extensions_registry.py` | `@pytest.mark.network`, skipped unless `MEDHA_NETWORK_TESTS=1`. | | B7 | Low | 16 unused variables (pyflakes). | dsa_engine, panel.py, renderers | Remove. CI runs `pyflakes medha` and must be clean. | +| B8 | Medium | Closing a window with active disassembly can call a deleted QTextEdit. | `app.py` closeEvent | Stop child QProcesses before widget teardown. | +| B9 | High | Closing an edited tab discarded unsaved work without confirmation. | `app.py` close_tab/closeEvent | Save/Discard/Cancel guard, including cancelled Save As. | More bugs come from Track B (section 3) — append them to this table with the same columns. ## 2. Track A — New UI like VS Code / Antigravity (ChatGPT) @@ -76,3 +78,13 @@ diagnostics, rename, format, run, debug) before the next: | 3.5 | Go (GoLand), Rust (RustRover) | gopls, rust-analyzer | delve, codelldb | | 3.6 | SQL (DataGrip) | sqls + a simple DB browser (SQLite first) | — | Each server is downloaded on first use (not bundled) to keep the Store package small; everything still works offline after that. + +## Track A implementation evidence (2026-11-05) + +Implemented on `v2-ui`: Track A steps 1–8, B1 and B2. Required offline/lint +prerequisites B4/B6/B7 are included as separate commits. B8/B9 were discovered +while exercising the UI and are fixed in this branch. B3/B5 remain Track B work. +See [verification details](V2-UI-VERIFICATION.md) and +[before/after screenshots](screenshots/v2/README.md). Linux checks: 106 passed, +1 explicit network skip; pyflakes clean; 20 UI smoke tests passed. Windows/manual +release acceptance remains pending. diff --git a/docs/V2-UI-VERIFICATION.md b/docs/V2-UI-VERIFICATION.md new file mode 100644 index 0000000..fe5caa4 --- /dev/null +++ b/docs/V2-UI-VERIFICATION.md @@ -0,0 +1,59 @@ +# Track A verification — 2026-11-05 + +Branch: `v2-ui`. Base: `92e278b`. + +## Implemented + +- B1: 48 px activity bar, scrollable sidebar views, editor-first splitters, bottom panel; Ctrl+B/Ctrl+J and QSettings size/visibility persistence. +- B2: escaped literal ampersands in docks, debugger tabs, and dynamically named editor tabs. +- Command centre, command palette, incremental Quick Open, workspace text search, Git view, and File/Edit/Selection/View/Go/Run/Terminal/Help menus. +- Existing menu actions preserved as the same QAction objects; original action names captured in `tests/fixtures/v1_actions.json` and checked after relocation. +- Medhā Dark/Light with shared QSS and central color tokens, readable activity icons, grey close buttons, editor gutter and clickable minimap. Windows PyInstaller spec includes the QSS resource. +- Welcome page with new C file, open folder/file, current Recent list, Turbo examples, and DSA entry points. +- Real local branch, document/language/encoding, compiler availability and build diagnostics; optional CPU/RAM sampling. +- B4, B6, B7: prerequisite duplicate-loader, offline-test and lint cleanup in separate commits. +- Newly documented B8/B9: orderly process shutdown and Save/Discard/Cancel protection for edited tabs/windows. + +## Checks by step + +All counts below are offline. The real registry test is explicitly skipped unless +`MEDHA_NETWORK_TESTS=1`; local fake registry-server tests still run. + +| Step | Full pytest | UI smoke | +|---|---|---| +| Baseline | 85 passed, real-network test deselected | baseline screenshots | +| B6 | 86 passed, 1 skipped | window opens | +| B4/B7 | 87 passed, 1 skipped | 1 passed | +| B2/B8 | 88 passed, 1 skipped | 2 passed | +| Workbench/B1 | 91 passed, 1 skipped | 5 passed | +| Navigation | 93 passed, 1 skipped | 7 passed | +| Themes/minimap | 94 passed, 1 skipped | 8 passed | +| Welcome/close guard | 96 passed, 1 skipped | 10 passed | +| Status/actions | 98 passed, 1 skipped | 12 passed | +| Final regression checks | **106 passed, 1 skipped** | **20 passed** | + +Pyflakes is clean after B7 and at every subsequent completed step. + +```sh +QT_QPA_PLATFORM=offscreen python -m pytest -q +python -m pyflakes medha +QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_smoke.py -q +``` + +UI tests check tool routing, both screen sizes/themes, actual text viewport width, +keyboard toggles, settings restoration into a second window, palette action +preservation, Quick Open, Search, Recent, minimap navigation, real status updates, +unsaved-close cancellation and cancelled Save As. Requests are blocked during UI +startup, and Qt callback exceptions are collected as failures. + +The all-action smoke test triggers every menu action from the legitimate Welcome +(no active document) state. Dialogs are cancelled and external CMake build calls +are stubbed. It verifies UI wiring and survival, not every compiler/debugger backend. +Focused tests exercise document operations separately. + +## Review limits + +- Screenshots and tests ran on Linux/PySide6 6.12. Windows install/launch/uninstall and the full cross-platform release checklist are still required before release. +- B3 (PTY/ConPTY) and B5 (remaining broad exception handlers) remain Track B work. +- Original UI supported only dark; before-light images cannot be captured authentically. Both new themes have full after evidence in `screenshots/v2/`. +- No Store submission, release version change, or merge is included. diff --git a/docs/screenshots/v2/README.md b/docs/screenshots/v2/README.md new file mode 100644 index 0000000..89f24b7 --- /dev/null +++ b/docs/screenshots/v2/README.md @@ -0,0 +1,34 @@ +# Track A visual verification + +Captured from the real PySide6 window on Linux with `QT_QPA_PLATFORM=offscreen`. +Images are native 1366×768 and 1920×1080 pixels, without rescaling. + +| State | 1366×768 | 1920×1080 | +|---|---|---| +| Before (original dark theme) | [Before](before-dark-1366x768.png) | [Before](before-dark-1920x1080.png) | +| After, Medhā Dark | [Dark](after-dark-1366x768.png) | [Dark](after-dark-1920x1080.png) | +| After, Medhā Light | [Light](after-light-1366x768.png) | [Light](after-light-1920x1080.png) | +| Welcome, dark | [Welcome](after-welcome-dark-1366x768.png) | [Welcome](after-welcome-dark-1920x1080.png) | +| Welcome, light | [Welcome](after-welcome-light-1366x768.png) | [Welcome](after-welcome-light-1920x1080.png) | + +The original application has no light-theme switch, so a genuine before-light +capture is unavailable. No synthetic before-light image is supplied. +`step-1` through `step-5` retain intermediate layout/navigation/theme/welcome/status evidence. + +The editor tab region increased from **127 → 1044 px** at 1366×768 and +**301 → 1598 px** at 1920×1080. Automated assertions also check the actual text +viewport (excluding the gutter and minimap) remains at least 60% of window width +in both themes at both resolutions. + +The red terminal job-control warning is existing **B3**, assigned to Track B. +It remains visible in these unedited screenshots; this PR does not implement a PTY. + +Reproduce from the repository root in an environment with the dependencies installed: + +```sh +QT_QPA_PLATFORM=offscreen python tools/capture_v2.py --phase after +QT_QPA_PLATFORM=offscreen python tools/capture_v2.py --phase after-welcome --welcome +``` + +The capture tool uses a temporary project and isolated settings, then shuts down +its processes. It does not change the user's workspace or preferences. diff --git a/docs/screenshots/v2/after-dark-1366x768.png b/docs/screenshots/v2/after-dark-1366x768.png new file mode 100644 index 0000000..ae0bdb9 Binary files /dev/null and b/docs/screenshots/v2/after-dark-1366x768.png differ diff --git a/docs/screenshots/v2/after-dark-1920x1080.png b/docs/screenshots/v2/after-dark-1920x1080.png new file mode 100644 index 0000000..872e813 Binary files /dev/null and b/docs/screenshots/v2/after-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/after-light-1366x768.png b/docs/screenshots/v2/after-light-1366x768.png new file mode 100644 index 0000000..05605b3 Binary files /dev/null and b/docs/screenshots/v2/after-light-1366x768.png differ diff --git a/docs/screenshots/v2/after-light-1920x1080.png b/docs/screenshots/v2/after-light-1920x1080.png new file mode 100644 index 0000000..3ae5067 Binary files /dev/null and b/docs/screenshots/v2/after-light-1920x1080.png differ diff --git a/docs/screenshots/v2/after-welcome-dark-1366x768.png b/docs/screenshots/v2/after-welcome-dark-1366x768.png new file mode 100644 index 0000000..6431ce3 Binary files /dev/null and b/docs/screenshots/v2/after-welcome-dark-1366x768.png differ diff --git a/docs/screenshots/v2/after-welcome-dark-1920x1080.png b/docs/screenshots/v2/after-welcome-dark-1920x1080.png new file mode 100644 index 0000000..41bf989 Binary files /dev/null and b/docs/screenshots/v2/after-welcome-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/after-welcome-light-1366x768.png b/docs/screenshots/v2/after-welcome-light-1366x768.png new file mode 100644 index 0000000..879b822 Binary files /dev/null and b/docs/screenshots/v2/after-welcome-light-1366x768.png differ diff --git a/docs/screenshots/v2/after-welcome-light-1920x1080.png b/docs/screenshots/v2/after-welcome-light-1920x1080.png new file mode 100644 index 0000000..4cf3cae Binary files /dev/null and b/docs/screenshots/v2/after-welcome-light-1920x1080.png differ diff --git a/docs/screenshots/v2/before-dark-1366x768.png b/docs/screenshots/v2/before-dark-1366x768.png new file mode 100644 index 0000000..328cad4 Binary files /dev/null and b/docs/screenshots/v2/before-dark-1366x768.png differ diff --git a/docs/screenshots/v2/before-dark-1920x1080.png b/docs/screenshots/v2/before-dark-1920x1080.png new file mode 100644 index 0000000..2bc3823 Binary files /dev/null and b/docs/screenshots/v2/before-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-1-dark-1366x768.png b/docs/screenshots/v2/step-1-dark-1366x768.png new file mode 100644 index 0000000..406a62f Binary files /dev/null and b/docs/screenshots/v2/step-1-dark-1366x768.png differ diff --git a/docs/screenshots/v2/step-1-dark-1920x1080.png b/docs/screenshots/v2/step-1-dark-1920x1080.png new file mode 100644 index 0000000..374f6fc Binary files /dev/null and b/docs/screenshots/v2/step-1-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-2-dark-1366x768.png b/docs/screenshots/v2/step-2-dark-1366x768.png new file mode 100644 index 0000000..31101a2 Binary files /dev/null and b/docs/screenshots/v2/step-2-dark-1366x768.png differ diff --git a/docs/screenshots/v2/step-2-dark-1920x1080.png b/docs/screenshots/v2/step-2-dark-1920x1080.png new file mode 100644 index 0000000..a86c505 Binary files /dev/null and b/docs/screenshots/v2/step-2-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-3-dark-1366x768.png b/docs/screenshots/v2/step-3-dark-1366x768.png new file mode 100644 index 0000000..3037d40 Binary files /dev/null and b/docs/screenshots/v2/step-3-dark-1366x768.png differ diff --git a/docs/screenshots/v2/step-3-dark-1920x1080.png b/docs/screenshots/v2/step-3-dark-1920x1080.png new file mode 100644 index 0000000..d623432 Binary files /dev/null and b/docs/screenshots/v2/step-3-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-3-light-1366x768.png b/docs/screenshots/v2/step-3-light-1366x768.png new file mode 100644 index 0000000..e569348 Binary files /dev/null and b/docs/screenshots/v2/step-3-light-1366x768.png differ diff --git a/docs/screenshots/v2/step-3-light-1920x1080.png b/docs/screenshots/v2/step-3-light-1920x1080.png new file mode 100644 index 0000000..b129c31 Binary files /dev/null and b/docs/screenshots/v2/step-3-light-1920x1080.png differ diff --git a/docs/screenshots/v2/step-4-welcome-dark-1366x768.png b/docs/screenshots/v2/step-4-welcome-dark-1366x768.png new file mode 100644 index 0000000..99b8e8e Binary files /dev/null and b/docs/screenshots/v2/step-4-welcome-dark-1366x768.png differ diff --git a/docs/screenshots/v2/step-4-welcome-dark-1920x1080.png b/docs/screenshots/v2/step-4-welcome-dark-1920x1080.png new file mode 100644 index 0000000..d978441 Binary files /dev/null and b/docs/screenshots/v2/step-4-welcome-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-4-welcome-light-1366x768.png b/docs/screenshots/v2/step-4-welcome-light-1366x768.png new file mode 100644 index 0000000..fe04c5a Binary files /dev/null and b/docs/screenshots/v2/step-4-welcome-light-1366x768.png differ diff --git a/docs/screenshots/v2/step-4-welcome-light-1920x1080.png b/docs/screenshots/v2/step-4-welcome-light-1920x1080.png new file mode 100644 index 0000000..2dd7f72 Binary files /dev/null and b/docs/screenshots/v2/step-4-welcome-light-1920x1080.png differ diff --git a/docs/screenshots/v2/step-5-dark-1366x768.png b/docs/screenshots/v2/step-5-dark-1366x768.png new file mode 100644 index 0000000..e13c7d4 Binary files /dev/null and b/docs/screenshots/v2/step-5-dark-1366x768.png differ diff --git a/docs/screenshots/v2/step-5-dark-1920x1080.png b/docs/screenshots/v2/step-5-dark-1920x1080.png new file mode 100644 index 0000000..a653cc3 Binary files /dev/null and b/docs/screenshots/v2/step-5-dark-1920x1080.png differ diff --git a/docs/screenshots/v2/step-5-light-1366x768.png b/docs/screenshots/v2/step-5-light-1366x768.png new file mode 100644 index 0000000..ce4b46c Binary files /dev/null and b/docs/screenshots/v2/step-5-light-1366x768.png differ diff --git a/docs/screenshots/v2/step-5-light-1920x1080.png b/docs/screenshots/v2/step-5-light-1920x1080.png new file mode 100644 index 0000000..98026ea Binary files /dev/null and b/docs/screenshots/v2/step-5-light-1920x1080.png differ diff --git a/medha/ai_panel.py b/medha/ai_panel.py index c25702b..c11944a 100644 --- a/medha/ai_panel.py +++ b/medha/ai_panel.py @@ -1,7 +1,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTextEdit, QLabel, QComboBox, QLineEdit ) -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import QThread, Signal from medha.ai import AiAssistant class AiStreamWorker(QThread): diff --git a/medha/app.py b/medha/app.py index 3ca42af..8fb304d 100644 --- a/medha/app.py +++ b/medha/app.py @@ -1,13 +1,8 @@ import os import sys -import psutil -from PySide6.QtWidgets import ( - QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, - QDockWidget, QFileDialog, QMessageBox, QLabel, QStatusBar, - QMenuBar, QMenu, QSplitter, QGraphicsOpacityEffect -) +from PySide6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QTabWidget, QFileDialog, QMessageBox from PySide6.QtGui import QAction, QIcon, QKeySequence, QTextCursor -from PySide6.QtCore import Qt, QSize, QPropertyAnimation +from PySide6.QtCore import Qt def get_app_icon_path() -> str: @@ -40,6 +35,8 @@ def get_app_icon_path() -> str: return repo_icon +from PySide6.QtWidgets import QTextEdit, QMenu +from medha.workbench import ToolDock, Workbench from medha.editor import CodeEditorWidget from medha.editor_widget import AdvancedCodeEditorWidget, CodeOutlineWidget from medha.project_manager import ProjectExplorerWidget @@ -131,23 +128,7 @@ def __init__(self, workspace_path=None): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_tab) self.tab_widget.currentChanged.connect(self.on_tab_changed) - self.tab_widget.setStyleSheet(""" - QTabWidget::pane { - border: none; - background-color: #0d1117; - } - QTabBar::tab { - background-color: #161b22; - color: #8b949e; - padding: 6px 14px; - border-right: 1px solid #0d1117; - } - QTabBar::tab:selected { - background-color: #0d1117; - color: #58a6ff; - border-top: 2px solid #1f6feb; - } - """) + # Appearance is inherited from the shared workbench QSS. self.setCentralWidget(self.tab_widget) @@ -156,7 +137,7 @@ def __init__(self, workspace_path=None): self.explorer.file_selected.connect(self.open_file_path) self.explorer.config_changed.connect(self.on_project_config_changed) - self.dock_explorer = QDockWidget("Project Explorer", self) + self.dock_explorer = ToolDock("Project Explorer", self) self.dock_explorer.setWidget(self.explorer) self.dock_explorer.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_explorer) @@ -171,29 +152,18 @@ def __init__(self, workspace_path=None): self.safety_panel.diagnostic_selected.connect(self.navigate_to_problem) self.bottom_tabs.addTab(self.terminal, "Terminal") - self.bottom_tabs.addTab(self.problems, "Compiler Output & Issues") + self.output_log = QTextEdit() + self.output_log.setReadOnly(True) + self.bottom_tabs.addTab(self.output_log, "Output") + self.bottom_tabs.addTab(self.problems, "Problems") self.bottom_tabs.addTab(self.safety_panel, "Safety Linter (cppcheck)") # v2: offline security scan of the student's own code (CWE + explanation) self.security_panel = SecurityPanel() self.security_panel.finding_selected.connect(self.navigate_to_problem) self.bottom_tabs.addTab(self.security_panel, "Security") - self.bottom_tabs.setStyleSheet(""" - QTabWidget::pane { - border: 1px solid #21262d; - background-color: #090d16; - } - QTabBar::tab { - background-color: #161b22; - color: #8b949e; - padding: 4px 12px; - } - QTabBar::tab:selected { - background-color: #0d1117; - color: #58a6ff; - } - """) - - self.dock_bottom = QDockWidget("Console & Safety Diagnostics", self) + # Appearance is inherited from the shared workbench QSS. + + self.dock_bottom = ToolDock("Console && Safety Diagnostics", self) self.dock_bottom.setWidget(self.bottom_tabs) self.dock_bottom.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.TopDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.dock_bottom) @@ -201,19 +171,19 @@ def __init__(self, workspace_path=None): # Dock Widget 3 — Universal DSA Visualizer (Right) self.dsa_tracer = UniversalDSATracer(self) self.dsa_widget = DSAVisualizerWidget(tracer=self.dsa_tracer, parent=self) - self.dock_dsa = QDockWidget("DSA Visualizer Engine", self) + self.dock_dsa = ToolDock("DSA Visualizer Engine", self) self.dock_dsa.setWidget(self.dsa_widget) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_dsa) # Dock Widget 4 — Visual Memory & Pointer Inspector (Right) self.memory_view = MemoryVisualizerWidget(self) - self.dock_memory = QDockWidget("Live Memory & Pointers", self) + self.dock_memory = ToolDock("Live Memory && Pointers", self) self.dock_memory.setWidget(self.memory_view) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_memory) # Dock Widget 5 — Assembly Inspector (Right) self.asm_inspector = DisassemblyInspectorWidget(self) - self.dock_asm = QDockWidget("Live Disassembly (gcc -S)", self) + self.dock_asm = ToolDock("Live Disassembly (gcc -S)", self) self.dock_asm.setWidget(self.asm_inspector) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_asm) @@ -228,51 +198,37 @@ def __init__(self, workspace_path=None): self.gdb_engine.stopped_at.connect(self.on_gdb_stopped_at) self.gdb_engine.variables_updated.connect(self.on_gdb_variables_updated) - self.dock_gdb = QDockWidget("GDB Interactive Debugger", self) + self.dock_gdb = ToolDock("GDB Interactive Debugger", self) self.dock_gdb.setWidget(self.gdb_panel) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_gdb) # Dock Widget 7 — Symbol Outline (Right) self.outline_panel = CodeOutlineWidget(self) self.outline_panel.symbol_selected.connect(self.navigate_to_line) - self.dock_outline = QDockWidget("Symbol Outline", self) + self.dock_outline = ToolDock("Symbol Outline", self) self.dock_outline.setWidget(self.outline_panel) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_outline) # AI Assistant Right Dock (P7) from medha.ai_panel import AiAssistantPanelWidget self.ai_panel = AiAssistantPanelWidget(self) - self.dock_ai = QDockWidget("Offline AI Assistant", self) + self.dock_ai = ToolDock("Offline AI Assistant", self) self.dock_ai.setWidget(self.ai_panel) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_ai) # Extensions Panel (Right Dock) - P8 Extensions self.extensions_panel = ExtensionsPanel(self) self.extensions_panel.theme_apply_requested.connect(self._on_theme_apply_requested) - self.dock_extensions = QDockWidget("Extensions", self) + self.dock_extensions = ToolDock("Extensions", self) self.dock_extensions.setWidget(self.extensions_panel) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_extensions) # v2: Turbo C screen — graphics.h / conio.h output drawn from the real framebuffer self.turbo_panel = TurboPanel(self) - self.dock_turbo = QDockWidget("Turbo C Screen (640×480)", self) + self.dock_turbo = ToolDock("Turbo C Screen (640×480)", self) self.dock_turbo.setWidget(self.turbo_panel) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_turbo) - # Tabify right dock widgets for clean UI - self.tabifyDockWidget(self.dock_dsa, self.dock_memory) - self.tabifyDockWidget(self.dock_memory, self.dock_asm) - self.tabifyDockWidget(self.dock_asm, self.dock_gdb) - self.tabifyDockWidget(self.dock_gdb, self.dock_outline) - self.tabifyDockWidget(self.dock_outline, self.dock_ai) - self.tabifyDockWidget(self.dock_ai, self.dock_extensions) - self.tabifyDockWidget(self.dock_extensions, self.dock_turbo) - - # Dock Fade-in Animations - self.setup_dock_fade_in(self.dock_explorer) - self.setup_dock_fade_in(self.dock_bottom) - self.setup_dock_fade_in(self.dock_dsa) - # Process Runner & Analyzer Engines self.runner = ProcessRunner(self) self.runner.output_received.connect(self.on_runner_output) @@ -294,51 +250,39 @@ def __init__(self, workspace_path=None): # Setup MenuBar & StatusBar self.create_menu_bar() + self.workbench = Workbench(self) + self.arrange_menus() + self.setCentralWidget(self.workbench) self.create_status_bar() self.apply_dark_theme() - # Open initial welcome scratchpad file - welcome_c_path = os.path.join(self.workspace_path, "main.c") - if not os.path.exists(welcome_c_path): - with open(welcome_c_path, 'w', encoding='utf-8') as f: - f.write("// MEDHĀ IDE — Native C Scratchpad\n#include \n#include \n\nint main() {\n int a = 42;\n int *ptr = (int *)malloc(sizeof(int) * 10);\n printf(\"Hello from Native MEDHĀ IDE!\\n\");\n free(ptr);\n return 0;\n}\n") - self.open_file_path(welcome_c_path) - - def setup_dock_fade_in(self, dock): - def on_vis(visible): - if visible and dock.widget(): - effect = QGraphicsOpacityEffect(dock.widget()) - dock.widget().setGraphicsEffect(effect) - anim = QPropertyAnimation(effect, b"opacity", dock) - anim.setDuration(120) - anim.setStartValue(0.0) - anim.setEndValue(1.0) - anim.start() - dock._fade_anim = anim - dock.visibilityChanged.connect(on_vis) + self.show_welcome() + + def show_welcome(self): + from medha.welcome import WelcomePage + for index in range(self.tab_widget.count()): + if isinstance(self.tab_widget.widget(index), WelcomePage): + self.tab_widget.widget(index).refresh_recent() + self.tab_widget.setCurrentIndex(index) + return + page = WelcomePage(self) + index = self.tab_widget.addTab(page, 'Welcome') + self.install_tab_close(page) + self.tab_widget.setCurrentIndex(index) + + def show_turbo_examples(self): + menu = QMenu(self) + root = os.path.join(str(platform_info.app_root()), 'examples', 'turboc') + if os.path.isdir(root): + for name in sorted(os.listdir(root)): + if os.path.isfile(os.path.join(root, name)): + action = menu.addAction(name.replace('&', '&&')) + action.triggered.connect(lambda _=False, p=os.path.join(root, name): self.open_lab_program(p)) + menu.exec(self.mapToGlobal(self.rect().center())) def create_menu_bar(self): menu_bar = self.menuBar() - menu_bar.setStyleSheet(""" - QMenuBar { - background-color: #161b22; - color: #c9d1d9; - padding: 2px; - } - QMenuBar::item:selected { - background-color: #1f6feb; - color: #ffffff; - } - QMenu { - background-color: #161b22; - color: #c9d1d9; - border: 1px solid #30363d; - } - QMenu::item:selected { - background-color: #1f6feb; - color: #ffffff; - } - """) + # Appearance is inherited from the shared workbench QSS. # File Menu file_menu = menu_bar.addMenu("&File") @@ -545,35 +489,122 @@ def create_menu_bar(self): act_about.triggered.connect(self.show_about_dialog) help_menu.addAction(act_about) + def arrange_menus(self): + from medha.navigation import SearchView, GitView + from PySide6.QtWidgets import QStyle, QPushButton + from medha.command_palette import collect_actions + self.legacy_actions = [action for _, action in collect_actions(self.menuBar())] + menus = {action.menu().title().replace('&', ''): action.menu() + for action in self.menuBar().actions()} + bar = self.menuBar() + for action in list(bar.actions()): + bar.removeAction(action) + # Reuse the very same QAction objects so shortcuts and connected slots survive. + selection = QMenu('Selection', self) + go = QMenu('Go', self) + terminal = QMenu('Terminal', self) + for menu in [menus['File'], menus['Edit'], selection, menus['View'], go, + menus['Run'], terminal, menus['Help']]: + bar.addMenu(menu) + menus['Run'].addMenu(menus['Debug']) + menus['Run'].addMenu(menus['Build']) + menus['Run'].addMenu(menus['Analyze']) + menus['Edit'].addMenu(menus['Tools']) + go.addMenu(menus['LSP']) + menus['View'].addMenu(menus['Git']) + + def add(menu, title, shortcut, callback): + action = QAction(title, self) + if shortcut: + action.setShortcut(QKeySequence(shortcut)) + action.triggered.connect(callback) + menu.addAction(action) + return action + + add(selection, 'Select All', 'Ctrl+A', lambda: self.get_current_editor() and self.get_current_editor().selectAll()) + add(menus['Help'], 'Welcome', None, self.show_welcome) + add(menus['File'], 'Quick Open…', 'Ctrl+P', self.show_quick_open) + add(go, 'Go to Line…', 'Ctrl+G', self.go_to_line) + add(terminal, 'Focus Terminal', 'Ctrl+`', self.focus_terminal) + add(terminal, 'Clear Terminal', None, self.terminal.output_edit.clear) + add(terminal, 'Stop Program', None, self.runner_stop) + self.search_view = SearchView(self) + search_index = self.workbench.add_view('Search', self.search_view, QStyle.StandardPixmap.SP_FileDialogContentsView) + self.git_view = GitView(self) + git_index = self.workbench.add_view('Git', self.git_view, QStyle.StandardPixmap.SP_DirLinkIcon) + order = ['Explorer', 'Search', 'Run and Debug', 'Git', 'Extensions', 'DSA', 'AI', 'Outline'] + for position, title in enumerate(order): + index = self.workbench.selector.findText(title) + self.workbench.activity_layout.insertWidget(position, self.workbench.buttons[index]) + add(menus['View'], 'Search Workspace', 'Ctrl+Shift+F', lambda: self.workbench.activate(search_index)) + add(menus['View'], 'Source Control', 'Ctrl+Shift+G', lambda: self.workbench.activate(git_index)) + add(menus['View'], 'Medhā Dark', None, lambda: self.apply_theme('dark')) + add(menus['View'], 'Medhā Light', None, lambda: self.apply_theme('light')) + self.command_centre = QPushButton('Search commands… Ctrl+Shift+P') + self.command_centre.setObjectName('commandCentre') + self.command_centre.clicked.connect(self.show_command_palette) + bar.setCornerWidget(self.command_centre, Qt.Corner.TopRightCorner) + + def show_quick_open(self): + from medha.navigation import QuickOpen + QuickOpen(self.workspace_path, self.open_file_path, self).exec() + + def go_to_line(self): + from PySide6.QtWidgets import QInputDialog + editor = self.get_current_editor() + if editor: + line, accepted = QInputDialog.getInt(self, 'Go to Line', 'Line:', 1, 1, editor.blockCount()) + if accepted: + self.navigate_to_line(line) + + def show_panel(self, widget): + self.bottom_tabs.show() + self.bottom_tabs.setCurrentWidget(widget) + self.workbench.restore_sizes() + # Build/debug/navigation handlers reveal their output even after Ctrl+J. + + def focus_terminal(self): + self.dock_bottom.show() + self.show_panel(self.terminal) + self.terminal.input_line.setFocus() + def set_active_build_target(self, target): self.active_build_config = target self.lbl_status.setText(f"Active Build Target: {target}") def create_status_bar(self): - status = QStatusBar() - status.setStyleSheet("background-color: #2b2d30; color: #bcbec4; border-top: 1px solid #393b40;") - self.setStatusBar(status) - - cores = os.cpu_count() or "Unavailable" - ram_gb = round(psutil.virtual_memory().total / (1024 ** 3), 1) - - self.lbl_status = QLabel(f"Ready [{self.active_build_config}]") - self.lbl_cursor_pos = QLabel("Ln 1, Col 1 | C") - self.lbl_cursor_pos.setStyleSheet("color: #a1a3ab; margin-right: 15px;") - self.lbl_sys_info = QLabel(f" CPU: {cores} Cores | RAM: {ram_gb} GB (Live OS) ") - self.lbl_sys_info.setStyleSheet("color: #58a6ff; font-weight: bold;") + from medha.status import StatusController + self.status_controller = StatusController(self) - status.addWidget(self.lbl_status, 1) - status.addPermanentWidget(self.lbl_cursor_pos) - status.addPermanentWidget(self.lbl_sys_info) + def confirm_close(self, widget): + if not isinstance(widget, AdvancedCodeEditorWidget) or not widget.is_modified: + return True + name = os.path.basename(widget.file_path) if widget.file_path else 'Untitled' + choice = QMessageBox.question(self, 'Unsaved changes', f'Save changes to {name}?', + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | + QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Save) + if choice == QMessageBox.StandardButton.Cancel: + return False + if choice == QMessageBox.StandardButton.Save: + self.tab_widget.setCurrentWidget(widget) + self.action_save_file() + return not widget.is_modified + return True + # Cancelled Save As must keep the document open just like Cancel does. def close_tab(self, index): - if index >= 0 and self.tab_widget.count() > 1: - self.tab_widget.removeTab(index) - else: - self.create_untitled_tab() + if index < 0: + return + widget = self.tab_widget.widget(index) + if not self.confirm_close(widget): + return + self.tab_widget.removeTab(index) + widget.deleteLater() + if self.tab_widget.count() == 0: + self.show_welcome() def on_tab_changed(self, index): + self.update_cursor_status() editor = self.get_current_editor() if editor and hasattr(editor, 'get_symbol_outline'): symbols = editor.get_symbol_outline() @@ -587,24 +618,54 @@ def update_tab_title(self, editor): base = os.path.basename(editor.file_path) if editor.file_path else "untitled.c" if getattr(editor, 'is_modified', False): base += " •" - self.tab_widget.setTabText(i, base) + self.tab_widget.setTabText(i, base.replace("&", "&&")) break def update_cursor_status(self): - editor = self.get_current_editor() - if editor: - cursor = editor.textCursor() - line = cursor.blockNumber() + 1 - col = cursor.columnNumber() + 1 - lang = getattr(editor, 'lang', 'cpp').upper() - self.lbl_cursor_pos.setText(f"Ln {line}, Col {col} | {lang}") + if hasattr(self, 'status_controller'): + self.status_controller.document_changed() def apply_dark_theme(self): + self.apply_theme(self.workbench.prefs.value('theme', 'dark')) + + def apply_theme(self, name): from PySide6.QtWidgets import QApplication - from medha.theme import DARK_QSS - app_inst = QApplication.instance() - if app_inst: - app_inst.setStyleSheet(DARK_QSS) + from medha.theme import stylesheet, apply_editor + self.theme_name = name if name in ('dark', 'light') else 'dark' + self.workbench.prefs.setValue('theme', self.theme_name) + # Legacy tools supplied local dark styles. Clear those overrides so the + # shared QSS controls the whole workbench in both built-in themes. + from shiboken6 import isValid + for widget in self.findChildren(QWidget): + if isValid(widget) and widget.styleSheet(): + widget.setStyleSheet('') + app = QApplication.instance() + qss = stylesheet(self.theme_name) + self.setStyleSheet(qss) + app.setProperty('medhaTheme', self.theme_name) + for index in range(self.tab_widget.count()): + editor = self.tab_widget.widget(index) + if isinstance(editor, AdvancedCodeEditorWidget): + apply_editor(editor, self.theme_name) + self.install_tab_close(editor) + from medha.workbench import activity_icon + from medha.theme import tokens + for index, button in self.workbench.buttons.items(): + button.setIcon(activity_icon(self.workbench.selector.itemText(index), tokens(self.theme_name)['fg'])) + if hasattr(self, 'lbl_theme'): + self.lbl_theme.setText('Medhā ' + self.theme_name.title()) + + def install_tab_close(self, widget): + from PySide6.QtWidgets import QTabBar, QToolButton + index = self.tab_widget.indexOf(widget) + button = QToolButton() + button.setObjectName('tabClose') + button.setText('×') + button.setAccessibleName('Close tab') + button.setFixedSize(18, 18) + button.clicked.connect(lambda: self.close_tab(self.tab_widget.indexOf(widget))) + self.tab_widget.tabBar().setTabButton(index, QTabBar.ButtonPosition.RightSide, button) + # Resolve the tab index at click time: closing earlier tabs shifts indices. def get_current_editor(self): widget = self.tab_widget.currentWidget() @@ -614,12 +675,18 @@ def get_current_editor(self): def create_untitled_tab(self, title="untitled.c", content=""): editor = AdvancedCodeEditorWidget(file_path=None, parent=self) + editor.lang = editor.detect_language(title) + editor.highlighter.language = editor.lang + editor.highlighter.rules.clear() + editor.highlighter.setup_rules() editor.setPlainText(content) + editor.document().setModified(False) editor.is_modified = False editor.cursorPositionChanged.connect(self.update_cursor_status) editor.textChanged.connect(lambda e=editor: self.update_tab_title(e)) editor.breakpoint_toggled.connect(self.on_breakpoint_toggled) - idx = self.tab_widget.addTab(editor, title) + idx = self.tab_widget.addTab(editor, title.replace("&", "&&")) + self.install_tab_close(editor) self.tab_widget.setCurrentIndex(idx) self.update_cursor_status() @@ -639,13 +706,18 @@ def open_file_path(self, path): filename = os.path.basename(path) editor = AdvancedCodeEditorWidget(file_path=path, parent=self) + editor.encoding_label = 'UTF-8 (replacement)' if '\ufffd' in content else 'UTF-8' editor.setPlainText(content) + editor.document().setModified(False) editor.is_modified = False editor.cursorPositionChanged.connect(self.update_cursor_status) editor.textChanged.connect(lambda e=editor: self.update_tab_title(e)) editor.breakpoint_toggled.connect(self.on_breakpoint_toggled) - idx = self.tab_widget.addTab(editor, filename) + idx = self.tab_widget.addTab(editor, filename.replace("&", "&&")) + self.install_tab_close(editor) self.tab_widget.setCurrentIndex(idx) + recent = [path] + [old for old in settings.get('recent_files', []) if old != path] + settings.set('recent_files', recent[:20]) self.lbl_status.setText(f"Opened file: {path}") self.update_cursor_status() @@ -674,6 +746,8 @@ def action_open_folder(self): self.workspace_path = folder self.explorer.set_workspace(folder) self.terminal.set_working_directory(folder) + self.workspace_ctrl = HybridWorkspaceController(folder) + self.status_controller.refresh() self.lbl_status.setText(f"Opened workspace: {folder}") def action_save_file(self): @@ -687,6 +761,7 @@ def action_save_file(self): try: with open(editor.file_path, 'w', encoding='utf-8') as f: f.write(editor.toPlainText()) + editor.document().setModified(False) editor.is_modified = False self.update_tab_title(editor) self.lbl_status.setText(f"Saved: {editor.file_path}") @@ -706,6 +781,13 @@ def action_save_as_file(self): with open(path, 'w', encoding='utf-8') as f: f.write(editor.toPlainText()) editor.file_path = path + editor.lang = editor.detect_language(path) + editor.highlighter.language = editor.lang + editor.highlighter.rules.clear() + editor.highlighter.setup_rules() + editor.highlighter.rehighlight() + self.update_cursor_status() + editor.document().setModified(False) editor.is_modified = False self.update_tab_title(editor) self.lbl_status.setText(f"Saved: {path}") @@ -725,14 +807,14 @@ def action_run_active_file(self): QMessageBox.warning(self, "Run Error", "Please save scratchpad file to disk before compilation.") return - self.bottom_tabs.setCurrentWidget(self.terminal) + self.show_panel(self.terminal) self.lbl_status.setText(f"Scratchpad Running ({self.active_build_config}) {os.path.basename(editor.file_path)}...") self.runner.run_file(editor.file_path) def action_run_linter(self): editor = self.get_current_editor() if editor and editor.file_path: - self.bottom_tabs.setCurrentWidget(self.safety_panel) + self.show_panel(self.safety_panel) self.linter_engine.analyze_file(editor.file_path) def action_run_disassembly(self): @@ -858,7 +940,7 @@ def action_git_status(self): from medha.git_backend import GitBackend git = GitBackend(self.workspace_path) status_files = git.status() - self.bottom_tabs.setCurrentWidget(self.terminal) + self.show_panel(self.terminal) self.terminal.append_output(f"[Git Status] Modified files: {status_files}\n", color="#79c0ff") def action_git_commit(self): @@ -875,17 +957,18 @@ def action_build_configure(self): from medha.build import BuildSystem bs = BuildSystem(self.workspace_path) code, out = bs.configure() - self.bottom_tabs.setCurrentWidget(self.terminal) + self.show_panel(self.terminal) self.terminal.append_output(f"[CMake Configure Exit {code}]\n{out}\n", color="#79c0ff") def action_build_project(self): from medha.build import BuildSystem bs = BuildSystem(self.workspace_path) code, out, artifact = bs.build() - self.bottom_tabs.setCurrentWidget(self.terminal) + self.show_panel(self.terminal) self.terminal.append_output(f"[Build Exit {code}] Artifact: {artifact}\n{out}\n", color="#79c0ff") def on_runner_output(self, text, stream_type): + self.output_log.insertPlainText(text) color = "#f85149" if stream_type == 'stderr' else None self.terminal.append_output(text, color=color) @@ -894,8 +977,9 @@ def on_runner_finished(self, exit_code, diagnostics): self.turbo_panel.on_finished(exit_code) self._turbo_active = False self.problems.set_diagnostics(diagnostics) + self.status_controller.set_diagnostics(diagnostics) if diagnostics: - self.bottom_tabs.setCurrentWidget(self.problems) + self.show_panel(self.problems) self.lbl_status.setText(f"Build Finished with {len(diagnostics)} diagnostic error(s).") else: self.lbl_status.setText(f"Process Finished with exit code {exit_code}.") @@ -970,7 +1054,7 @@ def action_security_scan(self): self.action_save_file() findings = scan_file(editor.file_path, settings.get("explain_lang", "en")) self.security_panel.set_findings(findings, editor.file_path) - self.bottom_tabs.setCurrentWidget(self.security_panel) + self.show_panel(self.security_panel) def action_toolchains(self): from medha.toolchains_dialog import ToolchainsDialog @@ -1012,6 +1096,19 @@ def show_shortcuts(self): ShortcutsDialog(collect_actions(self.menuBar()), self).exec() def closeEvent(self, event): + for index in range(self.tab_widget.count()): + if not self.confirm_close(self.tab_widget.widget(index)): + event.ignore() + return + self.workbench.save() + # Stop child processes while their receiving widgets still exist. Otherwise + # a late disassembly callback can access a deleted QTextEdit during teardown. + from PySide6.QtCore import QProcess + for process in self.findChildren(QProcess): + process.blockSignals(True) + if process.state() != QProcess.ProcessState.NotRunning: + process.kill() + process.waitForFinished(1000) if hasattr(self, 'terminal') and self.terminal: self.terminal.shutdown() if hasattr(self, 'runner') and self.runner and hasattr(self.runner, 'process'): @@ -1031,7 +1128,6 @@ def show_about_dialog(self): # Kanak's KP orbit logo (docs/assets/logo-mark.svg) is the watermark of every project logo = os.path.join(str(platform_info.app_root()), "docs", "assets", "logo-mark.svg") if os.path.exists(logo): - from PySide6.QtGui import QPixmap box.setIconPixmap(QIcon(logo).pixmap(96, 96)) gcc = toolchains.find("gcc") box.setText( @@ -1045,7 +1141,6 @@ def show_about_dialog(self): def _on_theme_apply_requested(self, colors: dict): """Handle theme apply request from Extensions panel.""" - from medha.extensions.contributions import map_vscode_theme_to_medha from medha.editor_widget import AdvancedCodeEditorWidget # Apply theme colors to all open editors diff --git a/medha/build.py b/medha/build.py index 122a569..3eea9f7 100644 --- a/medha/build.py +++ b/medha/build.py @@ -1,8 +1,6 @@ import os -import re import shutil import subprocess -from pathlib import Path class BuildSystem: def __init__(self, project_dir: str): diff --git a/medha/build_system.py b/medha/build_system.py index 2d66814..28c6511 100644 --- a/medha/build_system.py +++ b/medha/build_system.py @@ -7,7 +7,7 @@ import os import re import shutil -from PySide6.QtCore import QProcess, QObject, Signal, Qt +from PySide6.QtCore import QProcess, QObject, Signal class BuildDiagnostic: diff --git a/medha/compiler/disassembly.py b/medha/compiler/disassembly.py index 529e189..22aebb6 100644 --- a/medha/compiler/disassembly.py +++ b/medha/compiler/disassembly.py @@ -6,9 +6,9 @@ import os import shutil -from PySide6.QtCore import QProcess, QObject, Signal, Qt -from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLabel, QFontDialog, QPushButton, QHBoxLayout -from PySide6.QtGui import QFont, QColor, QTextCursor +from PySide6.QtCore import QProcess +from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLabel, QPushButton, QHBoxLayout +from PySide6.QtGui import QFont class DisassemblyInspectorWidget(QWidget): diff --git a/medha/core/orchestrator.py b/medha/core/orchestrator.py index 5c86f1b..81c5a55 100644 --- a/medha/core/orchestrator.py +++ b/medha/core/orchestrator.py @@ -6,8 +6,7 @@ import os import sys -import psutil -from PySide6.QtCore import QObject, Signal, Qt +from PySide6.QtCore import QObject, Signal class AntiGravityOrchestrator(QObject): diff --git a/medha/debugger_engine.py b/medha/debugger_engine.py index 3dc121a..696db58 100644 --- a/medha/debugger_engine.py +++ b/medha/debugger_engine.py @@ -3,18 +3,14 @@ Extends DebugSession with GUI panels: 1. Breakpoints Panel 2. Call Stack / Backtrace Panel - 3. Variables & Watch Expressions Panel + 3. Variables && Watch Expressions Panel Signals UI updates for editor execution line highlighting and margin breakpoints. """ import os import re -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem, - QHeaderView, QPushButton, QLineEdit, QLabel, QSplitter -) -from PySide6.QtGui import QColor, QFont -from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QPushButton, QLineEdit +from PySide6.QtCore import Signal, QObject from medha.debugger import DebugSession @@ -177,7 +173,7 @@ def __init__(self, engine: GdbDebuggerEngine, parent=None): watch_box.addWidget(btn_eval) var_layout.addLayout(watch_box) - self.tabs.addTab(var_widget, "Variables & Watch") + self.tabs.addTab(var_widget, "Variables && Watch") layout.addWidget(self.tabs) diff --git a/medha/dsa_engine.py b/medha/dsa_engine.py index c9dd0bd..5a2312f 100644 --- a/medha/dsa_engine.py +++ b/medha/dsa_engine.py @@ -7,8 +7,8 @@ QWidget, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QLabel, QPushButton ) -from PySide6.QtGui import QColor, QPen, QBrush, QFont, QPainter, QPolygonF -from PySide6.QtCore import Qt, QPointF +from PySide6.QtGui import QColor, QPen, QBrush, QFont, QPainter +from PySide6.QtCore import Qt class DSANodeData: @@ -99,7 +99,7 @@ def _render_array(self, snapshot): title.setPos(20, 20) for i, node in enumerate(snapshot.nodes): - rect = self.scene.addRect(x, y, 60, 50, QPen(QColor("#30363d"), 2), QBrush(QColor("#161b22"))) + self.scene.addRect(x, y, 60, 50, QPen(QColor('#30363d'), 2), QBrush(QColor('#161b22'))) txt = self.scene.addText(str(node.label), QFont("Monospace", 10, QFont.Bold)) txt.setDefaultTextColor(QColor("#ffffff")) txt.setPos(x + 20, y + 12) @@ -114,7 +114,7 @@ def _render_linked_list(self, snapshot): title.setPos(20, 20) for node in snapshot.nodes: - rect = self.scene.addRect(x, y, 90, 50, QPen(QColor("#238636"), 2), QBrush(QColor("#0d1117"))) + self.scene.addRect(x, y, 90, 50, QPen(QColor('#238636'), 2), QBrush(QColor('#0d1117'))) txt = self.scene.addText(str(node.label), QFont("Monospace", 10, QFont.Bold)) txt.setDefaultTextColor(QColor("#7ee787")) txt.setPos(x + 25, y + 12) diff --git a/medha/dsa_visualizer/dsa_widget.py b/medha/dsa_visualizer/dsa_widget.py index a15028f..ff1fd77 100644 --- a/medha/dsa_visualizer/dsa_widget.py +++ b/medha/dsa_visualizer/dsa_widget.py @@ -4,12 +4,9 @@ and dynamic layout rendering for Linear and Non-Linear Data Structures. """ -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, - QLabel, QPushButton, QComboBox, QSlider -) -from PySide6.QtGui import QColor, QBrush, QFont, QPainter -from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QLabel, QPushButton +from PySide6.QtGui import QColor, QBrush, QPainter +from PySide6.QtCore import Qt from medha.dsa_visualizer.tracers.dsa_tracer import DSASnapshot, UniversalDSATracer from medha.dsa_visualizer.renderers.linear_renderer import LinearDSARenderer from medha.dsa_visualizer.renderers.tree_renderer import TreeDSARenderer diff --git a/medha/dsa_visualizer/renderers/linear_renderer.py b/medha/dsa_visualizer/renderers/linear_renderer.py index 9b31122..b2c2b37 100644 --- a/medha/dsa_visualizer/renderers/linear_renderer.py +++ b/medha/dsa_visualizer/renderers/linear_renderer.py @@ -3,9 +3,7 @@ Renders Arrays, Vectors, Linked Lists, Doubly Linked Lists, Stacks, and Queues using QGraphicsScene. """ -from PySide6.QtWidgets import ( - QGraphicsScene, QGraphicsRectItem, QGraphicsTextItem, QGraphicsLineItem, QGraphicsItemGroup -) +from PySide6.QtWidgets import QGraphicsScene from PySide6.QtGui import QColor, QPen, QBrush, QFont, QPolygonF from PySide6.QtCore import Qt, QPointF @@ -36,7 +34,7 @@ def render_array(scene: QGraphicsScene, snapshot): idx_txt.setPos(x_offset + 15, y_pos - 25) # Element Box - rect = scene.addRect(x_offset, y_pos, 60, 50, QPen(border_color, 2), QBrush(bg_color)) + scene.addRect(x_offset, y_pos, 60, 50, QPen(border_color, 2), QBrush(bg_color)) # Value label inside box val_txt = scene.addText(str(val), QFont("Monospace", 10, QFont.Bold)) @@ -62,7 +60,7 @@ def render_linked_list(scene: QGraphicsScene, snapshot, is_double=False): val = node.get("label", "") # Node Box (Data | Next) - rect = scene.addRect(x_offset, y_pos, 90, 50, QPen(QColor("#238636"), 2), QBrush(QColor("#0d1117"))) + scene.addRect(x_offset, y_pos, 90, 50, QPen(QColor('#238636'), 2), QBrush(QColor('#0d1117'))) txt = scene.addText(f"{val}", QFont("Monospace", 10, QFont.Bold)) txt.setDefaultTextColor(QColor("#7ee787")) txt.setPos(x_offset + 25, y_pos + 12) @@ -84,13 +82,13 @@ def render_linked_list(scene: QGraphicsScene, snapshot, is_double=False): _, _, dx, dy = node_positions[dst] if label == "next": - line = scene.addLine(sx, sy, dx, dy, QPen(QColor("#58a6ff"), 2)) + scene.addLine(sx, sy, dx, dy, QPen(QColor('#58a6ff'), 2)) # Draw Arrow Head arrow = scene.addPolygon(QPolygonF([QPointF(dx, dy), QPointF(dx - 8, dy - 5), QPointF(dx - 8, dy + 5)])) arrow.setBrush(QBrush(QColor("#58a6ff"))) arrow.setPen(Qt.NoPen) elif label == "prev": - line = scene.addLine(dx, dy + 15, sx, sy + 15, QPen(QColor("#d29922"), 1.5, Qt.DashLine)) + scene.addLine(dx, dy + 15, sx, sy + 15, QPen(QColor('#d29922'), 1.5, Qt.DashLine)) # NULL Terminator null_txt = scene.addText("NULL", QFont("Monospace", 9, QFont.Bold)) @@ -114,7 +112,7 @@ def render_stack(scene: QGraphicsScene, snapshot): for i, node in enumerate(snapshot.nodes): val = node.get("label", "") - rect = scene.addRect(x_pos, y_offset, 100, 35, QPen(QColor("#8957e5"), 1.5), QBrush(QColor("#161b22"))) + scene.addRect(x_pos, y_offset, 100, 35, QPen(QColor('#8957e5'), 1.5), QBrush(QColor('#161b22'))) txt = scene.addText(str(val), QFont("Monospace", 10, QFont.Bold)) txt.setDefaultTextColor(QColor("#d2a8ff")) txt.setPos(x_pos + 35, y_offset + 5) diff --git a/medha/dsa_visualizer/renderers/tree_renderer.py b/medha/dsa_visualizer/renderers/tree_renderer.py index 00c8a00..5b044bc 100644 --- a/medha/dsa_visualizer/renderers/tree_renderer.py +++ b/medha/dsa_visualizer/renderers/tree_renderer.py @@ -3,11 +3,8 @@ Calculates automatic hierarchical tree levels and branch line coordinates for BST, AVL, and Red-Black Trees. """ -from PySide6.QtWidgets import ( - QGraphicsScene, QGraphicsEllipseItem, QGraphicsTextItem, QGraphicsLineItem -) +from PySide6.QtWidgets import QGraphicsScene from PySide6.QtGui import QColor, QPen, QBrush, QFont -from PySide6.QtCore import Qt, QPointF class TreeDSARenderer: @@ -85,7 +82,7 @@ def calculate_positions(node_id, x, y, dx): border_color = QColor("#58a6ff") if hl == "active" else QColor("#30363d") # Circle Item - ellipse = scene.addEllipse(x, y, 40, 40, QPen(border_color, 2), QBrush(bg_color)) + scene.addEllipse(x, y, 40, 40, QPen(border_color, 2), QBrush(bg_color)) # Label Text txt = scene.addText(label, QFont("Monospace", 9, QFont.Bold)) diff --git a/medha/dsa_visualizer/tracers/dsa_tracer.py b/medha/dsa_visualizer/tracers/dsa_tracer.py index 7139ac0..8c2c0db 100644 --- a/medha/dsa_visualizer/tracers/dsa_tracer.py +++ b/medha/dsa_visualizer/tracers/dsa_tracer.py @@ -4,9 +4,6 @@ from GDB (C/C++), sys.settrace (Python), and JDWP (Java). """ -import sys -import re -import os from PySide6.QtCore import QObject, Signal @@ -114,7 +111,6 @@ def dfs(node): def parse_gdb_variables(self, variables_list): """Parses GDB variables output into a DSASnapshot for C/C++.""" self.step_count += 1 - arr_elements = [] for name, val in variables_list: # Check for GDB Array representation: {1, 2, 3, 4} diff --git a/medha/editor_widget.py b/medha/editor_widget.py index f19a124..39f393c 100644 --- a/medha/editor_widget.py +++ b/medha/editor_widget.py @@ -10,10 +10,7 @@ import os import re from typing import TYPE_CHECKING -from PySide6.QtWidgets import ( - QPlainTextEdit, QWidget, QTextEdit, QVBoxLayout, QListWidget, QListWidgetItem, - QLabel, QSplitter -) +from PySide6.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QVBoxLayout, QListWidget, QListWidgetItem, QLabel from PySide6.QtGui import QColor, QPainter, QTextFormat, QFont, QTextCursor from PySide6.QtCore import Qt, QRect, QSize, Signal from medha.highlighter import CodeHighlighter @@ -52,6 +49,48 @@ def mousePressEvent(self, event): bottom = top + round(self.code_editor.blockBoundingRect(block).height()) +class Minimap(QWidget): + """A small, clickable overview of the actual document, without a second editor.""" + def __init__(self, editor): + super().__init__(editor) + self.editor = editor + self.setAccessibleName('Document minimap') + editor.textChanged.connect(self.update) + editor.verticalScrollBar().valueChanged.connect(self.update) + + def paintEvent(self, event): + painter = QPainter(self) + colors = self.editor.theme_tokens + painter.fillRect(self.rect(), QColor(colors['panel'])) + count = max(1, self.editor.blockCount()) + step = min(3.0, self.height() / count) + painter.setPen(QColor(colors['muted'])) + block = self.editor.document().firstBlock() + # A bounded overview samples very long documents instead of painting every line. + stride = max(1, count // max(1, self.height())) + while block.isValid(): + text = block.text() + y = int(block.blockNumber() * step) + start = len(text) - len(text.lstrip()) + painter.drawLine(min(75, start + 4), y, min(76, len(text) + 4), y) + block = self.editor.document().findBlockByNumber(block.blockNumber() + stride) + top = self.editor.firstVisibleBlock().blockNumber() * step + visible = self.editor.viewport().height() / max(1, self.editor.fontMetrics().height()) + highlight = QColor(colors['accent']) + highlight.setAlpha(45) + painter.fillRect(0, int(top), self.width(), max(4, int(visible * step)), highlight) + + def mousePressEvent(self, event): + count = max(1, self.editor.blockCount()) + step = min(3.0, self.height() / count) + line = min(count - 1, max(0, int(event.position().y() / max(.001, step)))) + cursor = self.editor.textCursor() + cursor.setPosition(self.editor.document().findBlockByNumber(line).position()) + self.editor.setTextCursor(cursor) + self.editor.centerCursor() + self.editor.setFocus() + + class AdvancedCodeEditorWidget(QPlainTextEdit): """ Production-ready C/C++ Code Editor with Margin Breakpoints, @@ -65,6 +104,7 @@ def __init__(self, file_path=None, parent=None): self.file_path = file_path self.is_modified = False self.margin_area = MarginArea(self) + self.minimap = Minimap(self) self.breakpoints = set() self.execution_line = None self.snippet_tabstops = [] # List of (tabstop_num, start_pos, end_pos) @@ -79,26 +119,21 @@ def __init__(self, file_path=None, parent=None): self.setFont(font) self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.setStyleSheet(""" - QPlainTextEdit { - background-color: #0d1117; - color: #c9d1d9; - border: none; - selection-background-color: #1f6feb; - selection-color: #ffffff; - } - """) + # Appearance is inherited from the shared workbench QSS. self.blockCountChanged.connect(self.update_margin_area_width) self.updateRequest.connect(self.update_margin_area) self.cursorPositionChanged.connect(self.highlight_current_line) self.textChanged.connect(self.on_text_changed) + from PySide6.QtWidgets import QApplication + from medha.theme import apply_editor + apply_editor(self, QApplication.instance().property('medhaTheme') or 'dark') self.update_margin_area_width(0) self.highlight_current_line() def on_text_changed(self): - self.is_modified = True + self.is_modified = self.document().isModified() def detect_language(self, path): if not path: @@ -120,7 +155,7 @@ def margin_area_width(self): return 30 + self.fontMetrics().horizontalAdvance('9') * digits def update_margin_area_width(self, _): - self.setViewportMargins(self.margin_area_width(), 0, 0, 0) + self.setViewportMargins(self.margin_area_width(), 0, 80, 0) def update_margin_area(self, rect, dy): if dy: @@ -134,6 +169,7 @@ def resizeEvent(self, event): super().resizeEvent(event) cr = self.contentsRect() self.margin_area.setGeometry(QRect(cr.left(), cr.top(), self.margin_area_width(), cr.height())) + self.minimap.setGeometry(cr.right() - 79, cr.top(), 80, cr.height()) def toggle_breakpoint(self, line_num): if line_num in self.breakpoints: @@ -163,7 +199,7 @@ def clear_execution_line(self): def marginAreaPaintEvent(self, event): painter = QPainter(self.margin_area) - painter.fillRect(event.rect(), QColor("#161b22")) + painter.fillRect(event.rect(), QColor(self.theme_tokens["line"])) block = self.firstVisibleBlock() block_number = block.blockNumber() @@ -186,7 +222,7 @@ def marginAreaPaintEvent(self, event): painter.drawText(16, top, 12, self.fontMetrics().height(), Qt.AlignLeft, "▶") # 3. Draw Line Number - painter.setPen(QColor("#a1a3ab") if block_number == curr_block_num else QColor("#4b5059")) + painter.setPen(QColor(self.theme_tokens["fg"] if block_number == curr_block_num else self.theme_tokens["muted"])) painter.setFont(self.font()) painter.drawText(0, top, self.margin_area.width() - 5, self.fontMetrics().height(), Qt.AlignRight, str(line_num)) @@ -200,7 +236,7 @@ def highlight_current_line(self): if not self.isReadOnly(): # Current cursor line (#21262d) selection = QTextEdit.ExtraSelection() - selection.format.setBackground(QColor("#161b22")) + selection.format.setBackground(QColor(self.theme_tokens["line"])) selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True) selection.cursor = self.textCursor() selection.cursor.clearSelection() @@ -210,7 +246,7 @@ def highlight_current_line(self): if self.execution_line and self.execution_line > 0: exec_block = self.document().findBlockByLineNumber(self.execution_line - 1) exec_sel = QTextEdit.ExtraSelection() - exec_sel.format.setBackground(QColor("#3d3200")) + exec_sel.format.setBackground(QColor(self.theme_tokens["execution"])) exec_sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True) c = QTextCursor(self.document()) c.setPosition(exec_block.position()) @@ -413,19 +449,11 @@ def __init__(self, parent=None): layout.setContentsMargins(0, 0, 0, 0) header = QLabel(" SYMBOL OUTLINE") - header.setStyleSheet("background-color: #161b22; color: #8b949e; font-weight: bold; font-size: 11px; padding: 4px;") + # Appearance is inherited from the shared workbench QSS. layout.addWidget(header) self.list_widget = QListWidget() - self.list_widget.setStyleSheet(""" - QListWidget { - background-color: #0d1117; - color: #c9d1d9; - border: none; - } - QListWidget::item:hover { background-color: #161b22; } - QListWidget::item:selected { background-color: #1f6feb; color: white; } - """) + # Appearance is inherited from the shared workbench QSS. self.list_widget.itemClicked.connect(self.on_item_clicked) layout.addWidget(self.list_widget) diff --git a/medha/explorer.py b/medha/explorer.py index 80912c3..e161aa4 100644 --- a/medha/explorer.py +++ b/medha/explorer.py @@ -1,6 +1,6 @@ import os -from PySide6.QtWidgets import QWidget, QVBoxLayout, QTreeView, QLabel, QHeaderView, QFileSystemModel -from PySide6.QtCore import QDir, Signal, Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout, QTreeView, QLabel, QFileSystemModel +from PySide6.QtCore import Signal class ProjectExplorerWidget(QWidget): file_selected = Signal(str) diff --git a/medha/extensions/__init__.py b/medha/extensions/__init__.py index 29c5683..dd8e3c4 100644 --- a/medha/extensions/__init__.py +++ b/medha/extensions/__init__.py @@ -93,4 +93,6 @@ "apply_theme_to_editor", "get_all_available_themes", "get_all_available_snippets", -] \ No newline at end of file +] +# Public re-exports remain available to extension consumers. +__all__ += ['load_installed_manifest', 'load_theme_file', 'load_snippets_file', 'load_grammar_file', 'classify_extension', 'get_supported_contributions'] diff --git a/medha/extensions/contributions.py b/medha/extensions/contributions.py index eecf42d..2cd20bd 100644 --- a/medha/extensions/contributions.py +++ b/medha/extensions/contributions.py @@ -11,9 +11,8 @@ from __future__ import annotations import json import logging -import os from pathlib import Path -from typing import Dict, List, Optional, Any, Union +from typing import Dict, List, Optional, Any from medha.extensions.models import ( ExtensionManifest, @@ -695,24 +694,3 @@ def load_extension_contributions(installed: InstalledExtension) -> Dict[str, Any return contributions -def load_extension_contributions(installed: InstalledExtension) -> Dict[str, Any]: - """ - Load all declarative contributions from an installed extension. - - Returns: - Dict with keys: themes, snippets, grammars - """ - if installed.classification == ExtensionClassification.UNSUPPORTED: - logger.info(f"Extension {installed.unique_id} is UNSUPPORTED (JS only)") - return {"themes": [], "snippets": {}, "grammars": []} - - contributions = { - "themes": load_all_themes(installed), - "snippets": load_all_snippets(installed), - "grammars": load_all_grammars(installed), - } - - if installed.classification == ExtensionClassification.PARTIAL: - logger.info(f"Extension {installed.unique_id} is PARTIAL (has JS code + declarative)") - - return contributions \ No newline at end of file diff --git a/medha/extensions/installer.py b/medha/extensions/installer.py index 4949fdf..8cf4377 100644 --- a/medha/extensions/installer.py +++ b/medha/extensions/installer.py @@ -9,20 +9,14 @@ import hashlib import json import logging -import os import shutil import tempfile import time import zipfile -from dataclasses import asdict from pathlib import Path -from typing import List, Optional, Tuple +from typing import List, Optional -from medha.extensions.models import ( - ExtensionManifest, - InstalledExtension, - ExtensionClassification, -) +from medha.extensions.models import ExtensionManifest, InstalledExtension logger = logging.getLogger(__name__) diff --git a/medha/extensions/models.py b/medha/extensions/models.py index e535b34..88480f6 100644 --- a/medha/extensions/models.py +++ b/medha/extensions/models.py @@ -8,7 +8,6 @@ from dataclasses import dataclass, field from typing import Optional, List, Dict, Any from enum import Enum -import json class ExtensionClassification(Enum): diff --git a/medha/extensions/registry.py b/medha/extensions/registry.py index 98d3cb8..f3c3ff8 100644 --- a/medha/extensions/registry.py +++ b/medha/extensions/registry.py @@ -6,18 +6,17 @@ All network calls have 10 second timeout. """ from __future__ import annotations -import json import hashlib import logging import os import tempfile from dataclasses import dataclass -from typing import Optional, List, Tuple -from urllib.parse import urlencode, urljoin +from typing import Optional, List +from urllib.parse import urlencode import requests -from medha.extensions.models import ExtensionInfo, ExtensionDetail, ExtensionFileRef +from medha.extensions.models import ExtensionInfo, ExtensionDetail logger = logging.getLogger(__name__) diff --git a/medha/extensions/ui/panel.py b/medha/extensions/ui/panel.py index d856361..3b7ae15 100644 --- a/medha/extensions/ui/panel.py +++ b/medha/extensions/ui/panel.py @@ -6,18 +6,11 @@ """ from __future__ import annotations import logging -import os -from pathlib import Path -from typing import Optional, Dict, List - -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QListWidget, QListWidgetItem, - QLabel, QLineEdit, QPushButton, QScrollArea, QFrame, QSplitter, - QTextBrowser, QMessageBox, QMenu, QStyle -) -from PySide6.QtCore import Qt, Signal, QSize, QTimer -from PySide6.QtGui import QIcon, QPixmap, QFont, QColor, QDesktopServices -from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from typing import Optional, List + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QListWidget, QListWidgetItem, QLabel, QLineEdit, QPushButton, QFrame, QSplitter, QTextBrowser, QMessageBox +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QDesktopServices from medha.extensions.models import ( ExtensionInfo, @@ -25,18 +18,7 @@ ExtensionClassification, InstalledExtension, ) -from medha.extensions.ui.worker import ( - SearchWorker, - DetailWorker, - DownloadWorker, - InstallWorker, - UninstallWorker, - ListInstalledWorker, - ToggleEnabledWorker, - LoadThemesWorker, - LoadSnippetsWorker, - ApplyThemeWorker, -) +from medha.extensions.ui.worker import SearchWorker, DetailWorker, DownloadWorker, InstallWorker, UninstallWorker, ListInstalledWorker, ToggleEnabledWorker, ApplyThemeWorker logger = logging.getLogger(__name__) @@ -67,7 +49,6 @@ def __init__(self, ext: ExtensionInfo, is_installed: bool = False): def update_display(self): """Update the item's display text.""" status = " [Installed]" if self.is_installed else "" - classification = self.ext.files.download is not None # placeholder # Classification will be set from detail self.setText( f"{self.ext.display_name}{status}\n" @@ -77,8 +58,6 @@ def update_display(self): def set_classification(self, classification: ExtensionClassification): """Set classification badge.""" self.classification = classification - color = CLASSIFICATION_COLORS.get(classification, "#8b949e") - tooltip = CLASSIFICATION_TOOLTIPS.get(classification, "") # We'll render the badge in the delegate @@ -680,15 +659,7 @@ def _on_search_results(self, result): def _fetch_classifications(self, extensions: List[ExtensionInfo]): """Fetch details for first few extensions to get classification.""" - # For now, just use a simple heuristic - for i in range(self.results_list.count()): - item = self.results_list.item(i) - widget = self.results_list.itemWidget(item) - ext = item.data(Qt.UserRole) - if widget: - badge = widget.findChild(QLabel, "") - # Simple heuristic: if it has themes/snippets it's SUPPORTED - # We'll fetch detail on selection instead + # Details and classification are fetched on selection. No eager requests. def _on_search_finished(self): self.search_btn.setEnabled(True) diff --git a/medha/extensions/ui/worker.py b/medha/extensions/ui/worker.py index f0b7010..37fec0f 100644 --- a/medha/extensions/ui/worker.py +++ b/medha/extensions/ui/worker.py @@ -6,33 +6,12 @@ """ from __future__ import annotations import logging -from typing import Optional, List from PySide6.QtCore import QThread, Signal -from medha.extensions.registry import ( - OpenVSXRegistryClient, - SearchResult, - ExtensionDetail, - ExtensionInfo, - NetworkError, - NotFoundError, - DownloadError, -) -from medha.extensions.models import ExtensionClassification -from medha.extensions.installer import ( - install_vsix, - uninstall_extension, - list_installed_extensions, - get_enabled_extensions, - set_extension_enabled, -) -from medha.extensions.contributions import ( - load_all_themes, - load_all_snippets, - get_all_available_themes, - get_all_available_snippets, -) +from medha.extensions.registry import OpenVSXRegistryClient +from medha.extensions.installer import install_vsix, uninstall_extension, list_installed_extensions, set_extension_enabled +from medha.extensions.contributions import get_all_available_themes, get_all_available_snippets logger = logging.getLogger(__name__) diff --git a/medha/git_backend.py b/medha/git_backend.py index 9971c4e..f42fe7b 100644 --- a/medha/git_backend.py +++ b/medha/git_backend.py @@ -1,6 +1,5 @@ import os import subprocess -from pathlib import Path class GitBackend: def __init__(self, repo_path: str): diff --git a/medha/lsp.py b/medha/lsp.py index bb68c98..05596c6 100644 --- a/medha/lsp.py +++ b/medha/lsp.py @@ -1,5 +1,4 @@ import os -import sys import json import time import shutil diff --git a/medha/navigation.py b/medha/navigation.py new file mode 100644 index 0000000..73f0d2e --- /dev/null +++ b/medha/navigation.py @@ -0,0 +1,155 @@ +"""Local, incremental navigation: no indexing service or startup network traffic.""" +import os +from pathlib import Path +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem, QLabel, QPushButton +from medha.command_palette import CommandPalette + + +IGNORED = {'.git', '.venv', 'venv', 'node_modules', '__pycache__', '.medha_build', 'build', 'dist'} + + +def workspace_files(root): + for folder, dirs, files in os.walk(root, followlinks=False): + dirs[:] = sorted(d for d in dirs if d not in IGNORED and not d.startswith('.')) + for name in sorted(files): + path = Path(folder, name) + if not path.is_symlink(): + yield path + # Never follow directory symlinks or scan dependency/build trees. + + +class QuickOpen(CommandPalette): + def __init__(self, root, opener, parent=None): + self.paths = [] + self.root = Path(root) + self.opener = opener + super().__init__([], parent) + self.setWindowTitle('Quick Open') + self.search.setPlaceholderText('Search workspace files…') + self.pending = workspace_files(root) + self.timer = QTimer(self) + self.timer.timeout.connect(self.index_batch) + self.finished.connect(self.timer.stop) + self.timer.start(0) + # Small batches keep typing and Escape responsive on large projects. + + def index_batch(self): + for _ in range(100): + path = next(self.pending, None) + if path is None: + self.timer.stop() + break + self.paths.append(path) + self.refilter(self.search.text()) + + def refilter(self, text): + self.list.clear() + words = text.casefold().split() + for path in self.paths: + label = str(path.relative_to(self.root)) + if all(word in label.casefold() for word in words): + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, str(path)) + self.list.addItem(item) + if self.list.count() == 200: + break + if self.list.count(): + self.list.setCurrentRow(0) + + def run_selected(self): + item = self.list.currentItem() + if item: + path = item.data(Qt.ItemDataRole.UserRole) + self.accept() + self.opener(path) + + +class SearchView(QWidget): + def __init__(self, window): + super().__init__() + self.window = window + layout = QVBoxLayout(self) + self.query = QLineEdit() + self.query.setPlaceholderText('Search text in workspace · Enter') + self.query.setAccessibleName('Workspace search') + self.query.returnPressed.connect(self.search) + self.info = QLabel('Search local text files (up to 1 MB each).') + self.info.setWordWrap(True) + self.results = QListWidget() + self.results.itemActivated.connect(self.open_result) + layout.addWidget(self.query) + layout.addWidget(self.info) + layout.addWidget(self.results) + self.timer = QTimer(self) + self.timer.timeout.connect(self.scan_next) + + def search(self): + self.timer.stop() + self.results.clear() + self.needle = self.query.text().casefold() + if not self.needle: + return + self.pending = workspace_files(self.window.workspace_path) + self.info.setText('Searching…') + self.timer.start(0) + + def scan_next(self): + path = next(self.pending, None) + if path is None or self.results.count() >= 1000: + self.timer.stop() + self.info.setText(f'{self.results.count()} matches' + (' (limit reached)' if path else '')) + return + try: + if path.stat().st_size > 1_000_000: + return + content = path.read_text(encoding='utf-8') + if '\0' in content: + return + except (OSError, UnicodeError): + return + for number, line in enumerate(content.splitlines(), 1): + if self.needle in line.casefold(): + item = QListWidgetItem(f'{path.relative_to(self.window.workspace_path)}:{number} {line.strip()[:120]}') + item.setData(Qt.ItemDataRole.UserRole, (str(path), number)) + self.results.addItem(item) + if self.results.count() >= 1000: + break + # Only plain text is opened; binary and oversized files are skipped explicitly. + + def open_result(self, item): + path, line = item.data(Qt.ItemDataRole.UserRole) + self.window.navigate_to_problem(path, line) + + +class GitView(QWidget): + def __init__(self, window): + super().__init__() + self.window = window + layout = QVBoxLayout(self) + self.info = QLabel('Source Control') + self.info.setWordWrap(True) + self.files = QListWidget() + refresh = QPushButton('Refresh Git status') + refresh.clicked.connect(self.refresh) + commit = QPushButton('Stage and commit…') + commit.clicked.connect(window.action_git_commit) + layout.addWidget(self.info) + layout.addWidget(refresh) + layout.addWidget(self.files) + layout.addWidget(commit) + + def refresh(self): + from medha.git_backend import GitBackend + backend = GitBackend(self.window.workspace_path) + try: + result = backend._run_git(['status', '--short']) + self.files.clear() + if result.returncode: + self.info.setText('This folder is not a Git repository.') + else: + self.info.setText('Working tree changes') + self.files.addItems(result.stdout.splitlines() or ['No changes']) + except OSError as error: + self.info.setText(f'Git unavailable: {error}') + # Refresh is user initiated; entering the IDE does not invoke remote Git. diff --git a/medha/project_manager.py b/medha/project_manager.py index 908988f..8b2ae7b 100644 --- a/medha/project_manager.py +++ b/medha/project_manager.py @@ -7,11 +7,8 @@ import os import json -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTreeView, QLabel, QPushButton, - QHeaderView, QMenu, QInputDialog, QFileDialog, QMessageBox -) -from PySide6.QtGui import QStandardItemModel, QStandardItem, QIcon, QColor, QFont +from PySide6.QtWidgets import QWidget, QVBoxLayout, QTreeView, QLabel, QMenu, QInputDialog, QFileDialog +from PySide6.QtGui import QStandardItemModel, QStandardItem, QColor, QFont from PySide6.QtCore import Qt, Signal, QObject diff --git a/medha/smoke_gui.py b/medha/smoke_gui.py index c54c86e..6a817c7 100644 --- a/medha/smoke_gui.py +++ b/medha/smoke_gui.py @@ -19,7 +19,7 @@ def verify(): try: assert window.isVisible(), "Main window is not visible" assert window.tab_widget.count() > 0, "Welcome editor did not open" - assert Path(window.workspace_path, "main.c").is_file(), "Scratchpad missing" + assert window.tab_widget.tabText(0) == "Welcome", "Welcome missing" assert window.grab().save(str(result.with_suffix(".png"))), "Screenshot failed" result.write_text(json.dumps({"status": "PASS", "workspace": window.workspace_path, "title": window.windowTitle()}), encoding="utf-8") diff --git a/medha/status.py b/medha/status.py new file mode 100644 index 0000000..fa5f022 --- /dev/null +++ b/medha/status.py @@ -0,0 +1,80 @@ +"""Status values come from the active document, compiler discovery and local Git.""" +import os +import psutil +from PySide6.QtCore import QObject, QProcess, QTimer +from PySide6.QtWidgets import QStatusBar, QLabel +from PySide6.QtGui import QAction +from medha import toolchains + + +class StatusController(QObject): + def __init__(self, window): + super().__init__(window) + self.window = window + bar = QStatusBar() + window.setStatusBar(bar) + self.branch = QLabel('Git: checking…') + self.diagnostics = QLabel('No build diagnostics') + window.lbl_status = QLabel('Ready') + window.lbl_cursor_pos = QLabel('No active file') + self.compiler = QLabel('Compiler: —') + window.lbl_theme = QLabel('Medhā Dark') + window.lbl_sys_info = QLabel() + for label in [self.branch, self.diagnostics]: + bar.addWidget(label) + bar.addWidget(window.lbl_status, 1) + for label in [window.lbl_cursor_pos, self.compiler, window.lbl_theme, window.lbl_sys_info]: + bar.addPermanentWidget(label) + self.monitor = QAction('Show CPU and RAM', window) + self.monitor.setCheckable(True) + self.monitor.setChecked(window.workbench.prefs.value('system_monitor', False, type=bool)) + self.monitor.toggled.connect(self.toggle_monitor) + window.menuBar().actions()[3].menu().addAction(self.monitor) + self.toggle_monitor(self.monitor.isChecked()) + self.git = QProcess(self) + self.git.finished.connect(self.branch_finished) + self.git.errorOccurred.connect(lambda _: self.branch.setText('Git unavailable')) + self.timer = QTimer(self) + self.timer.timeout.connect(self.refresh) + self.timer.start(5000) + self.refresh() + # Git is a short, local asynchronous command; no fetch or network is involved. + + def toggle_monitor(self, enabled): + self.window.lbl_sys_info.setVisible(enabled) + self.window.workbench.prefs.setValue('system_monitor', enabled) + + def refresh(self): + if self.git.state() == QProcess.ProcessState.NotRunning: + self.git.setWorkingDirectory(self.window.workspace_path) + self.git.start('git', ['rev-parse', '--abbrev-ref', 'HEAD']) + if self.monitor.isChecked(): + self.window.lbl_sys_info.setText(f'CPU {psutil.cpu_percent():.0f}% · RAM {psutil.virtual_memory().percent:.0f}%') + self.document_changed() + + def branch_finished(self, code, status): + text = bytes(self.git.readAllStandardOutput()).decode('utf-8', 'replace').strip() + self.branch.setText(('Branch: ' + text) if code == 0 and text else 'No Git repository') + + def document_changed(self): + window = self.window + editor = window.get_current_editor() + name = os.path.basename(editor.file_path) if editor and editor.file_path else ('Untitled' if editor else 'Welcome') + window.setWindowTitle(f'Medhā · {name} · {os.path.basename(window.workspace_path)}') + if not editor: + window.lbl_cursor_pos.setText('No active file') + self.compiler.setText('Compiler: —') + return + cursor = editor.textCursor() + encoding = getattr(editor, 'encoding_label', 'UTF-8') + window.lbl_cursor_pos.setText(f'Ln {cursor.blockNumber()+1}, Col {cursor.columnNumber()+1} · {editor.lang.upper()} · {encoding}') + tool_name = {'c': 'gcc', 'cpp': 'g++', 'java': 'javac', 'python': 'python'}.get(editor.lang) + tool = toolchains.find(tool_name) if tool_name else None + self.compiler.setText(f'{tool_name}: ' + ('ready' if tool.found else 'not found') if tool else 'Compiler: —') + self.compiler.setToolTip(tool.path or '' if tool else '') + # Discovery respects bundled/user-configured compilers, not just PATH. + + def set_diagnostics(self, diagnostics): + errors = sum(d.severity == 'error' for d in diagnostics) + warnings = sum(d.severity == 'warning' for d in diagnostics) + self.diagnostics.setText(f'{errors} errors · {warnings} warnings') diff --git a/medha/terminal.py b/medha/terminal.py index 1e566a6..ea8d84a 100644 --- a/medha/terminal.py +++ b/medha/terminal.py @@ -18,7 +18,7 @@ import os from PySide6.QtCore import QProcess, Signal -from PySide6.QtGui import QColor, QFont, QFontDatabase, QTextCursor +from PySide6.QtGui import QColor, QFontDatabase, QTextCursor from PySide6.QtWidgets import QLineEdit, QTextEdit, QVBoxLayout, QWidget from medha import platform_info as P diff --git a/medha/theme.py b/medha/theme.py index 6f47507..99bca8e 100644 --- a/medha/theme.py +++ b/medha/theme.py @@ -1,106 +1,38 @@ -DARK_QSS = """ -QMainWindow, QWidget { - background-color: #1e1f22; - color: #bcbec4; - font-family: "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", monospace; -} +"""Built-in color tokens live here; both themes render the same workbench.qss.""" +from pathlib import Path +from string import Template -QMainWindow::separator { - background-color: #393b40; - width: 1px; - height: 1px; -} +DARK = dict(bg='#1f1f1f', panel='#181818', field='#313131', fg='#cccccc', muted='#9d9d9d', + border='#2b2b2b', accent='#0078d4', selection='#264f78', line='#282828', + keyword='#c586c0', string='#ce9178', comment='#6a9955', number='#b5cea8', + type='#4ec9b0', function='#dcdcaa', execution='#514400') +LIGHT = dict(bg='#ffffff', panel='#f3f3f3', field='#ffffff', fg='#202020', muted='#606060', + border='#dddddd', accent='#005fb8', selection='#cce8ff', line='#f1f1f1', + keyword='#af00db', string='#a31515', comment='#008000', number='#098658', + type='#267f99', function='#795e26', execution='#fff3b0') -QMenuBar { - background-color: #2b2d30; - color: #bcbec4; - border-bottom: 1px solid #393b40; - padding: 2px; -} -QMenuBar::item:selected { - background-color: #2e436e; - color: #ffffff; -} +def tokens(name='dark'): + return dict(LIGHT if name == 'light' else DARK) -QMenu { - background-color: #2b2d30; - color: #bcbec4; - border: 1px solid #393b40; -} -QMenu::item:selected { - background-color: #2e436e; - color: #ffffff; -} +def stylesheet(name='dark'): + return Template(Path(__file__).with_name('workbench.qss').read_text()).substitute(tokens(name)) -QStatusBar { - background-color: #2b2d30; - color: #bcbec4; - border-top: 1px solid #393b40; -} -QDockWidget { - color: #bcbec4; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - border: 1px solid #393b40; -} +DARK_QSS = stylesheet('dark') # Existing imports continue to resolve. -QDockWidget::title { - background-color: #2b2d30; - padding: 6px; - border-bottom: 1px solid #393b40; -} -QPlainTextEdit, QTextEdit, QLineEdit { - background-color: #1e1f22; - color: #bcbec4; - border: 1px solid #393b40; - font-family: "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", monospace; - font-size: 13px; - selection-background-color: #214283; -} - -QTreeView, QListView, QTableWidget { - background-color: #1e1f22; - color: #bcbec4; - border: 1px solid #393b40; - selection-background-color: #214283; -} - -QTreeView::item:hover, QListView::item:hover { - background-color: #2e436e; -} - -QPushButton { - background-color: #365880; - color: #ffffff; - border: none; - border-radius: 3px; - padding: 5px 12px; - font-family: "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", monospace; -} - -QPushButton:hover { - background-color: #2e436e; -} - -QTabBar::tab { - background-color: #2b2d30; - color: #bcbec4; - border: 1px solid #393b40; - padding: 6px 12px; -} - -QTabBar::tab:selected { - background-color: #1e1f22; - color: #ffffff; - border-bottom: 2px solid #365880; -} - -QSplitter::handle { - background-color: #393b40; -} -""" +def apply_editor(editor, name): + from PySide6.QtGui import QFont, QFontDatabase + colors = tokens(name) + available = set(QFontDatabase.families()) + family = next((f for f in ['JetBrains Mono', 'Cascadia Code', 'DejaVu Sans Mono'] if f in available), 'monospace') + editor.setFont(QFont(family, 11)) + editor.theme_tokens = colors + editor.setStyleSheet('') + editor.highlighter.set_theme_formats({key: {'foreground': colors[key]} + for key in ['keyword', 'string', 'comment', 'number', 'type', 'function']}) + editor.highlight_current_line() + editor.viewport().update() + # Painters (gutter/minimap) and syntax formats use the same theme as Qt widgets. diff --git a/medha/visualizers/memory_view.py b/medha/visualizers/memory_view.py index bbae39d..0d25328 100644 --- a/medha/visualizers/memory_view.py +++ b/medha/visualizers/memory_view.py @@ -6,12 +6,9 @@ 3. Pointer Arrows linking pointers to target memory addresses """ -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, - QGraphicsRectItem, QGraphicsTextItem, QGraphicsLineItem, QLabel, QPushButton -) +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QLabel, QPushButton from PySide6.QtGui import QColor, QPen, QBrush, QFont, QPainter -from PySide6.QtCore import Qt, QPointF +from PySide6.QtCore import Qt class MemoryVisualizerWidget(QWidget): @@ -74,7 +71,7 @@ def update_memory_map(self, stack_vars, heap_allocations): y_offset = 40 stack_boxes = {} for name, val, addr in stack_vars: - rect = self.scene.addRect(20, y_offset, 220, 40, QPen(QColor("#1f6feb"), 1.5), QBrush(QColor("#161b22"))) + self.scene.addRect(20, y_offset, 220, 40, QPen(QColor('#1f6feb'), 1.5), QBrush(QColor('#161b22'))) txt = self.scene.addText(f"{name}: {val}\nAddr: {addr}", QFont("Monospace", 8)) txt.setDefaultTextColor(QColor("#c9d1d9")) txt.setPos(25, y_offset + 2) @@ -88,7 +85,7 @@ def update_memory_map(self, stack_vars, heap_allocations): hy_offset = 40 for ptr_name, heap_addr, size in heap_allocations: - rect = self.scene.addRect(320, hy_offset, 220, 45, QPen(QColor("#238636"), 1.5), QBrush(QColor("#091d14"))) + self.scene.addRect(320, hy_offset, 220, 45, QPen(QColor('#238636'), 1.5), QBrush(QColor('#091d14'))) txt = self.scene.addText(f"Heap Chunk @ {heap_addr}\nSize: {size} bytes (via {ptr_name})", QFont("Monospace", 8)) txt.setDefaultTextColor(QColor("#7ee787")) txt.setPos(325, hy_offset + 2) @@ -96,7 +93,7 @@ def update_memory_map(self, stack_vars, heap_allocations): # Draw Pointer Arrow connecting Stack Pointer to Heap Chunk if ptr_name in stack_boxes: sx, sy = stack_boxes[ptr_name] - line = self.scene.addLine(sx, sy, 320, hy_offset + 22, QPen(QColor("#f2cc60"), 2, Qt.DashLine)) + self.scene.addLine(sx, sy, 320, hy_offset + 22, QPen(QColor('#f2cc60'), 2, Qt.DashLine)) hy_offset += 55 def render_sample_memory(self): diff --git a/medha/welcome.py b/medha/welcome.py new file mode 100644 index 0000000..d24180f --- /dev/null +++ b/medha/welcome.py @@ -0,0 +1,57 @@ +"""Welcome actions use existing handlers; merely launching never creates a file.""" +from pathlib import Path +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from medha import settings + + +class WelcomePage(QWidget): + def __init__(self, window): + super().__init__() + self.window = window + layout = QVBoxLayout(self) + layout.setContentsMargins(64, 40, 64, 40) + layout.setSpacing(6) + title = QLabel('Medhā') + title.setObjectName('WelcomeTitle') + subtitle = QLabel('Build something. Understand how it works.') + subtitle.setObjectName('WelcomeSubtitle') + subtitle.setWordWrap(True) + layout.addWidget(title) + layout.addWidget(subtitle) + layout.addSpacing(24) + + def link(text, callback): + button = QPushButton(text) + button.setObjectName('welcomeLink') + button.clicked.connect(callback) + layout.addWidget(button) + + link('New C file', lambda: window.create_untitled_tab('untitled.c', '#include \n\nint main(void) {\n return 0;\n}\n')) + link('Open folder…', window.action_open_folder) + link('Open file…', window.action_open_file) + link('Turbo C examples', window.show_turbo_examples) + link('Learn DSA visually', window.dock_dsa.show) + layout.addSpacing(20) + layout.addWidget(QLabel('RECENT')) + recent_box = QWidget() + self.recent_layout = QVBoxLayout(recent_box) + self.recent_layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(recent_box) + self.refresh_recent() + layout.addStretch() + hint = QLabel('Ctrl+P Open a file · Ctrl+Shift+P Find any command') + hint.setWordWrap(True) + layout.addWidget(hint) + + def refresh_recent(self): + while self.recent_layout.count(): + self.recent_layout.takeAt(0).widget().deleteLater() + recent = [path for path in settings.get('recent_files', []) if Path(path).is_file()] + for path in recent[:6]: + button = QPushButton(Path(path).name + ' · ' + str(Path(path).parent)) + button.setObjectName('welcomeLink') + button.clicked.connect(lambda _=False, p=path: self.window.open_file_path(p)) + self.recent_layout.addWidget(button) + if not recent: + self.recent_layout.addWidget(QLabel('Files you open will appear here.')) + # Refresh on re-entry so Recent reflects files opened during this session. diff --git a/medha/workbench.py b/medha/workbench.py new file mode 100644 index 0000000..db5a870 --- /dev/null +++ b/medha/workbench.py @@ -0,0 +1,269 @@ +"""The workbench owns placement; existing tools continue to own their behavior. + +WHY: docking every tool on the right imposes all their minimum sizes on the +editor. Scrollable sidebar pages and one bottom panel keep those tools reachable +without stealing the editing surface. Legacy dock actions route to their new home. +""" +from PySide6.QtCore import Qt, QSettings, QTimer, QSize +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QDockWidget, QSplitter, QStackedWidget, + QScrollArea, QToolButton, QStyle, QComboBox, QSizePolicy, QTextEdit, QLineEdit, +) + + +def activity_icon(title, color): + from PySide6.QtSvg import QSvgRenderer + from PySide6.QtGui import QPixmap, QPainter, QIcon + paths = { + 'Explorer': '', + 'Search': '', + 'Run and Debug': '', + 'Git': '', + 'Extensions': '', + 'DSA': '', + 'AI': '', + 'Outline': '', + } + svg = ('' + f'' + + paths.get(title, paths['Explorer']) + '') + image = QPixmap(24, 24) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + QSvgRenderer(svg.encode()).render(painter) + painter.end() + return QIcon(image) + # Neutral line icons remain legible in both themes, unlike platform-colored icons. + + +class ToolDock(QDockWidget): + """Compatibility bridge for the existing show/raise/toggle call sites.""" + host = None + + def toggleViewAction(self): + if not hasattr(self, '_view_action'): + self._view_action = QAction(self.windowTitle(), self) + self._view_action.triggered.connect(self.show) + return self._view_action + + def setVisible(self, visible): + if self.host is None: + super().setVisible(visible) + elif visible: + self.host.reveal(self) + # Once moved, hiding a legacy dock must not hide its reparented tool. + + def show(self): + self.setVisible(True) + + def raise_(self): + if self.host is None: + super().raise_() + else: + self.host.reveal(self) + + +def scroll_page(widget): + page = QScrollArea() + page.setWidgetResizable(True) + page.setFrameShape(QScrollArea.Shape.NoFrame) + page.setWidget(widget) + page.setMinimumSize(0, 0) + page.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + return page + # A wide tool scrolls inside its page instead of enlarging the whole window. + + +class Workbench(QWidget): + def __init__(self, window): + super().__init__(window) + self.window = window + self.prefs = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, 'MEDHA', 'Workbench-v2') + self.routes = {} + self.buttons = {} + self.sidebar_width = max(180, min(420, self.prefs.value('sidebar_width', 270, type=int))) + self.panel_height = max(120, min(400, self.prefs.value('panel_height', 220, type=int))) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.activity = QWidget() + self.activity.setObjectName('activityBar') + self.activity.setFixedWidth(48) + self.activity_layout = QVBoxLayout(self.activity) + self.activity_layout.setContentsMargins(0, 4, 0, 4) + self.activity_layout.setSpacing(0) + layout.addWidget(self.activity) + + self.horizontal = QSplitter(Qt.Orientation.Horizontal) + self.sidebar = QWidget() + self.sidebar.setObjectName('sideBar') + self.sidebar.setMinimumWidth(0) + side_layout = QVBoxLayout(self.sidebar) + side_layout.setContentsMargins(0, 0, 0, 0) + self.selector = QComboBox() + self.selector.setAccessibleName('Sidebar view') + side_layout.addWidget(self.selector) + self.stack = QStackedWidget() + self.stack.setMinimumWidth(0) + self.stack.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + side_layout.addWidget(self.stack) + self.selector.currentIndexChanged.connect(self.select) + self.horizontal.addWidget(self.sidebar) + self.vertical = QSplitter(Qt.Orientation.Vertical) + self.vertical.addWidget(window.tab_widget) + self.vertical.addWidget(window.bottom_tabs) + window.bottom_tabs.setMinimumSize(0, 0) + window.bottom_tabs.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + window.bottom_tabs.setUsesScrollButtons(True) + self.horizontal.addWidget(self.vertical) + self.horizontal.setStretchFactor(0, 0) + self.horizontal.setStretchFactor(1, 1) + self.vertical.setStretchFactor(0, 1) + self.vertical.setStretchFactor(1, 0) + layout.addWidget(self.horizontal) + self.horizontal.splitterMoved.connect(self.remember_sidebar) + self.vertical.splitterMoved.connect(self.remember_panel) + # Splitter state is stored as logical sizes, clamped again for each display. + + sidebar_tools = [ + ('Explorer', window.dock_explorer, QStyle.StandardPixmap.SP_DirIcon), + ('Run and Debug', window.dock_gdb, QStyle.StandardPixmap.SP_MediaPlay), + ('Extensions', window.dock_extensions, QStyle.StandardPixmap.SP_FileDialogDetailedView), + ('DSA', window.dock_dsa, QStyle.StandardPixmap.SP_ComputerIcon), + ('AI', window.dock_ai, QStyle.StandardPixmap.SP_MessageBoxInformation), + ('Outline', window.dock_outline, QStyle.StandardPixmap.SP_FileDialogListView), + ] + for title, dock, icon in sidebar_tools: + widget = dock.widget() + window.removeDockWidget(dock) + QDockWidget.setVisible(dock, False) + self.add_view(title, widget, icon, dock) + for title, dock in [('Memory', window.dock_memory), ('Disassembly', window.dock_asm), + ('Debug Console', None), ('Turbo C screen', window.dock_turbo)]: + if dock is None: + console = QWidget() + console_layout = QVBoxLayout(console) + self.debug_log = QTextEdit() + self.debug_log.setReadOnly(True) + self.debug_input = QLineEdit() + self.debug_input.setPlaceholderText('Evaluate an expression in the active debug session') + self.debug_input.returnPressed.connect(self.evaluate) + window.gdb_engine.state_changed.connect(self.debug_log.append) + console_layout.addWidget(self.debug_log) + console_layout.addWidget(self.debug_input) + window.bottom_tabs.addTab(console, title) + continue + widget = dock.widget() + window.removeDockWidget(dock) + QDockWidget.setVisible(dock, False) + page = scroll_page(widget) + window.bottom_tabs.addTab(page, title) + self.routes[dock] = ('panel', page) + dock.host = self + window.removeDockWidget(window.dock_bottom) + QDockWidget.setVisible(window.dock_bottom, False) + window.dock_bottom.host = self + self.routes[window.dock_bottom] = ('panel', window.terminal) + self.activity_layout.addStretch() + self.add_toggle('Toggle Side Bar', 'Ctrl+B', self.toggle_sidebar) + self.add_toggle('Toggle Panel', 'Ctrl+J', self.toggle_panel) + self.selector.setCurrentIndex(0) + self.sidebar.setVisible(self.prefs.value('sidebar_visible', True, type=bool)) + window.bottom_tabs.setVisible(self.prefs.value('panel_visible', True, type=bool)) + QTimer.singleShot(0, self.restore_sizes) + + def evaluate(self): + expression = self.debug_input.text().strip() + if not expression: + return + if not self.window.gdb_engine.session: + self.debug_log.append('Start debugging before evaluating an expression.') + return + result = self.window.gdb_engine.evaluate_expression(expression) + self.debug_log.append(f'> {expression}\n{result}') + self.debug_input.clear() + # The console uses the existing engine so watches and evaluations agree. + + def add_toggle(self, title, shortcut, slot): + action = QAction(title, self.window) + action.setShortcut(QKeySequence(shortcut)) + action.triggered.connect(slot) + self.window.menuBar().actions()[2].menu().addAction(action) + return action + + def add_view(self, title, widget, icon, dock=None): + page = scroll_page(widget) + index = self.stack.addWidget(page) + self.selector.addItem(title) + button = QToolButton() + button.setFixedSize(48, 48) + button.setIconSize(QSize(24, 24)) + button.setIcon(self.style().standardIcon(icon)) + button.setToolTip(title) + button.setAccessibleName(title) + button.setCheckable(True) + button.clicked.connect(lambda: self.activate(index)) + self.activity_layout.insertWidget(index, button) + self.buttons[index] = button + if dock: + self.routes[dock] = ('side', index) + dock.host = self + return index + + def select(self, index): + self.stack.setCurrentIndex(index) + for number, button in self.buttons.items(): + button.setChecked(number == index) + + def activate(self, index): + self.sidebar.show() + self.selector.setCurrentIndex(index) + self.select(index) + self.restore_sizes() + + def reveal(self, dock): + location, target = self.routes[dock] + if location == 'side': + self.activate(target) + else: + self.window.bottom_tabs.show() + self.window.bottom_tabs.setCurrentWidget(target) + self.restore_sizes() + + def toggle_sidebar(self): + self.sidebar.setVisible(self.sidebar.isHidden()) + self.restore_sizes() + + def toggle_panel(self): + self.window.bottom_tabs.setVisible(self.window.bottom_tabs.isHidden()) + self.restore_sizes() + + def restore_sizes(self): + # Keep at least 60% of the whole window available to the editor at ≥1366px. + maximum = max(160, int(self.window.width() * .4) - 56) + width = min(self.sidebar_width, maximum) + self.horizontal.setSizes([width, max(200, self.width() - width - 52)]) + self.vertical.setSizes([max(180, self.height() - self.panel_height), self.panel_height]) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.restore_sizes() + + def remember_sidebar(self, position, index): + width = self.horizontal.sizes()[0] + if width > 0: + self.sidebar_width = width + self.restore_sizes() + + def remember_panel(self, position, index): + height = self.vertical.sizes()[1] + if height > 0: + self.panel_height = height + + def save(self): + self.prefs.setValue('sidebar_width', self.sidebar_width) + self.prefs.setValue('panel_height', self.panel_height) + self.prefs.setValue('sidebar_visible', not self.sidebar.isHidden()) + self.prefs.setValue('panel_visible', not self.window.bottom_tabs.isHidden()) + self.prefs.sync() diff --git a/medha/workbench.qss b/medha/workbench.qss new file mode 100644 index 0000000..c94ac66 --- /dev/null +++ b/medha/workbench.qss @@ -0,0 +1,38 @@ +QWidget { background: $bg; color: $fg; font-family: "Segoe UI", "DejaVu Sans"; font-size: 12px; } +QMainWindow, QMenuBar, QStatusBar, #sideBar, #activityBar { background: $panel; } +QMenuBar { border-bottom: 1px solid $border; padding: 3px; } +QMenuBar::item { padding: 5px 9px; background: transparent; } +QMenuBar::item:selected, QMenu::item:selected { background: $selection; } +QMenu { border: 1px solid $border; padding: 5px; } +QMenu::item { padding: 6px 24px; } +QStatusBar { border-top: 1px solid $border; } +QStatusBar QLabel { background: transparent; padding: 3px 8px; font-size: 11px; } +QTabWidget::pane { border: 0; } +QTabBar { background: $panel; } +QTabBar::tab { background: $panel; color: $muted; padding: 9px 13px; border-right: 1px solid $border; border-top: 2px solid transparent; } +QTabBar::tab:selected { background: $bg; color: $fg; border-top: 2px solid $accent; } +QTabBar QToolButton { background: $panel; } +QToolButton, QPushButton { background: $field; border: 1px solid $border; padding: 5px 9px; border-radius: 3px; } +QPushButton:hover, QToolButton:hover { background: $selection; } +#activityBar QToolButton { border: 0; border-left: 2px solid transparent; border-radius: 0; background: $panel; padding: 0; } +#activityBar QToolButton:checked { border-left: 2px solid $accent; background: $bg; } +#commandCentre { min-width: 250px; color: $muted; padding: 4px 18px; } +QLineEdit, QComboBox { background: $field; border: 1px solid $border; padding: 6px; selection-background-color: $selection; } +QLineEdit:focus { border: 1px solid $accent; } +QPlainTextEdit, QTextEdit { font-size: 14px; background: $bg; color: $fg; border: none; selection-background-color: $selection; font-family: "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono"; } +QTreeView, QListView, QTableView { background: $panel; border: none; selection-background-color: $selection; } +QTreeView::item, QListView::item { padding: 4px; } +QHeaderView::section { background: $panel; border: 0; padding: 6px; } +QSplitter::handle { background: $border; width: 4px; height: 4px; } +QScrollArea { border: none; } +QScrollBar:vertical { background: $panel; width: 10px; } +QScrollBar:horizontal { background: $panel; height: 10px; } +QScrollBar::handle { background: $border; min-height: 24px; min-width: 24px; } +QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; } +QLabel#WelcomeTitle { font-size: 32px; font-weight: 600; } +QLabel#WelcomeSubtitle { font-size: 15px; color: $muted; } +QPushButton#welcomeLink { text-align: left; padding: 10px 14px; color: $accent; background: transparent; border: 0; } +QPushButton#welcomeLink:hover { background: $selection; } +QToolButton#tabClose { border: none; background: transparent; color: $muted; padding: 0; font-size: 14px; } +QToolButton#tabClose:hover { background: $field; } +QToolTip { background: $field; color: $fg; border: 1px solid $border; } diff --git a/packaging/windows/medha_windows.spec b/packaging/windows/medha_windows.spec index 1079c7b..3dbeee5 100644 --- a/packaging/windows/medha_windows.spec +++ b/packaging/windows/medha_windows.spec @@ -6,7 +6,8 @@ # FLOW: build_msix.ps1 → pyinstaller medha_windows.spec → dist/MEDHA/MEDHA.exe import os ROOT = os.path.abspath(os.path.join(SPECPATH, "..", "..")) -datas = [(os.path.join(ROOT, "medha", "turbo", "runtime"), "medha/turbo/runtime"), +datas = [(os.path.join(ROOT, "medha", "workbench.qss"), "medha"), + (os.path.join(ROOT, "medha", "turbo", "runtime"), "medha/turbo/runtime"), (os.path.join(ROOT, "medha", "turbo", "modernize_explanations.json"), "medha/turbo"), (os.path.join(ROOT, "examples"), "examples"), (os.path.join(ROOT, "docs", "assets"), "docs/assets")] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4ed124 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Offline by default: only explicitly opted-in tests may contact the registry.""" +import os +import pytest + + +def pytest_configure(config): + config.addinivalue_line('markers', 'network: requires external internet access') + + +def pytest_collection_modifyitems(config, items): + # Local fake HTTP servers remain enabled; only real external calls need opt-in. + if os.environ.get('MEDHA_NETWORK_TESTS') != '1': + for item in items: + if item.get_closest_marker('network'): + item.add_marker(pytest.mark.skip(reason='Set MEDHA_NETWORK_TESTS=1 for network tests')) diff --git a/tests/fixtures/v1_actions.json b/tests/fixtures/v1_actions.json new file mode 100644 index 0000000..21f6792 --- /dev/null +++ b/tests/fixtures/v1_actions.json @@ -0,0 +1,101 @@ +[ + "&About MEDH\u0100 IDE", + "&Build Project (Ctrl+Shift+B)", + "&Command Palette\u2026", + "&Configure CMake", + "&Go to Definition (F12)", + "&Keyboard Shortcuts", + "&Modernize this code (Turbo \u2192 C11/C++17)\u2026", + "&New Scratchpad File", + "&Open File...", + "&Redo", + "&Run Active Scratchpad File", + "&Save File", + "&Security Scan (offline, CWE)", + "&Stop Debugging", + "&Stop Program", + "&Toolchains && Run Settings\u2026", + "&Undo", + "01_class_rectangle.cpp", + "01_even_odd.py", + "01_factorial.c", + "01_hello_graphics.c", + "02_inheritance.cpp", + "02_lines_and_shapes.c", + "02_list_stats.py", + "02_prime_check.c", + "03_dictionary_marks.py", + "03_fibonacci.c", + "03_operator_overload.cpp", + "03_smiley.c", + "04_bubble_sort.c", + "04_class_bank.py", + "04_concentric_circles.c", + "04_vector_sort.cpp", + "05_file_io.cpp", + "05_file_word_count.py", + "05_matrix_multiply.c", + "05_national_flag.c", + "06_hut.c", + "06_recursion_hanoi.py", + "06_student_struct.c", + "06_template_stack.cpp", + "07_moving_car.c", + "08_bouncing_ball.c", + "09_analog_clock.c", + "10_bar_chart.c", + "11_pie_chart.c", + "12_dda_line.c", + "13_bresenham_line.c", + "14_midpoint_circle.c", + "15_midpoint_ellipse.c", + "16_boundary_fill.c", + "17_flood_fill.c", + "18_translation_2d.c", + "19_scaling_2d.c", + "20_rotation_2d.c", + "21_reflection_2d.c", + "22_cohen_sutherland.c", + "23_sine_wave.c", + "24_traffic_light.c", + "25_kite.c", + "26_snowman.c", + "27_rainbow.c", + "28_star_polygon.c", + "29_conio_menu.c", + "30_conio_box.c", + "31_turbo_cpp_marks.cpp", + "32_keyboard_move_box.c", + "BubbleSort.java", + "Console & Safety Diagnostics", + "DSA Visualizer Engine", + "E&xit", + "ExceptionDemo.java", + "Explain errors in &Hinglish", + "Extensions", + "Factorial.java", + "GDB Interactive Debugger", + "Git &Status", + "Git Stage & Commit...", + "Inspect &Live Assembly (gcc -S)", + "Live Disassembly (gcc -S)", + "Live Memory & Pointers", + "Offline AI Assistant", + "Open &Workspace Folder...", + "Project Explorer", + "RecordDemo.java", + "Run &Static Safety Linter (cppcheck)", + "Save &As...", + "Shapes.java", + "Start &Debugging (F5)", + "Step &Into (F11)", + "Step &Out (Shift+F11)", + "Step &Over (F10)", + "Student.java", + "Symbol Outline", + "T&urbo mode (graphics.h / conio.h / iostream.h)", + "Target: &Debug (-g -O0)", + "Target: &Release (-O2 -s)", + "Trigger &Completion (Ctrl+Space)", + "Turbo C Screen (640\u00d7480)" +] \ No newline at end of file diff --git a/tests/test_extensions_contributions.py b/tests/test_extensions_contributions.py index 5389c7c..c03fe07 100644 --- a/tests/test_extensions_contributions.py +++ b/tests/test_extensions_contributions.py @@ -384,4 +384,13 @@ def test_multiple_snippet_files_merged(self, installed_extension): # ============================================================================= if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"]) + +def test_contribution_loader_is_defined_once(): + # A duplicate silently shadows the implementation even when behavior tests pass. + import ast + import inspect + from medha.extensions import contributions + tree = ast.parse(inspect.getsource(contributions)) + assert sum(isinstance(node, ast.FunctionDef) and + node.name == "load_extension_contributions" for node in tree.body) == 1 diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py new file mode 100644 index 0000000..e97be28 --- /dev/null +++ b/tests/test_ui_smoke.py @@ -0,0 +1,286 @@ +"""Open the real Qt window offline; individual workbench contracts live here.""" +import pytest +from PySide6.QtCore import QSettings, QCoreApplication, QEvent +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest +from medha import settings +from medha.app import MedhaMainWindow + + +@pytest.fixture +def window(tmp_path, monkeypatch): + import sys + import requests + errors = [] + monkeypatch.setattr(sys, 'excepthook', lambda *error: errors.append(error)) + def deny_network(*args, **kwargs): + raise AssertionError('UI startup must remain offline') + monkeypatch.setattr(requests.Session, 'request', deny_network) + app = QApplication.instance() or QApplication([]) + monkeypatch.setenv('XDG_DATA_HOME', str(tmp_path / 'data')) + monkeypatch.setattr(settings, 'settings_path', lambda: str(tmp_path / 'settings.json')) + settings.reset_cache_for_tests() + QSettings.setDefaultFormat(QSettings.Format.IniFormat) + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(tmp_path)) + win = MedhaMainWindow(str(tmp_path)) + win.resize(1366, 768) + win.show() + QTest.qWait(150) + yield win + for index in range(win.tab_widget.count()): + widget = win.tab_widget.widget(index) + if hasattr(widget, "is_modified"): + widget.is_modified = False + win.close() + win.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + app.processEvents() + settings.reset_cache_for_tests() + assert not errors, errors + + +def test_window_opens(window): + assert window.isVisible() + assert window.tab_widget.count() > 0 + + +def test_titles_escape_literal_ampersands(window, tmp_path): + import re + from PySide6.QtWidgets import QDockWidget, QTabWidget + path = tmp_path / 'a&b.c' + path.write_text('int main(void) {}') + window.open_file_path(str(path)) + window.get_current_editor().insertPlainText('// edit') + window.create_untitled_tab('x&y.c') + titles = [dock.windowTitle() for dock in window.findChildren(QDockWidget)] + for tabs in window.findChildren(QTabWidget): + titles.extend(tabs.tabText(i) for i in range(tabs.count())) + assert all(not re.search(r'(?= size[0] * .6 + for dock in window.workbench.routes: + dock.toggleViewAction().trigger() + QApplication.processEvents() + assert window.tab_widget.width() >= size[0] * .6 + window.workbench.toggle_sidebar() + assert window.workbench.sidebar.isHidden() + window.workbench.toggle_panel() + assert window.bottom_tabs.isHidden() + window.dock_turbo.show() + assert not window.bottom_tabs.isHidden() + + +def test_layout_preferences_roundtrip(window): + shell = window.workbench + shell.sidebar_width = 320 + shell.panel_height = 180 + shell.toggle_sidebar() + shell.save() + prefs = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, 'MEDHA', 'Workbench-v2') + assert prefs.value('sidebar_width', type=int) == 320 + assert prefs.value('panel_height', type=int) == 180 + assert prefs.value('sidebar_visible', type=bool) is False + + +def test_menu_order_and_existing_actions_preserved(window): + import json + from pathlib import Path + from collections import Counter + from medha.command_palette import collect_actions + menus = [a.text().replace('&', '') for a in window.menuBar().actions()] + assert menus == ['File', 'Edit', 'Selection', 'View', 'Go', 'Run', 'Terminal', 'Help'] + original = json.loads((Path(__file__).parent / 'fixtures/v1_actions.json').read_text()) + before = Counter(text.replace('&', '') for text in original) + after = Counter(action.text().replace('&', '') for _, action in collect_actions(window.menuBar())) + assert not (before - after) + reachable = {action for _, action in collect_actions(window.menuBar())} + assert set(window.legacy_actions) <= reachable + + +def test_quick_open_and_workspace_search(window, tmp_path): + from medha.navigation import QuickOpen + source = tmp_path / 'unique-file.c' + source.write_text('int find_this = 7;\n') + opened = [] + dialog = QuickOpen(tmp_path, opened.append, window) + for _ in range(10): + QApplication.processEvents() + dialog.search.setText('unique-file') + assert dialog.list.count() == 1 + dialog.run_selected() + assert opened == [str(source)] + window.search_view.query.setText('find_this') + window.search_view.search() + for _ in range(30): + QApplication.processEvents() + assert window.search_view.results.count() == 1 + window.search_view.open_result(window.search_view.results.item(0)) + assert window.get_current_editor().file_path == str(source) + + +def test_themes_apply_to_existing_and_new_editors(window): + from medha.theme import LIGHT, DARK + window.create_untitled_tab(content='int value = 1;') + editor = window.get_current_editor() + for name, colors in [('light', LIGHT), ('dark', DARK)]: + window.apply_theme(name) + QApplication.processEvents() + assert editor.theme_tokens['bg'] == colors['bg'] + assert not editor.is_modified + assert editor.minimap.width() == 80 + window.create_untitled_tab() + assert window.get_current_editor().theme_tokens['bg'] == colors['bg'] + + +def test_welcome_does_not_create_files(window, tmp_path): + from medha.welcome import WelcomePage + assert isinstance(window.tab_widget.currentWidget(), WelcomePage) + assert not (tmp_path / 'main.c').exists() + window.create_untitled_tab() + window.close_tab(window.tab_widget.currentIndex()) + assert isinstance(window.tab_widget.currentWidget(), WelcomePage) + + +def test_cancel_keeps_unsaved_tab(window, monkeypatch): + from PySide6.QtWidgets import QMessageBox + window.create_untitled_tab() + editor = window.get_current_editor() + editor.insertPlainText('unsaved') + monkeypatch.setattr(QMessageBox, 'question', lambda *args: QMessageBox.StandardButton.Cancel) + window.close_tab(window.tab_widget.currentIndex()) + assert window.tab_widget.indexOf(editor) >= 0 + window.close() + assert window.isVisible() + monkeypatch.setattr(QMessageBox, 'question', lambda *args: QMessageBox.StandardButton.Discard) + window.close_tab(window.tab_widget.indexOf(editor)) + assert window.tab_widget.indexOf(editor) == -1 + + +def test_status_uses_document_and_build_data(window, tmp_path): + from types import SimpleNamespace + source = tmp_path / 'sample.py' + source.write_text('print(1)\nprint(2)\n') + window.open_file_path(str(source)) + window.navigate_to_line(2) + assert 'Ln 2' in window.lbl_cursor_pos.text() + assert 'PYTHON' in window.lbl_cursor_pos.text() + assert 'UTF-8' in window.lbl_cursor_pos.text() + assert 'sample.py' in window.windowTitle() + assert window.lbl_sys_info.isHidden() + window.status_controller.set_diagnostics([SimpleNamespace(severity='error'), SimpleNamespace(severity='warning')]) + assert window.status_controller.diagnostics.text() == '1 errors · 1 warnings' + window.status_controller.monitor.setChecked(True) + window.status_controller.refresh() + assert '%' in window.lbl_sys_info.text() + + +def test_every_menu_action_from_welcome(window, monkeypatch): + """Exercise UI slots with cancelled dialogs and isolated external build calls.""" + import sys + from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QInputDialog, QMenu + from medha.command_palette import collect_actions + from medha.build import BuildSystem + errors = [] + monkeypatch.setattr(sys, 'excepthook', lambda *error: errors.append(error)) + monkeypatch.setattr(QDialog, 'exec', lambda self: QDialog.DialogCode.Rejected) + monkeypatch.setattr(QMessageBox, 'exec', lambda self: QMessageBox.StandardButton.Cancel) + monkeypatch.setattr(QMenu, 'exec', lambda *args: None) + monkeypatch.setattr(QFileDialog, 'getOpenFileName', lambda *a, **k: ('', '')) + monkeypatch.setattr(QFileDialog, 'getSaveFileName', lambda *a, **k: ('', '')) + monkeypatch.setattr(QFileDialog, 'getExistingDirectory', lambda *a, **k: '') + monkeypatch.setattr(QInputDialog, 'getInt', lambda *a, **k: (1, False)) + monkeypatch.setattr(BuildSystem, 'configure', lambda self: (0, 'Smoke configuration')) + monkeypatch.setattr(BuildSystem, 'build', lambda self: (0, 'Smoke build', None)) + monkeypatch.setattr(window, 'open_lab_program', lambda path: None) + # Each action starts in the legitimate no-active-document Welcome state. + # Editing/build behavior with a real document is covered in the focused tests. + actions = [action for _, action in collect_actions(window.menuBar())] + exit_action = next(a for a in actions if a.text().replace('&', '') == 'Exit') + for action in actions: + if action == exit_action: + continue + window.show_welcome() + action.trigger() + QApplication.processEvents() + assert not errors, (action.text(), errors) + assert window.isVisible(), action.text() + exit_action.trigger() + assert not errors + assert not window.isVisible() + + +@pytest.mark.parametrize('theme', ['dark', 'light']) +@pytest.mark.parametrize('width,height', [(1366, 768), (1920, 1080)]) +def test_text_viewport_meets_width_target(window, theme, width, height): + window.resize(width, height) + window.apply_theme(theme) + window.create_untitled_tab('sample.c', 'int main(void) { return 0; }') + QTest.qWait(30) + editor = window.get_current_editor() + assert editor.viewport().width() >= width * .6 + assert editor.lang == 'c' + window.workbench.toggle_panel() + window.show_panel(window.problems) + assert window.bottom_tabs.isVisible() + assert window.bottom_tabs.currentWidget() == window.problems + + +def test_layout_restored_in_second_window(window): + shell = window.workbench + shell.sidebar_width = 310 + shell.panel_height = 190 + shell.sidebar.hide() + shell.save() + other = MedhaMainWindow(window.workspace_path) + other.show() + QApplication.processEvents() + assert other.workbench.sidebar_width == 310 + assert other.workbench.panel_height == 190 + assert other.workbench.sidebar.isHidden() + other.close() + other.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + +def test_cancelled_save_as_preserves_unsaved_document(window, monkeypatch): + from PySide6.QtWidgets import QMessageBox, QFileDialog + window.create_untitled_tab() + editor = window.get_current_editor() + editor.insertPlainText('int x;') + monkeypatch.setattr(QMessageBox, 'question', lambda *args: QMessageBox.StandardButton.Save) + monkeypatch.setattr(QFileDialog, 'getSaveFileName', lambda *args: ('', '')) + window.close_tab(window.tab_widget.currentIndex()) + assert window.get_current_editor() is editor + assert editor.is_modified + + +def test_recent_files_and_minimap_navigation(window, tmp_path): + from PySide6.QtCore import Qt, QPoint + from PySide6.QtWidgets import QPushButton + source = tmp_path / 'recent.c' + source.write_text('\n'.join(f'int value_{i};' for i in range(300))) + window.open_file_path(str(source)) + editor = window.get_current_editor() + QTest.qWait(30) + QTest.mouseClick(editor.minimap, Qt.MouseButton.LeftButton, + pos=QPoint(20, editor.minimap.height() // 2)) + assert editor.textCursor().blockNumber() > 100 + window.show_welcome() + assert any('recent.c' in button.text() for button in window.tab_widget.currentWidget().findChildren(QPushButton)) + + +def test_collapse_keyboard_shortcuts(window): + from PySide6.QtCore import Qt + window.activateWindow() + window.setFocus() + QTest.keyClick(window, Qt.Key.Key_B, Qt.KeyboardModifier.ControlModifier) + assert window.workbench.sidebar.isHidden() + QTest.keyClick(window, Qt.Key.Key_J, Qt.KeyboardModifier.ControlModifier) + assert window.bottom_tabs.isHidden() + QTest.keyClick(window, Qt.Key.Key_B, Qt.KeyboardModifier.ControlModifier) + assert not window.workbench.sidebar.isHidden() diff --git a/tools/capture_v2.py b/tools/capture_v2.py new file mode 100644 index 0000000..be3cea7 --- /dev/null +++ b/tools/capture_v2.py @@ -0,0 +1,46 @@ +"""Reproducible Qt screenshots: python tools/capture_v2.py --phase after.""" +import argparse +import os +import sys +import tempfile +from pathlib import Path + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest +from medha.app import MedhaMainWindow +from medha import settings + +parser = argparse.ArgumentParser() +parser.add_argument('--phase', default='after') +parser.add_argument('--welcome', action='store_true') +args = parser.parse_args() +app = QApplication([]) +output = Path(__file__).resolve().parents[1] / 'docs/screenshots/v2' +output.mkdir(parents=True, exist_ok=True) +with tempfile.TemporaryDirectory(prefix='medha-capture-') as directory: + root = Path(directory) + os.environ['XDG_DATA_HOME'] = str(root / 'data') + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(root)) + settings.settings_path = lambda: str(root / 'settings.json') + project = root / 'hello-c' + project.mkdir() + source = project / 'hello.c' + source.write_text('''// MEDHĀ · learn by building\n#include \n\nint main(void) {\n const char *name = "world";\n int numbers[] = {8, 3, 5, 1, 9};\n\n printf("Hello, %s!\\n", name);\n for (int i = 0; i < 5; ++i) {\n printf("numbers[%d] = %d\\n", i, numbers[i]);\n }\n\n return 0;\n}\n''') + window = MedhaMainWindow(str(project)) + if not args.welcome: + window.open_file_path(str(source)) + for theme in (['dark', 'light'] if hasattr(window, 'apply_theme') else ['dark']): + if hasattr(window, 'apply_theme'): + window.apply_theme(theme) + for width, height in [(1366, 768), (1920, 1080)]: + window.resize(width, height) + window.show() + QTest.qWait(200) + assert window.size().width() == width and window.size().height() == height + path = output / f'{args.phase}-{theme}-{width}x{height}.png' + assert window.grab().save(str(path)) + print(path.name, 'editor:', window.tab_widget.width(), flush=True) + window.close()