diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e764ea..e1676fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,41 @@ All notable changes to this project are documented here. Versions follow [semantic versioning](https://semver.org/). +## [Unreleased] + +## [1.3.0] — 2026-07-31 + +Read the diff in whichever colour scheme suits the screen, with A2L coloured +like the rest and noise pushed out of the way instead of out of the file. + +### Added + +- **Light and dark colour schemes.** Both the viewer and the report can be read + either way. `--theme dark|light` sets which one they open with (dark stays the + default), the viewer has a switch in its toolbar, and the report carries both + schemes inside the file — so its own switch works with no internet, and your + choice is remembered for the next report you open. +- **A2L files are syntax-coloured** in the viewer, keywords and block types + apart from the calibration object names — so a name still stands out in a + page of ASAM keywords. + +### Changed + +- **Hiding comment or unimportant differences now greys those lines out instead + of removing them.** They keep their place and their line numbers, so the code + around a real change is still there to read it in, and they no longer count + as changes on the minimap or when stepping through changes. +- **The report shows unimportant differences when you click their badge**, in + grey rather than red or green, so a revealed category still reads as one that + does not count. Comment differences stay out of the report entirely — only + the count of hidden lines is shown — while the viewer keeps showing them. + +### Fixed + +- **A model's `_data` companion file is filed under that model** in the report + instead of landing in Shared / other, so everything generated for one + component is read in one place. + ## [1.2.0] — 2026-07-29 Compare a folder against its own git history, sign off a whole file at once, diff --git a/CLAUDE.md b/CLAUDE.md index 9a350c5..e49f279 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,12 +50,25 @@ place, `diff_engine._status_of`. Only noise verdicts are foldable (`scanner.FOLDABLE`). `real-change`, `added`, `deleted` and `error` can **never** be folded away by a UI toggle. +Folding a category in the viewer changes the file's verdict and **greys** its +rows (`view_model.mute_rows`) — it does not remove them. The lines stay +readable, and only the "where should I look next" surfaces (minimap, F7/F8) +stop counting them. Collapsing them to a `⋯ N lines hidden` placeholder was +tried and reverted: a regenerated file is mostly banner churn, so it took the +context the surviving hunks have to be read in. + ## 3. One seam per shared decision Any fact two renderers need lives in **one** module they both import. -`compare_tool/view_model.py` holds `mode_of`, `char_span` and `aligned_rows`; -the HTML report and the Qt viewer both consume them, so they cannot disagree -about what changed or how it is coloured. +`compare_tool/view_model.py` holds `mode_of`, `char_span`, `aligned_rows` and +`mute_rows`; `compare_tool/theme.py` holds every colour as a named role, one +value per theme. The HTML report and the Qt viewer both consume them, so they +cannot disagree about what changed or how it is coloured. + +A colour literal outside `theme.py` is a bug: it paints one theme correctly and +the other by accident. Add a role to **both** palettes (an import-time assert +enforces it), then use `var(--role)` in the report's CSS or `theme.c(role)` in +Qt. Re-implementing a mapping inline "because it is only four lines" is the bug: the copies drift the moment a new kind is added. If you find a duplicated diff --git a/README.md b/README.md index 03ea1a9..476621f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ scan did find is still printed. | `--exit-zero` | Always exit 0 even when real changes exist (report-only mode for pipelines). Compare errors still exit 2 | | `--arxml-only` | Scan only `.arxml`/`.xml`/`.a2l` and write a compact per-type report (default `arxml_update.html`) — always written, even when nothing changed | | `--review FILE` | Render notes and sign-offs from a review file (`codegen-review.json`, written by the viewer) next to the changes they belong to, plus a `Reviewed` badge that hides the changes already signed off. Must be named explicitly — a report must not pick up someone else's sign-off by accident; no effect with `--arxml-only` | +| `--theme dark\|light` | Colour scheme the report and the viewer open with (default `dark`). The report carries **both** and has its own switch, so this only sets what the reader sees first | | `--qt`, `--viewer` | Open the side-by-side viewer on folders named on the command line, instead of comparing them in the terminal. Needs the `viewer` extra (see below) | Omitting `old_dir`/`new_dir` opens the viewer. `--gui` (the tkinter panel) was removed in 1.1.0. @@ -113,6 +114,8 @@ Reading a scan: - `F8` / `F7` step through the changes in the open file and then **carry on into the next (previous) file** with something to review, wrapping at the end. `Ctrl+Home` / `Ctrl+End` stay inside the file. - `Ctrl+F` **finds text in the open file** (either side, `F3` / `Shift+F3` to step, `Esc` to close). The query survives moving to another file, so an identifier can be chased across the compare. - `Hide identical` leaves only the files with a difference in the tree. It is a view: verdicts, counts and the exported report are untouched. +- Unticking `Comment` / `Unimportant` **greys those lines out** rather than removing them: they stay where they are, keep their line numbers, lose their red/green, and drop off the minimap and out of `F7`/`F8`. The code around a change is what makes it readable, and a regenerated file is mostly banner churn — folding it away took most of the file with it. +- `☀ Light` / `☾ Dark` in the toolbar switches the colour scheme; `--theme` picks the one it starts in. C, ARXML and A2L are syntax-coloured in both. `Review mode` adds the note box and a `Review` column in the tree — green when every change in a row is signed off, amber part way, grey when none is. Sign off one change (`Ctrl+R`) or a whole file (`Ctrl+Shift+R`); the notes travel into the exported report. @@ -138,7 +141,7 @@ A shorter name can stop an argument wrapping at 80 columns, so the two sides hol Everything else keeps its suffix as meaning. `SIG_TORQUE_MIN` → `SIG_TORQUE_MAX` and `CFG_TIMEOUT_MS` → `CFG_TIMEOUT_US` are real changes, and so are `rtb_AND_…` → `rtb_OR_…` (a different block drives that buffer) and `Sub_…_step` → `Sub_…_Init` (a different entry point). Digits glued to a block name (`rtb_Switch1` vs `rtb_Switch2`) are part of the name, not a mangle tail. -**Comment changes are their own category.** A file whose differences are *only* comments is reported as **Comment**, separate from **Unimportant** (UUIDs, timestamps, SW-VERSION, renames, whitespace) — a rewritten comment banner triages differently from a renamed identifier. Separate counts in the CLI summary and its own tree marker in the viewer. A file mixing comments *with* other noise stays Unimportant. The HTML report keeps the verdict but does not display comment content — see [HTML report](#html-report). +**Comment changes are their own category.** A file whose differences are *only* comments is reported as **Comment**, separate from **Unimportant** (UUIDs, timestamps, SW-VERSION, renames, whitespace) — a rewritten comment banner triages differently from a renamed identifier. Separate counts in the CLI summary and its own tree marker in the viewer. A file mixing comments *with* other noise stays Unimportant. The viewer has a rule toggle for each; the HTML report gives `Unimportant` a badge and leaves comment lines out altogether — see [HTML report](#html-report). ## Moved block detection @@ -168,11 +171,13 @@ A file whose XML fails to parse is skipped from this summary (its text diff stil ## Grouping by model / SWC -Files are grouped by **Simulink model** using the Embedded Coder AUTOSAR naming convention (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, the modular ARXML set, …). Files that match no model land in a final **Shared / other** group. +Files are grouped by **Simulink model** using the Embedded Coder AUTOSAR naming convention (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, `X_data.c`, the modular ARXML set, …). Files that match no model land in a final **Shared / other** group. ## HTML report -Self-contained file, one per compare: badge toggles, folder tree, filter box, collapsible diffs per file. Opens `Unimportant` hidden and `Modified` expanded, so it opens on what matters. +Self-contained file, one per compare: badge toggles, folder tree, filter box, collapsible diffs per file. Opens `Unimportant` hidden, `Modified` expanded, so it opens on what matters. Clicking `Unimportant` reveals the actual noise lines — painted flat grey rather than red/green, so a revealed category still reads as "does not count" instead of looking like another change. Comment changes never render in the report at all — only a placeholder states how many comment lines were hidden — the report is a record meant to be sent around, and comment churn is left out of it entirely; the side-by-side viewer still shows them, greyed, for a reviewer working file by file. A `☀ Light` / `☾ Dark` button sits in the top right — both palettes are embedded in the file, so switching fetches nothing and works on a machine with no internet. + +A whole file with nothing but comment differences still gets no detail section of its own (there is nothing beyond the comment lines to show); it keeps its own `≉` mark and `Comment` count in the folder tree either way. ![Report viewer](resources/pic/report_page.png) @@ -230,13 +235,14 @@ compare_tool/ ├── arxml_rules.py # ARXML rules: UUID, ADMIN-DATA, DATE, comments + extract port interfaces, SWCs (ports/runnables/events) ├── a2l_rules.py # A2L rules: strip C-style comments + extract CHARACTERISTIC/MEASUREMENT ├── view_model.py # renderer-agnostic view model (paint mode, intra-line span, row alignment) shared by the report and the viewer -├── syntax.py # line-at-a-time C / XML token spans, Qt-free so it ships in the .pyz +├── theme.py # the dark and light palettes as named roles, shared by the report's CSS and every Qt surface +├── syntax.py # line-at-a-time C / XML / A2L token spans, Qt-free so it ships in the .pyz ├── review.py # reviewer notes and sign-offs, keyed by change content so they survive a rescan ├── gitsource.py # read-only `git archive` of a commit into a temp folder, so a commit can be the OLD side └── report.py # self-contained HTML report (badge toggles, model overview, grouping, filter, collapsible diffs) ``` -[docs/architecture.md](docs/architecture.md) covers how these fit together and why: the two diff passes, where a verdict is decided, the shared seams and the result-dict contract. Anything both renderers need lives in `view_model.py` — reimplementing a mapping inline lets the HTML report and the viewer drift apart about what changed. +[docs/architecture.md](docs/architecture.md) covers how these fit together and why: the two diff passes, where a verdict is decided, the shared seams and the result-dict contract. Anything both renderers need lives in `view_model.py` (what changed) or `theme.py` (what colour it gets) — reimplementing a mapping inline lets the HTML report and the viewer drift apart. To add a rule: write the strip function in `c_rules.py` / `arxml_rules.py` / `a2l_rules.py`, join it into that ruleset's shadow, register one labelled variant in `_build_variants` in `diff_engine.py`, and add both tests — the pattern alone is noise, and the same pattern *beside* a real change still reports the real change. diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py index 55cd6f8..48f4a76 100644 --- a/compare_tool/__init__.py +++ b/compare_tool/__init__.py @@ -1,3 +1,3 @@ """CodeGen Compare Tool - AUTOSAR MATLAB codegen diff with noise filtering.""" -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/compare_tool/main.py b/compare_tool/main.py index abd6f94..45fc32b 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -2,14 +2,14 @@ Usage: python -m compare_tool [--report out.html] [--arxml-only] - python -m compare_tool # side-by-side viewer + python -m compare_tool [--theme light] # side-by-side viewer """ import argparse import sys from pathlib import Path -from . import review +from . import review, theme from .diff_engine import RULES from .report import build_arxml_report, build_report from .scanner import (scan, summarize, summarize_a2l, summarize_ifaces, @@ -47,7 +47,7 @@ def default_report_name(arxml_only): def run_compare(old_root, new_root, out, arxml_only=False, exclude=(), - progress=None, reviews=None): + progress=None, reviews=None, theme_name=theme.DEFAULT): """Scan two trees and write the HTML report. Returns (results, counts). Raises :class:`ReportWriteError` when the report could not be written -- a run whose record does not exist is not a run that @@ -68,9 +68,11 @@ def run_compare(old_root, new_root, out, arxml_only=False, exclude=(), if arxml_only: # ALWAYS written: "no changes" must be an explicit statement, never # a silently absent file (indistinguishable from a run that died) - page = build_arxml_report(results, old_root, new_root) + page = build_arxml_report(results, old_root, new_root, + theme_name=theme_name) else: - page = build_report(results, old_root, new_root, reviews) + page = build_report(results, old_root, new_root, reviews, + theme_name=theme_name) try: out.write_text(page, encoding='utf-8') except OSError as e: @@ -184,6 +186,12 @@ def _parser(): 'diff report; the report is ALWAYS written -- when ' 'nothing real changed it states "no changes" ' 'explicitly per file type') + ap.add_argument('--theme', choices=theme.THEMES, default=theme.DEFAULT, + help='colour scheme the viewer opens with, and the one the ' + 'HTML report opens with (default: dark). The report ' + 'always carries both, so its own button switches with ' + 'nothing to download; the viewer has the same button ' + 'in its toolbar') ap.add_argument('--exclude', metavar='PATTERN', action='append', default=[], help='skip files matching this glob (relative path or bare ' 'file name); repeatable. Example: --exclude compare_report.html') @@ -230,7 +238,7 @@ def main(argv=None): from .qtviewer import run_viewer # deferred: PySide6 may be absent try: return run_viewer(args.old_dir, args.new_dir, exclude=args.exclude, - arxml_only=args.arxml_only) + arxml_only=args.arxml_only, theme_name=args.theme) except ImportError as e: # a stdlib-only install (the .pyz, a locked-down box) has no Qt. # Say so plainly instead of dumping a traceback. @@ -275,7 +283,7 @@ def progress(done, total, rel): try: results, counts = run_compare(old_root, new_root, out, args.arxml_only, exclude=args.exclude, progress=progress, - reviews=reviews) + reviews=reviews, theme_name=args.theme) except ReportWriteError as e: # what WAS scanned still goes to the terminal -- the compare itself may # have been fine, it is only the record that is missing diff --git a/compare_tool/qtviewer/app.py b/compare_tool/qtviewer/app.py index da350f6..37c7d5f 100644 --- a/compare_tool/qtviewer/app.py +++ b/compare_tool/qtviewer/app.py @@ -21,7 +21,7 @@ QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget) -from .. import gitsource, review +from .. import gitsource, review, theme from ..diff_engine import RULES from ..main import default_report_name from ..report import build_arxml_report, build_report @@ -31,7 +31,8 @@ from .icons import ACCENT, app_icon, icon, std_icon from .pickers import pick_commit, pick_folders from .summary import SummaryPanel -from .tree import REVIEW_COLOR, STATUS, build_nodes, filter_nodes, review_state +from .tree import (STATUS, build_nodes, filter_nodes, review_color, + review_state, status_color) from .worker import ScanWorker REL_ROLE = Qt.UserRole # a FILE row's relative path (folders: None) @@ -89,8 +90,11 @@ def focusOutEvent(self, event): class MainWindow(QMainWindow): - def __init__(self, old=None, new=None, exclude=(), arxml_only=False): + def __init__(self, old=None, new=None, exclude=(), arxml_only=False, + theme_name=theme.DEFAULT): super().__init__() + self._theme = theme.set_current(theme_name) + self._state = ('idle', 'Ready') self.old = old self.new = new self.exclude = tuple(exclude) @@ -117,8 +121,6 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False): self.banner = QLabel() self.banner.setVisible(False) self.banner.setWordWrap(True) - self.banner.setStyleSheet('background:#4a1d1d; color:#ffd6d6; padding:6px 10px;' - 'font-weight:bold; border-bottom:1px solid #b04a4a;') self.tree = QTreeWidget() # the Review column exists at all times but is hidden until review mode @@ -236,7 +238,6 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False): self.state_label.setStyleSheet('padding:0 8px;') self.statusBar().addWidget(self.state_label) self.counts_label = QLabel('') - self.counts_label.setStyleSheet('color:#9aa1ad; padding:0 8px;') self.statusBar().addPermanentWidget(self.counts_label) self.progress = QProgressBar() self.progress.setMaximumWidth(240) @@ -245,6 +246,7 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False): self._set_state('idle', 'Ready') self._build_toolbar() + self._style_widgets() if self.old and self.new: self._start_scan() @@ -259,21 +261,85 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False): # tool-state chip on the status bar: a coloured dot plus a word, so the # reviewer can tell at a glance whether a result is final or still coming - _STATE_DOT = {'idle': '#8a8f98', 'busy': '#e2c16b', - 'ready': '#7bd88a', 'error': '#ff7b7b'} + _STATE_DOT = {'idle': 'state-idle', 'busy': 'state-busy', + 'ready': 'state-ready', 'error': 'state-error'} def _set_state(self, kind, text): - dot = self._STATE_DOT.get(kind, '#8a8f98') + dot = theme.c(self._STATE_DOT.get(kind, 'state-idle')) + self._state = (kind, text) self.state_label.setText( '  {}' .format(dot, text)) + # --- theme --- + + def _style_widgets(self): + """The stylesheets this window sets by hand (the rest is the app-wide + QSS). One place, so a theme switch is one call.""" + self.banner.setStyleSheet( + 'background:{}; color:{}; padding:6px 10px; font-weight:bold; ' + 'border-bottom:1px solid {};'.format( + theme.c('err-bg'), theme.c('err-fg'), theme.c('err-border'))) + self.counts_label.setStyleSheet('color:{}; padding:0 8px;' + .format(theme.c('st-ign'))) + self.review_where.setStyleSheet('color:{}; font-size:11px;' + .format(theme.c('state-idle'))) + self._show_review_file() # owns review_file's colour: normal or warning + + def _toggle_theme(self): + self._set_theme(theme.other()) + + def _set_theme(self, name): + """Repaint the whole window in `name`. + + Every surface that stamps a colour into a widget rather than reading it + from the stylesheet has to be told: the tree's verdict colours, the + quick-changes rows, the diff pane's block formats, the tinted icons. + Missing one leaves half the window in the old theme, which is why they + are listed here and not discovered by walking children. + """ + changed = theme.set_current(name) != self._theme + self._theme = theme.current() + if changed: + # taken FIRST and handed back LAST: rebuilding the tree re-selects + # the open file, which re-renders it and parks on its first change. + # Restoring inside the pane alone would be undone a line later. + at = self.diff.reading_position() + apply_theme(QApplication.instance()) + self._style_widgets() + self._set_state(*self._state) + self._apply_icons() + self.summary.apply_theme() + self.diff.apply_theme() + self._refresh_tree_keep_selection() # verdict colours are per item + self.diff.restore_reading_position(at) + # outside the guard: the toolbar button is checkable, so Qt has already + # flipped its state by the time this runs. Skipping the repaint must + # not leave the button claiming a theme the window is not in. + self.act_theme.setText(self._theme_label()) + self.act_theme.setChecked(self._theme == theme.LIGHT) + + @staticmethod + def _theme_label(): + return '☀ Light' if theme.current() == theme.DARK else '☾ Dark' + + def _apply_icons(self): + """Re-tint every shipped glyph for the current chrome.""" + self.act_open.setIcon(std_icon(self, QStyle.SP_DirOpenIcon)) + for act, glyph, role in self._icon_actions: + act.setIcon(icon(glyph, role) if role else icon(glyph)) + self.help_button.setIcon(icon('report')) + # --- actions, toolbar, bottom action bar --- def _make_actions(self): """Every command the window offers, in one place. The bottom bar and the toolbar are just two views of these actions, so a button and its shortcut can never drift apart.""" + # (action, glyph, role): what _apply_icons re-tints after a theme + # switch. A QIcon carries baked pixels, so it cannot follow a palette + # by itself. + self._icon_actions = [] self.act_open = QAction(std_icon(self, QStyle.SP_DirOpenIcon), 'Open folders…', self) self.act_open.setToolTip('Choose the BASELINE and CURRENT folders — or ' @@ -287,6 +353,7 @@ def _make_actions(self): self.act_git.setToolTip('Compare a folder against one of its own ' 'commits — no second folder to choose') self.act_git.triggered.connect(self._pick_commit) + self._icon_actions.append((self.act_git, 'git-commit', None)) # First/Last stay inside the open file -- they mean "this file's ends". # Previous/Next run off them into the next file with something to @@ -305,6 +372,7 @@ def _make_actions(self): act.setToolTip('{} ({}) — noise is skipped, {}'.format(text, key, scope)) act.triggered.connect(slot) setattr(self, attr, act) + self._icon_actions.append((act, glyph, None)) # these four live in the diff pane's own header, beside the file name # they step through -- a bar of their own at the bottom repeated the # same "change k of N" the header already shows @@ -317,6 +385,7 @@ def _make_actions(self): self.act_export.setToolTip('Write the full HTML report (Ctrl+E) — always ' 'the complete scan, never the folded view') self.act_export.triggered.connect(self._export_report) + self._icon_actions.append((self.act_export, 'export', ACCENT)) # signing off is a second pass, not part of reading a diff, so the note # box stays out of the way until it is asked for -- it was taking a @@ -326,6 +395,17 @@ def _make_actions(self): self.act_review_mode.setToolTip('Show the note box and sign-off for the ' 'current change') self.act_review_mode.toggled.connect(self._set_review_mode) + self._icon_actions.append((self.act_review_mode, 'review-comment', None)) + + # text and a sun/moon glyph, no shipped icon: the label names where the + # click GOES, and reusing another button's glyph for it would make two + # different commands look like the same one + self.act_theme = QAction(self._theme_label(), self) + self.act_theme.setCheckable(True) + self.act_theme.setChecked(self._theme == theme.LIGHT) + self.act_theme.setToolTip('Switch the viewer between the dark and the ' + 'light colour scheme') + self.act_theme.triggered.connect(self._toggle_theme) # no button of its own: the tick in the review bar IS the button. The # shortcut exists so a review pass can stay on the keyboard -- F8, tick, @@ -344,10 +424,12 @@ def _make_actions(self): self.act_guide.setShortcut('F1') self.act_guide.setToolTip('How to use the viewer (F1)') self.act_guide.triggered.connect(lambda: show_user_guide(self)) + self._icon_actions.append((self.act_guide, 'report', None)) self.act_notes = QAction(icon('review-resolved'), 'Release notes', self) self.act_notes.setToolTip("What changed in this and earlier versions") self.act_notes.triggered.connect(lambda: show_release_notes(self)) + self._icon_actions.append((self.act_notes, 'review-resolved', None)) self.act_about = QAction(app_icon(), 'About', self) self.act_about.setToolTip('Version, author and license') @@ -375,6 +457,9 @@ def _build_toolbar(self): spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) tb.addWidget(spacer) + # over on the right with Help: both are about the tool, not about the + # compare, and the left half of the bar is for the latter + tb.addAction(self.act_theme) # the three help pages behind one menu on the right: they are read once # and then never again, so three permanent buttons were spending the # top bar on the rarest thing in the window @@ -445,9 +530,7 @@ def _review_bar(self): self.btn_file_reviewed.setCursor(Qt.PointingHandCursor) self.btn_file_reviewed.clicked.connect(self._toggle_file_reviewed) self.review_where = QLabel('') - self.review_where.setStyleSheet('color:#8a8f98; font-size:11px;') self.review_file = QLabel('') - self.review_file.setStyleSheet('color:#6f757e; font-size:11px;') side = QVBoxLayout() side.setContentsMargins(0, 0, 0, 0) @@ -580,18 +663,25 @@ def _load_review(self): def _show_review_file(self): path = self._reviews.path if path is None: + # the stylesheet is set even with nothing to show: this label is + # red while a review file is broken, and a theme switch that left + # the old red behind would outlive the state that earned it self.review_file.setText('') + self.review_file.setStyleSheet('color:{}; font-size:11px;' + .format(theme.c('fg-muted'))) return if self._reviews.error: self.review_file.setText('⚠ {}'.format(path.name)) - self.review_file.setStyleSheet('color:#ff9d9d; font-size:11px;') + self.review_file.setStyleSheet('color:{}; font-size:11px;' + .format(theme.c('st-err'))) self.review_file.setToolTip('{}\n\n{}\n\nNothing is loaded from it ' 'and nothing will be written over it. ' 'Fix or remove the file, then rescan.' .format(path, self._reviews.error)) else: self.review_file.setText(path.name) - self.review_file.setStyleSheet('color:#6f757e; font-size:11px;') + self.review_file.setStyleSheet('color:{}; font-size:11px;' + .format(theme.c('fg-muted'))) self.review_file.setToolTip('Notes and sign-offs are saved to\n{}' .format(path)) @@ -759,10 +849,12 @@ def _checkout(self, root, sub, commit): # --- scan lifecycle --- - # a folded category disappears from BOTH places it shows: the file's verdict - # (status -> Identical/Modified) and the lines in the diff panes. Leaving a - # wall of coloured rows in the code after saying those do not count was - # the worst of both. + # switching a category off changes BOTH places it shows: the file's verdict + # (status -> Identical/Modified) and how its lines are painted in the diff + # panes -- greyed out, and dropped from the minimap and from F7/F8. Leaving + # a wall of red and green in the code after saying those do not count was + # the worst of both; taking the lines away instead cost the context the + # remaining changes have to be read in. _FOLD_MODE = {'comment-only': 'comment', 'ignorable-only': 'minor'} def _fold(self): @@ -840,7 +932,7 @@ def _apply_rules(self): keep = self._selected_rel() fold = self._fold() self.results = apply_fold(self._raw_results, fold) - self.diff.set_fold_modes([self._FOLD_MODE[f] for f in fold]) + self.diff.set_muted_modes([self._FOLD_MODE[f] for f in fold]) self._refresh_tree() self._reselect(keep) # keep the reviewer on the file they were reading if self._autoselect: @@ -898,15 +990,19 @@ def _export_report(self): # the ARXML/A2L report lists files, not individual changes, so # there is nothing for a per-change note to attach to page = build_arxml_report(self._raw_results, self.old, self.new, - old_label=self._old_label) + old_label=self._old_label, + theme_name=self._theme) else: # only pass the store when it holds something: an untouched # review would otherwise add a "0 of N Reviewed" badge to every # report, for a feature that run never used store = self._reviews if (self._reviews.any_entries() or self._reviews.error) else None + # the report opens in whatever the viewer is showing, and + # carries both palettes so the reader can still switch page = build_report(self._raw_results, self.old, self.new, store, - old_label=self._old_label) + old_label=self._old_label, + theme_name=self._theme) Path(out).write_text(page, encoding='utf-8') except Exception as e: QMessageBox.critical(self, 'Export failed', @@ -944,9 +1040,9 @@ def _refresh_tree_keep_selection(self): def _fill_tree(self, nodes): def add(parent, node, prefix): - marker, label, color = STATUS[node.status] + marker, label, _role = STATUS[node.status] item = QTreeWidgetItem(['{} {}'.format(marker, node.name), label]) - brush = QBrush(QColor(color)) + brush = QBrush(QColor(status_color(node.status))) item.setForeground(0, brush) item.setForeground(1, brush) rel = node.rel or (prefix + node.name) @@ -1016,11 +1112,11 @@ def _paint_review(self, item, done, total): # or NOT-compared file has nothing anyone could have read, and a # free "done" on it is exactly the false all-clear to avoid. item.setText(REVIEW_COL, '—') - item.setForeground(REVIEW_COL, QBrush(QColor('#5a5d63'))) + item.setForeground(REVIEW_COL, QBrush(QColor(theme.c('fg-muted')))) item.setToolTip(REVIEW_COL, 'Nothing here can be signed off.') return item.setText(REVIEW_COL, '{}/{}'.format(done, total)) - item.setForeground(REVIEW_COL, QBrush(QColor(REVIEW_COLOR[state]))) + item.setForeground(REVIEW_COL, QBrush(QColor(review_color(state)))) item.setToolTip(REVIEW_COL, '{} of {} change(s) reviewed'.format(done, total)) # --- right-click: open the file where it really lives --- @@ -1178,60 +1274,69 @@ def _on_select(self): # chrome styling. Deliberately narrow: the diff editors, the minimap and the # per-file tree colours are painted in code, and a stylesheet rule on their -# items would override those verdict colours. +# items would override those verdict colours. Colours are named as theme roles +# and filled in by apply_theme, so there is one QSS for both schemes. _QSS = """ -QToolBar#main { background:#25262a; border:0; border-bottom:1px solid #34363c; - padding:4px 12px 4px 6px; spacing:2px; } +QToolBar#main {{ background:{chrome-bg}; border:0; border-bottom:1px solid {border}; + padding:4px 12px 4px 6px; spacing:2px; }} /* the Help button carries a menu: without room for it the arrow is clipped against the window edge */ -QToolBar#main QToolButton::menu-indicator { subcontrol-position: right center; - subcontrol-origin: padding; right:-2px; } -QToolBar#main QToolButton { padding:5px 10px; border-radius:6px; color:#d7d7d7; } -QToolBar#main QToolButton:hover { background:#34363c; } -QToolBar#main QToolButton:pressed { background:#3d404a; } +QToolBar#main QToolButton::menu-indicator {{ subcontrol-position: right center; + subcontrol-origin: padding; right:-2px; }} +QToolBar#main QToolButton {{ padding:5px 10px; border-radius:6px; color:{icon-tint}; }} +QToolBar#main QToolButton:hover {{ background:{chrome-hover}; }} +QToolBar#main QToolButton:pressed {{ background:{chrome-pressed}; }} /* Review mode is a MODE: without a lit checked state the button looks the same on as off, and the note box appearing is the only clue it worked */ -QToolBar#main QToolButton:checked { background:#343a63; color:#e8e8ff; } -QToolBar#main QToolButton:checked:hover { background:#454c80; } -QFrame#reviewbar { background:#212226; border-top:1px solid #34363c; } -QFrame#reviewbar QPlainTextEdit { background:#232427; border:1px solid #3a3c42; - border-radius:6px; padding:4px 6px; color:#d4d4d4; } -QFrame#reviewbar QPlainTextEdit:focus { border:1px solid #7c8cf8; } -QFrame#reviewbar QPlainTextEdit:disabled { background:#1f2023; color:#6a6a6a; - border:1px solid #2f3136; } -QToolButton#primary { background:#343a63; } -QToolButton#primary:hover { background:#454c80; } -QToolButton#primary:disabled { background:#2b2d33; } -QTreeWidget { border:1px solid #34363c; border-radius:6px; } -QTreeWidget::item { padding:2px 0; } -QTreeWidget::item:selected { background:#3a4a7a; } -QHeaderView::section { background:#2a2c31; color:#b9b9b9; border:0; - border-right:1px solid #34363c; padding:4px 6px; } -QLineEdit { background:#232427; border:1px solid #3a3c42; border-radius:6px; - padding:5px 8px; } -QLineEdit:focus { border:1px solid #7c8cf8; } -QSplitter::handle { background:#34363c; } -QSplitter::handle:horizontal { width:3px; } -QSplitter::handle:vertical { height:3px; } -QStatusBar { background:#25262a; color:#b0b0b0; border-top:1px solid #34363c; } -QProgressBar { background:#232427; border:1px solid #3a3c42; border-radius:6px; - text-align:center; color:#d0d0d0; } -QProgressBar::chunk { background:#4F46E5; border-radius:5px; } -QCheckBox { spacing:6px; } +QToolBar#main QToolButton:checked {{ background:{chrome-checked-bg}; + color:{chrome-checked-fg}; }} +QToolBar#main QToolButton:checked:hover {{ background:{chrome-checked-hover}; }} +QFrame#reviewbar {{ background:{chrome-bar-bg}; border-top:1px solid {border}; }} +QFrame#reviewbar QPlainTextEdit {{ background:{code-bg}; border:1px solid {border}; + border-radius:6px; padding:4px 6px; color:{fg}; }} +QFrame#reviewbar QPlainTextEdit:focus {{ border:1px solid {accent-2}; }} +QFrame#reviewbar QPlainTextEdit:disabled {{ background:{chrome-disabled-bg}; + color:{chrome-disabled-fg}; border:1px solid {border}; }} +QToolButton#primary {{ background:{chrome-checked-bg}; color:{chrome-checked-fg}; }} +QToolButton#primary:hover {{ background:{chrome-checked-hover}; }} +QToolButton#primary:disabled {{ background:{chrome-disabled-bg}; + color:{chrome-disabled-fg}; }} +QTreeWidget {{ border:1px solid {border}; border-radius:6px; }} +QTreeWidget::item {{ padding:2px 0; }} +QTreeWidget::item:selected {{ background:{tree-selected}; }} +QHeaderView::section {{ background:{header-bg}; color:{header-fg}; border:0; + border-right:1px solid {border}; padding:4px 6px; }} +QLineEdit {{ background:{code-bg}; border:1px solid {border}; border-radius:6px; + padding:5px 8px; }} +QLineEdit:focus {{ border:1px solid {accent-2}; }} +QSplitter::handle {{ background:{border}; }} +QSplitter::handle:horizontal {{ width:3px; }} +QSplitter::handle:vertical {{ height:3px; }} +QStatusBar {{ background:{chrome-bg}; color:{status-fg}; border-top:1px solid {border}; }} +QProgressBar {{ background:{code-bg}; border:1px solid {border}; border-radius:6px; + text-align:center; color:{fg}; }} +QProgressBar::chunk {{ background:{progress-chunk}; border-radius:5px; }} +QCheckBox {{ spacing:6px; }} /* find strip: a band of its own between the header and the code, so it reads as a tool over the diff rather than as part of the file being read */ -QWidget#findbar { background:#212226; border-top:1px solid #34363c; - border-bottom:1px solid #34363c; } -QWidget#findbar QToolButton { color:#9aa1ad; padding:2px 6px; border-radius:4px; } -QWidget#findbar QToolButton:hover { background:#34363c; color:#e8e8e8; } +QWidget#findbar {{ background:{chrome-bar-bg}; border-top:1px solid {border}; + border-bottom:1px solid {border}; }} +QWidget#findbar QToolButton {{ color:{st-ign}; padding:2px 6px; border-radius:4px; }} +QWidget#findbar QToolButton:hover {{ background:{chrome-hover}; color:{fg-strong}; }} """ -def _apply_dark(app): - """Fusion dark palette so the viewer matches the report's dark identity.""" +def apply_theme(app): + """Palette and stylesheet for the whole application, in the current theme. + + Fusion in both directions: the native Windows style ignores most of a + palette, so a light run under it would come out as a half-themed window + rather than a light one. + """ app.setStyle('Fusion') p = QPalette() - bg, base, text = QColor('#1e1f22'), QColor('#232427'), QColor('#d4d4d4') + bg, base, text = (QColor(theme.c('bg')), QColor(theme.c('code-bg')), + QColor(theme.c('fg'))) p.setColor(QPalette.Window, bg) p.setColor(QPalette.Base, base) p.setColor(QPalette.AlternateBase, bg) @@ -1239,13 +1344,15 @@ def _apply_dark(app): p.setColor(QPalette.WindowText, text) p.setColor(QPalette.Button, base) p.setColor(QPalette.ButtonText, text) - p.setColor(QPalette.Highlight, QColor('#3a5a7a')) - p.setColor(QPalette.HighlightedText, QColor('#ffffff')) + p.setColor(QPalette.ToolTipBase, base) + p.setColor(QPalette.ToolTipText, text) + p.setColor(QPalette.Highlight, QColor(theme.c('tree-selected'))) + p.setColor(QPalette.HighlightedText, QColor(theme.c('fg-strong'))) # the filter box's "Filter by path…" placeholder: Fusion fades it so far it - # is barely legible on the dark field, so set an explicit, readable grey - p.setColor(QPalette.PlaceholderText, QColor('#9aa1ad')) + # is barely legible, so set an explicit, readable grey + p.setColor(QPalette.PlaceholderText, QColor(theme.c('st-ign'))) app.setPalette(p) - app.setStyleSheet(_QSS) + app.setStyleSheet(_QSS.format(**theme.palette())) def _taskbar_identity(): @@ -1259,16 +1366,18 @@ def _taskbar_identity(): pass # not Windows, or the call is unavailable: cosmetic either way -def run_viewer(old=None, new=None, exclude=(), arxml_only=False): +def run_viewer(old=None, new=None, exclude=(), arxml_only=False, + theme_name=theme.DEFAULT): app = QApplication.instance() owns = app is None if owns: _taskbar_identity() app = QApplication(sys.argv[:1]) - _apply_dark(app) + theme.set_current(theme_name) + apply_theme(app) app.setApplicationName('CodeGen Compare') app.setWindowIcon(app_icon()) - win = MainWindow(old, new, exclude, arxml_only) + win = MainWindow(old, new, exclude, arxml_only, theme_name) win.show() return app.exec() if owns else 0 diff --git a/compare_tool/qtviewer/dialogs.py b/compare_tool/qtviewer/dialogs.py index 0053c81..fa84f24 100644 --- a/compare_tool/qtviewer/dialogs.py +++ b/compare_tool/qtviewer/dialogs.py @@ -56,20 +56,27 @@ ## 4. Fold the noise - Untick `Comment` / `Unimportant` -- affected files re-judge instantly -- Folded lines collapse to `⋯ N lines hidden` -- Tick back on to bring them back +- Those lines are **greyed out, not removed**: they stay where they are, keep + their line numbers, and lose their red/green +- They also drop off the minimap and out of `F7` / `F8`, so nothing sends you + back to them +- Tick back on to bring the colour back - Real changes can never be folded away ## 5. Read the diff - Baseline on the left, Current on the right, scrolled in lockstep - Minimap on the right edge -- click or drag to jump -- C and ARXML are syntax-coloured; the code colours never use red or green +- C, ARXML and A2L are syntax-coloured; the code colours never use red or green +- `☀ Light` / `☾ Dark` in the toolbar switches the colour scheme. Start in one + with `--theme dark|light`; an exported report opens in the same one and + carries its own switch | Row colour | Meaning | |---|---| | red / green | removed / added | | dim red / green | noise: comment, UUID, rename, whitespace | +| flat grey | a category you switched off -- shown, but not a change | | blue | moved block | ## 6. Navigate, find and export diff --git a/compare_tool/qtviewer/diffpane.py b/compare_tool/qtviewer/diffpane.py index 7782a18..d59e00a 100644 --- a/compare_tool/qtviewer/diffpane.py +++ b/compare_tool/qtviewer/diffpane.py @@ -9,7 +9,12 @@ noise = the same pair dimmed, moved = blue, absent side = dim filler); the changed characters inside a line are highlighted at the exact offsets :func:`view_model.char_span` reports, so the pane and the HTML report mark -identical spans. +identical spans. Every colour comes from :mod:`compare_tool.theme`, looked up +when it is painted, so the dark/light switch is a repaint. + +A category the reviewer switches off is *muted*, not removed: the lines keep +their place and stay readable in one flat grey, and only the surfaces that +answer "where are the changes" -- the minimap and F7/F8 -- stop counting them. Foreground is the other channel: :mod:`compare_tool.syntax` colours the code itself, and the two never touch -- the diff owns background, syntax owns text. @@ -25,11 +30,11 @@ QSplitter, QStackedWidget, QTextEdit, QToolButton, QVBoxLayout, QWidget) -from .. import review +from .. import review, theme from ..scanner import looks_binary, read_text from ..syntax import language_for -from ..view_model import (Row, aligned_rows, char_span, collapse_rows, - hunk_row_starts, row_with) +from ..view_model import (MUTED, Row, aligned_rows, char_span, hunk_row_starts, + mute_rows, row_with) from .highlight import CodeHighlighter from .icons import logo_pixmap from .minimap import Minimap @@ -73,7 +78,9 @@ def _semantic_summary(result): chips = [c for c in chips if c] return 'AUTOSAR / A2L: ' + ' · '.join(chips) if chips else '' -# per-side row background by mode; None = context (editor base colour). +# per-side row background by mode, as theme roles; None = context (editor base +# colour). Looked up at paint time, so a theme switch is a repaint and never a +# second copy of this table. # # One colour language: removed is red, added is green, on every category. Noise # (comment banners, UUID churn, renames) used to get purple and yellow of its @@ -82,44 +89,39 @@ def _semantic_summary(result): # at a glance. Noise is the SAME red/green, one notch dimmer: the reviewer still # has to see which hunks inside a Modified file are the ones that count. _ROW_BG = { - ('real', 'old'): '#3a2222', ('real', 'new'): '#1f3a24', - ('comment', 'old'): '#2f2020', ('comment', 'new'): '#1e2f21', - ('minor', 'old'): '#2f2020', ('minor', 'new'): '#1e2f21', - ('moved', 'old'): '#1d2f3e', ('moved', 'new'): '#1d2f3e', - # a folded run of noise: a flat strip, no diff colour -- it stands for - # lines the current compare rules say are not a difference at all - ('folded', 'old'): '#26272b', ('folded', 'new'): '#26272b', + ('real', 'old'): 'del-bg', ('real', 'new'): 'add-bg', + ('comment', 'old'): 'del-bg-dim', ('comment', 'new'): 'add-bg-dim', + ('minor', 'old'): 'del-bg-dim', ('minor', 'new'): 'add-bg-dim', + ('moved', 'old'): 'mv-bg', ('moved', 'new'): 'mv-bg', + # a muted row -- a category the reviewer switched off -- keeps its code but + # loses its diff colour: one flat grey, same on both sides, so the eye + # passes over it on the way to the change that still counts + (MUTED, 'old'): 'muted-bg', (MUTED, 'new'): 'muted-bg', } -# a folded placeholder is not code and gets no diff colour; its text says what -# was folded, so the colour does not have to -_FOLD_FG = {'comment': '#8f96a2', 'other': '#8f96a2'} -# inline changed-span background by mode/side +# inline changed-span background by mode/side. No entry for MUTED on purpose: +# marking the changed characters inside a line the rules no longer report would +# undo the whole point of playing it down. _SEG_BG = { - ('real', 'old'): '#7a2f2f', ('real', 'new'): '#2f6e3d', - ('comment', 'old'): '#5e2a2a', ('comment', 'new'): '#2c5738', - ('minor', 'old'): '#5e2a2a', ('minor', 'new'): '#2c5738', - ('moved', 'old'): '#2f5a7a', ('moved', 'new'): '#2f5a7a', + ('real', 'old'): 'seg-del-bg', ('real', 'new'): 'seg-add-bg', + ('comment', 'old'): 'seg-del-dim-bg', ('comment', 'new'): 'seg-add-dim-bg', + ('minor', 'old'): 'seg-del-dim-bg', ('minor', 'new'): 'seg-add-dim-bg', + ('moved', 'old'): 'seg-mv-bg', ('moved', 'new'): 'seg-mv-bg', } -# translucent overlay marking the change the reviewer is currently on, so -# F7/F8 are visibly doing something even when the file fits on screen and -# there is nothing to scroll -_CUR_BG = QColor(255, 255, 255, 34) -# the find hits. Amber on purpose: red, green and blue already mean removed, +_ZOOM_MIN, _ZOOM_MAX = 6, 24 # point size clamp for Ctrl+wheel zoom + +# The find hits are amber on purpose: red, green and blue already mean removed, # added and moved, so a fourth hue is the only way a search result can be told # apart from a verdict about the code. Every occurrence is marked, the one the # counter is pointing at brighter -- "3 of 8" is only useful if the other seven -# are visible too. -_FIND_BG = QColor('#5a4715') -_FIND_CUR_BG = QColor('#8f7220') -# OLD/NEW pane-banner accents: one source, used for both the tag text and the -# underline so the two can never drift apart -_OLD_ACCENT = '#c98b8b' -_NEW_ACCENT = '#8ec69a' -_FILLER_BG = '#26272b' # the absent side of an insert/delete -_ADD_BG = '#1f3a24' -_DEL_BG = '#3a2222' -_ZOOM_MIN, _ZOOM_MAX = 6, 24 # point size clamp for Ctrl+wheel zoom -_BASE_BG = '#232427' +# are visible too. (Roles: find-bg / find-cur-bg.) +# +# 'cur-row' is the translucent wash over the change the reviewer is on, so +# F7/F8 are visibly doing something even when the file fits on screen and there +# is nothing to scroll. + + +def _qc(role): + return QColor(theme.c(role)) class _Gutter(QWidget): @@ -162,14 +164,18 @@ def __init__(self): f = QFont('Consolas', 10) f.setStyleHint(QFont.Monospace) self.setFont(f) - self.setStyleSheet('QPlainTextEdit{{background:{};color:#d4d4d4;' - 'border:none;}}'.format(_BASE_BG)) + self.apply_theme() self._nos = [] # per block: line-number string ('' for padding) self._gutter = _Gutter(self) self.blockCountChanged.connect(lambda _n: self._update_gutter_width()) self.updateRequest.connect(self._on_update_request) self._update_gutter_width() + def apply_theme(self): + self.setStyleSheet('QPlainTextEdit{{background:{};color:{};' + 'border:none;}}'.format(theme.c('code-bg'), + theme.c('code-fg'))) + def wheelEvent(self, event): if event.modifiers() & Qt.ControlModifier: self.zoomStep.emit(1 if event.angleDelta().y() > 0 else -1) @@ -213,11 +219,11 @@ def resizeEvent(self, event): def paint_gutter(self, event): painter = QPainter(self._gutter) - painter.fillRect(event.rect(), QColor('#1e1f22')) + painter.fillRect(event.rect(), _qc('gutter-bg')) block = self.firstVisibleBlock() top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() bottom = top + self.blockBoundingRect(block).height() - painter.setPen(QColor('#6a6a6a')) + painter.setPen(_qc('gutter-fg')) h = self.fontMetrics().height() while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): @@ -254,7 +260,6 @@ def __init__(self): self._msg = QLabel(_HINT) self._msg.setAlignment(Qt.AlignCenter) self._msg.setWordWrap(True) - self._msg.setStyleSheet('color:#b9b9b9; font-size:13px;') msg_page = QWidget() ml = QVBoxLayout(msg_page) ml.setSpacing(18) @@ -264,7 +269,6 @@ def __init__(self): ml.addStretch(1) self._header = QLabel('') - self._header.setStyleSheet('color:#e8e8e8; font-weight:bold;') # navigation lives in THIS row, beside the file name it steps through -- # not in a bar of its own at the bottom, which repeated the same # "change k of N" this header already carries for four small buttons' @@ -280,7 +284,6 @@ def __init__(self): head_row.addLayout(self.nav_actions) self._sem = QLabel('') self._sem.setWordWrap(True) - self._sem.setStyleSheet('color:#9a9a9a; padding:0 10px 6px; font-size:12px;') self._sem.setVisible(False) self.old_edit = DiffEditor() self.new_edit = DiffEditor() @@ -294,8 +297,10 @@ def __init__(self): # the right, so which side is which is unmistakable at a glance. Each # banner is wrapped INTO the splitter pane, so it tracks the split when # the reviewer drags the divider. - self._old_name = self._pane_banner(_OLD_ACCENT) - self._new_name = self._pane_banner(_NEW_ACCENT) + self._old_name = QLabel('') + self._new_name = QLabel('') + for lbl in (self._old_name, self._new_name): + lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) self._split = QSplitter(Qt.Horizontal) self._split.addWidget(self._pane(self._old_name, self.old_edit)) self._split.addWidget(self._pane(self._new_name, self.new_edit)) @@ -333,9 +338,12 @@ def __init__(self): self.rows = [] self._stops = [] # first row of each reviewable change block - self._fold = () # row modes folded out of the panes + self._muted = () # row modes played down in the panes self._units = [] # review.Unit per change, same order as _stops self._rel = None # file currently shown + # what show_file was last called with, so a theme switch can re-render + # the same file from the same arguments instead of half-repainting it + self._last = None self._old_label = None # (text, tooltip) when OLD is not a folder self._cur_idx = 0 # which change (index into _stops / _units) self._head_base = '' # header without the "change k of N" suffix @@ -358,17 +366,78 @@ def __init__(self): QShortcut(QKeySequence.Find, self).activated.connect(self.open_find) QShortcut(QKeySequence(Qt.Key_F3), self).activated.connect(self.find_next) QShortcut(QKeySequence('Shift+F3'), self).activated.connect(self.find_prev) - - @staticmethod - def _pane_banner(accent): - # neutral dark strip, coloured only in the OLD/NEW tag text and a thin + self._style_widgets() + + # --- theme --- + + def _style_widgets(self): + """Every stylesheet this pane sets by hand, in one place, so a theme + switch is one call rather than a hunt through the constructor.""" + self._msg.setStyleSheet('color:{}; font-size:13px;' + .format(theme.c('fg-dim'))) + self._header.setStyleSheet('color:{}; font-weight:bold;' + .format(theme.c('fg-strong'))) + self._sem.setStyleSheet('color:{}; padding:0 10px 6px; font-size:12px;' + .format(theme.c('fg-dim'))) + self._find_count.setStyleSheet('color:{}; font-size:12px;' + .format(theme.c('st-ign'))) + # neutral strip, coloured only in the OLD/NEW tag text and a thin # underline -- a full red/green band would read as a changed diff row - lbl = QLabel('') - lbl.setStyleSheet( - 'background:#2a2c31; color:{}; padding:5px 10px; font-weight:bold; ' - 'font-size:13px; border-bottom:2px solid {};'.format(accent, accent)) - lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - return lbl + for lbl, role in ((self._old_name, 'pane-old-accent'), + (self._new_name, 'pane-new-accent')): + lbl.setStyleSheet( + 'background:{}; color:{}; padding:5px 10px; font-weight:bold; ' + 'font-size:13px; border-bottom:2px solid {};' + .format(theme.c('pane-banner-bg'), theme.c(role), + theme.c(role))) + + def apply_theme(self): + """Repaint everything this pane owns in the current theme. + + The file on screen is re-rendered from ``show_file``'s own arguments: + the row backgrounds are stamped into the document as block formats, so + a stylesheet swap alone would leave the previous theme's red and green + sitting in the code.""" + self._style_widgets() + for editor, hl in ((self.old_edit, self._hl_old), + (self.new_edit, self._hl_new)): + editor.apply_theme() + hl.apply_theme() + self.minimap.update() + if self._last is None or self.currentIndex() != 1: + return + at = self.reading_position() + self.show_file(*self._last) + self.restore_reading_position(at) + + def reading_position(self): + """Where the reviewer is in the file on screen, as an opaque token. + + Re-rendering a file parks on its first change, and a repaint is not a + navigation command -- so anything that re-renders has to take this + first and hand it back afterwards. None when there is no file to hold + a position in.""" + if self.currentIndex() != 1: + return None + return (self._rel, self._drive.textCursor().blockNumber(), + self._drive.verticalScrollBar().value()) + + def restore_reading_position(self, at): + """Put the cursor, the current-change overlay, the `change k of N` and + the scroll back where :meth:`reading_position` found them. + + Ignored when the file changed underneath it: landing a stale row number + on a different file would scroll somewhere arbitrary.""" + if not at or at[0] != self._rel or self.currentIndex() != 1: + return + _rel, row, scroll = at + if row < len(self.rows): + self._drive.setTextCursor( + QTextCursor(self._drive.document().findBlockByNumber(row))) + self._highlight_block(row) + self._update_position(row) + self._drive.verticalScrollBar().setValue(scroll) + self.unitChanged.emit() @staticmethod def _pane(banner, editor): @@ -404,7 +473,6 @@ def _build_find_bar(self): self._find_edit.returnPressed.connect(self.find_next) self._find_edit.installEventFilter(self) self._find_count = QLabel('') - self._find_count.setStyleSheet('color:#9aa1ad; font-size:12px;') close = QToolButton() close.setText('✕') close.setToolTip('Close the find bar (Esc)') @@ -525,7 +593,7 @@ def _mark_matches(self): continue # the other side of a one-sided file block = doc.findBlockByNumber(row) line = block.text().lower() - colour = _FIND_CUR_BG if row == cur else _FIND_BG + colour = _qc('find-cur-bg' if row == cur else 'find-bg') at = line.find(needle) while at >= 0: sel = QTextEdit.ExtraSelection() @@ -563,16 +631,16 @@ def _set_pane_names(self, old_root, new_root): """Name each pane by its folder: a coloured BASELINE/CURRENT tag then the folder name, bright, with the full path as a tooltip. Called wherever a file is shown, so the two roots are in hand.""" - for lbl, root, tag, accent in ( - (self._old_name, old_root, 'BASELINE', _OLD_ACCENT), - (self._new_name, new_root, 'CURRENT', _NEW_ACCENT)): + for lbl, root, tag, role in ( + (self._old_name, old_root, 'BASELINE', 'pane-old-accent'), + (self._new_name, new_root, 'CURRENT', 'pane-new-accent')): p = Path(root) name, tip = p.name or str(p), str(p) if tag == 'BASELINE' and self._old_label: name, tip = self._old_label[0], self._old_label[1] or str(p) lbl.setText('{}' - '  ·  {}' - .format(accent, tag, name)) + '  ·  {}' + .format(theme.c(role), tag, theme.c('fg-strong'), name)) lbl.setToolTip(tip) # --- scroll sync: equal block counts make it a straight mirror --- @@ -608,12 +676,18 @@ def _zoom_by(self, step): # --- public seam --- - def set_fold_modes(self, modes): - """Row modes to fold out of the panes -- the same categories the - compare rules stop reporting. Unticking `Unimportant` should not leave - a wall of yellow lines in the code: if those differences do not count, - showing them is noise. Takes effect on the next ``show_file``.""" - self._fold = tuple(modes) + def set_muted_modes(self, modes): + """Row modes to play down in the panes -- the same categories the + compare rules stop reporting. + + Unticking `Unimportant` should not leave a wall of red and green in the + code claiming to be changes; but taking those lines away costs the + reviewer the context the surviving hunks are read in, and a regenerated + file is mostly banner churn. So they are greyed, not removed: still + readable, no diff colour, and gone from the minimap and from F7/F8. + + Takes effect on the next ``show_file``.""" + self._muted = tuple(modes) def clear(self): self._logo.setVisible(False) @@ -627,6 +701,7 @@ def clear(self): def _forget_units(self): self._rel = None + self._last = None self._units = [] self._cur_idx = 0 self.unitChanged.emit() @@ -652,6 +727,7 @@ def show_file(self, rel, result, old_root, new_root): self._rel = rel self._units = [] self._cur_idx = 0 + self._last = (rel, result, old_root, new_root) try: self._show_file(rel, result, old_root, new_root) except Exception as e: @@ -739,10 +815,10 @@ def _show_file(self, rel, result, old_root, new_root): # come from the hunks, so what a note is attached to never depends on # which categories happen to be folded on screen self._units = review.units_of(result, old_p, new_p, old_lines, new_lines) - self.rows, row_map = collapse_rows(rows, self._fold) - self._load_rows(rel, status, result, row_map) + self.rows = mute_rows(rows, self._muted) + self._load_rows(rel, status, result) - def _load_rows(self, rel, status, result=None, row_map=None): + def _load_rows(self, rel, status, result=None): rows = self.rows n_moved = sum(1 for r in rows if r.mode == 'moved') # header names the file only -- the verdict (real-change / identical / @@ -777,37 +853,30 @@ def _load_rows(self, rel, status, result=None, row_map=None): for i, r in enumerate(rows): if r.mode == 'ctx': continue - if r.mode == 'folded': - fg = _FOLD_FG['comment' if r.kind == 'comment' else 'other'] - for editor in (self.old_edit, self.new_edit): - self._block_bg(editor, i, _ROW_BG[('folded', 'old')]) - self._block_fg(editor, i, fg) - continue # old side if r.old_txt is None: - self._block_bg(self.old_edit, i, _FILLER_BG) + self._block_bg(self.old_edit, i, theme.c('filler-bg')) else: - self._block_bg(self.old_edit, i, _ROW_BG.get((r.mode, 'old'))) + self._block_bg(self.old_edit, i, self._bg(r.mode, 'old')) # new side if r.new_txt is None: - self._block_bg(self.new_edit, i, _FILLER_BG) + self._block_bg(self.new_edit, i, theme.c('filler-bg')) else: - self._block_bg(self.new_edit, i, _ROW_BG.get((r.mode, 'new'))) - # inline highlight only when both sides present - if r.old_txt is not None and r.new_txt is not None: + self._block_bg(self.new_edit, i, self._bg(r.mode, 'new')) + # inline highlight only when both sides present, and never on a + # muted row: there is no _SEG_BG entry for it, so this stays quiet + if r.mode != MUTED and r.old_txt is not None and r.new_txt is not None: (o_lo, o_hi), (n_lo, n_hi) = char_span(r.old_txt, r.new_txt) - self._seg_bg(self.old_edit, i, o_lo, o_hi, _SEG_BG.get((r.mode, 'old'))) - self._seg_bg(self.new_edit, i, n_lo, n_hi, _SEG_BG.get((r.mode, 'new'))) + self._seg_bg(self.old_edit, i, o_lo, o_hi, self._seg(r.mode, 'old')) + self._seg_bg(self.new_edit, i, n_lo, n_hi, self._seg(r.mode, 'new')) # navigation stops: the first row of each reviewable change, one per # hunk. Deriving them from the hunk list rather than from runs of # coloured rows is what makes "change 3 of 7", the hunk count the CLI # prints and the units a review note attaches to all the same thing. + # Muting never moves a row, so these indices need no translation -- + # and a muted category is absent from _units anyway, so F7/F8 skip it. starts = hunk_row_starts((result or {}).get('hunks') or []) - # the starts are positions in the UNFOLDED layout; row_map carries them - # over. Real and moved rows are never folded, so a stop always lands on - # the change itself and never inside a placeholder. - self._stops = [row_map[starts[u.index]] if row_map else starts[u.index] - for u in self._units + self._stops = [starts[u.index] for u in self._units if u.index is not None and u.index < len(starts)] self._cur_idx = 0 self.setCurrentIndex(1) @@ -838,9 +907,9 @@ def _load_one_side(self, rel, label, lines, side): self._header.setText(self._head_base) edit = self.old_edit if side == 'old' else self.new_edit other = self.new_edit if side == 'old' else self.old_edit - bg = _DEL_BG if side == 'old' else _ADD_BG - # a whole added/deleted file is all one mode, so there are no folded - # placeholders to skip -- the language is the only thing to pass on + bg = theme.c('del-bg' if side == 'old' else 'add-bg') + # a whole added/deleted file is all one mode, so there are no muted + # rows to grey out -- the language is the only thing to pass on lang = language_for(rel) self._hl_old.configure(lang, (), repaint=False) self._hl_new.configure(lang, (), repaint=False) @@ -857,6 +926,16 @@ def _load_one_side(self, rel, label, lines, side): self.minimap.set_rows(self.rows) self.setCurrentIndex(1) + @staticmethod + def _bg(mode, side): + role = _ROW_BG.get((mode, side)) + return theme.c(role) if role else None + + @staticmethod + def _seg(mode, side): + role = _SEG_BG.get((mode, side)) + return theme.c(role) if role else None + @staticmethod def _set_text(editor, text): """Replace an editor's contents, caret formatting reset first. @@ -880,14 +959,6 @@ def _block_bg(self, editor, block_no, color): fmt.setBackground(QColor(color)) cursor.setBlockFormat(fmt) - def _block_fg(self, editor, block_no, color): - block = editor.document().findBlockByNumber(block_no) - cursor = QTextCursor(block) - cursor.select(QTextCursor.BlockUnderCursor) - fmt = QTextCharFormat() - fmt.setForeground(QColor(color)) - cursor.mergeCharFormat(fmt) - def _seg_bg(self, editor, block_no, lo, hi, color): if not color or lo >= hi: return @@ -952,7 +1023,7 @@ def _highlight_block(self, row): continue for i in range(start, end + 1): sel = QTextEdit.ExtraSelection() - sel.format.setBackground(_CUR_BG) + sel.format.setBackground(_qc('cur-row')) sel.format.setProperty(QTextFormat.FullWidthSelection, True) cur = QTextCursor(editor.document().findBlockByNumber(i)) cur.clearSelection() diff --git a/compare_tool/qtviewer/highlight.py b/compare_tool/qtviewer/highlight.py index 4d7049a..cdf0058 100644 --- a/compare_tool/qtviewer/highlight.py +++ b/compare_tool/qtviewer/highlight.py @@ -10,25 +10,27 @@ **No red, no green.** Those two mean removed and added here. The palette is blue / teal / amber / lilac, muted enough to stay legible on the red and green -row fills rather than competing with them. +row fills rather than competing with them. The actual values live in +:mod:`compare_tool.theme` -- one per role per theme -- so switching to the light +page does not need a second copy of this mapping. """ from PySide6.QtGui import QColor, QSyntaxHighlighter, QTextCharFormat -from .. import syntax +from .. import syntax, theme -# token kind -> (colour, italic). Deliberately low-saturation: these sit on top -# of diff fills, and a bright token colour there reads as another change. +# token kind -> (theme role, italic). Deliberately low-saturation: these sit on +# top of diff fills, and a bright token colour there reads as another change. _PALETTE = { - syntax.COMMENT: ('#8f96a2', True), - syntax.STRING: ('#e0a860', False), - syntax.NUMBER: ('#c5a3e8', False), - syntax.KEYWORD: ('#7aa2e3', False), - syntax.TYPE: ('#57b6a9', False), - syntax.PREPROC: ('#b58ac4', False), - syntax.CALL: ('#d8c99a', False), - syntax.TAG: ('#7aa2e3', False), - syntax.ATTR: ('#57b6a9', False), + syntax.COMMENT: ('syn-comment', True), + syntax.STRING: ('syn-string', False), + syntax.NUMBER: ('syn-number', False), + syntax.KEYWORD: ('syn-keyword', False), + syntax.TYPE: ('syn-type', False), + syntax.PREPROC: ('syn-preproc', False), + syntax.CALL: ('syn-call', False), + syntax.TAG: ('syn-tag', False), + syntax.ATTR: ('syn-attr', False), } # above this the pane stays plain: rehighlighting runs on the GUI thread, and a @@ -38,30 +40,43 @@ def _formats(): out = {} - for kind, (colour, italic) in _PALETTE.items(): + for kind, (role, italic) in _PALETTE.items(): fmt = QTextCharFormat() - fmt.setForeground(QColor(colour)) + fmt.setForeground(QColor(theme.c(role))) if italic: fmt.setFontItalic(True) out[kind] = fmt return out +def _muted_format(): + """One flat grey for a whole muted line. + + A row the compare rules no longer report is still on screen, and syntax + colour on it would undo the point of playing it down -- so the code channel + goes quiet too, not just the diff background.""" + fmt = QTextCharFormat() + fmt.setForeground(QColor(theme.c('muted-fg'))) + return fmt + + class CodeHighlighter(QSyntaxHighlighter): - """Colours one editor's document, skipping rows that are not code. - - ``modes`` is the row-mode list the pane is showing, in block order. Its - only job is to spot ``folded`` placeholders (`⋯ 12 uuid lines hidden`): - those are not code, and -- more importantly -- the lines they stand for are - gone, so a `/*` whose `*/` was folded away would otherwise turn every line - below into a comment. A fold therefore resets the state: worst case a real - comment loses its colour, which is cosmetic, where the other direction - looks like a finding. + """Colours one editor's document, playing down rows that do not count. + + ``modes`` is the row-mode list the pane is showing, in block order. Its job + is to spot ``muted`` rows -- the noise categories the reviewer has switched + off -- and paint those in one flat grey instead of syntax colours. The line + is still there and still readable; it just stops competing with the change + beside it. + + The block state is still computed from the real text of a muted row, so a + `/* ... */` running across one keeps the lines below it correctly coloured. """ def __init__(self, document): super().__init__(document) self._fmt = _formats() + self._muted = _muted_format() self._language = None self._modes = () @@ -77,20 +92,27 @@ def configure(self, language, modes=(), repaint=True): if repaint: self.rehighlight() + def apply_theme(self): + """Re-read the palette after a theme switch and repaint.""" + self._fmt = _formats() + self._muted = _muted_format() + self.rehighlight() + def highlightBlock(self, text): - if self._language is None: - return n = self.currentBlock().blockNumber() - if n >= MAX_LINES: - return - if n < len(self._modes) and self._modes[n] == 'folded': - self.setCurrentBlockState(syntax.PLAIN) + muted = n < len(self._modes) and self._modes[n] == 'muted' + if muted and text: + # the grey has to win even with no language: a muted plain-text row + # must not keep the editor's normal foreground + self.setFormat(0, len(text), self._muted) + if self._language is None or n >= MAX_LINES: return prev = self.previousBlockState() state = prev if prev in (syntax.PLAIN, syntax.IN_BLOCK_COMMENT) else syntax.PLAIN spans, state = syntax.spans(text, self._language, state) - for start, end, kind in spans: - fmt = self._fmt.get(kind) - if fmt is not None: - self.setFormat(start, end - start, fmt) + if not muted: + for start, end, kind in spans: + fmt = self._fmt.get(kind) + if fmt is not None: + self.setFormat(start, end - start, fmt) self.setCurrentBlockState(state) diff --git a/compare_tool/qtviewer/icons.py b/compare_tool/qtviewer/icons.py index a8e4331..48ab1b0 100644 --- a/compare_tool/qtviewer/icons.py +++ b/compare_tool/qtviewer/icons.py @@ -13,11 +13,14 @@ from PySide6.QtCore import QByteArray, Qt from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap +from .. import theme from ..resources import icon_file, logo_file -# icon tint on the dark chrome, and the accent used for the primary action -TINT = '#d7d7d7' -ACCENT = '#7c8cf8' +# theme roles: the icon tint, and the accent the primary action wears. Roles, +# not literals, because the tint has to flip with the chrome -- a light grey +# glyph is invisible on the light theme's toolbar. +TINT = 'icon-tint' +ACCENT = 'accent-2' _cache = {} @@ -31,8 +34,12 @@ def _tinted(pm, color): return out -def icon(name, color=TINT, size=20): - """Tinted :class:`QIcon` for a shipped glyph; empty when it is missing.""" +def icon(name, role=TINT, size=20): + """Tinted :class:`QIcon` for a shipped glyph; empty when it is missing. + + ``role`` is a theme role, resolved now -- so the same call made again after + a theme switch returns a glyph tinted for the new chrome.""" + color = theme.c(role) key = (name, color, size) if key in _cache: return _cache[key] @@ -46,14 +53,14 @@ def icon(name, color=TINT, size=20): return ico -def std_icon(widget, standard_pixmap, color=TINT, size=20): +def std_icon(widget, standard_pixmap, role=TINT, size=20): """A Qt built-in icon, tinted to match the shipped set. Windows' own icons are full colour (a blue folder, a red help ring) and would read as decorations dropped into an otherwise monochrome toolbar. """ pm = widget.style().standardIcon(standard_pixmap).pixmap(size, size) - return QIcon(_tinted(pm, color)) if not pm.isNull() else QIcon() + return QIcon(_tinted(pm, theme.c(role))) if not pm.isNull() else QIcon() def app_icon(): @@ -68,22 +75,24 @@ def app_icon(): # The shipped lockup's wordmark is near-black on transparent -- correct on a -# white page, all but invisible on this app's dark chrome. So the SVG twin of -# logo-full.png is re-rendered with a light wordmark; the gradient mark itself -# is untouched. The PNG stays the fallback when Qt's SVG module is absent. +# white page, all but invisible on the dark chrome. So on the dark theme the +# SVG twin of logo-full.png is re-rendered with a light wordmark; the gradient +# mark itself is untouched, and the light theme wants the asset as shipped. The +# PNG stays the fallback when Qt's SVG module is absent. _DARK_WORDMARK = (('fill="#0f172a"', 'fill="#e9edf5"'), ('fill="#475569"', 'fill="#9fb0c6"')) -def _dark_lockup(width): +def _svg_lockup(width): path = logo_file('logo-full.svg') if path is None: return None try: from PySide6.QtSvg import QSvgRenderer svg = path.read_text(encoding='utf-8') - for old, new in _DARK_WORDMARK: - svg = svg.replace(old, new) + if theme.current() == theme.DARK: + for old, new in _DARK_WORDMARK: + svg = svg.replace(old, new) r = QSvgRenderer(QByteArray(svg.encode('utf-8'))) if not r.isValid(): return None @@ -103,7 +112,7 @@ def _dark_lockup(width): def logo_pixmap(width=320, name='logo-full.png'): """Horizontal lockup for the landing page and the About box; None if the asset is not shipped with this install.""" - pm = _dark_lockup(width) + pm = _svg_lockup(width) if pm is not None: return pm path = logo_file(name) diff --git a/compare_tool/qtviewer/minimap.py b/compare_tool/qtviewer/minimap.py index bcb1023..326c9f4 100644 --- a/compare_tool/qtviewer/minimap.py +++ b/compare_tool/qtviewer/minimap.py @@ -14,28 +14,28 @@ from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import QWidget +from .. import theme + _WIDTH = 92 -_BG = '#202124' _MAX_LINE_H = 4.0 # px per line at most; short files render small, VS Code-like _MAX_CHAR_W = 3.0 # px per char at most, so short lines don't stretch full width # dim token colour per mode (ctx = plain code grey); changed rows also get a # translucent full-width strip so diffs pop on the map. Noise shares the change # colour, dimmer -- same one-colour-language rule as the panes (see _ROW_BG). -_TOKEN = {'ctx': '#565b62', 'real': '#e8908d', 'comment': '#a4706e', - 'minor': '#a4706e', 'moved': '#7fb0d9', 'folded': '#4a4e55'} -# a folded placeholder is not a change on the map either: it stands for lines -# the current compare rules say are not a difference, so it gets no strip and -# collapses away with the context rows when the map is compressed -_NOT_A_CHANGE = ('ctx', 'folded') +_TOKEN = {'ctx': 'map-ctx', 'real': 'map-real', 'comment': 'map-noise', + 'minor': 'map-noise', 'moved': 'map-moved', 'muted': 'map-muted'} +# a muted row is not a change on the map either. That is the point of switching +# a category off: the lines stay readable in the panes, but the map -- the +# "where are the changes" surface -- stops pointing at them, exactly like +# F7/F8 already do. +_NOT_A_CHANGE = ('ctx', 'muted') _STRIP = { - 'real': QColor(217, 82, 79, 70), - 'comment': QColor(217, 82, 79, 40), - 'minor': QColor(217, 82, 79, 40), - 'moved': QColor(63, 127, 176, 70), + 'real': 'map-strip-real', + 'comment': 'map-strip-noise', + 'minor': 'map-strip-noise', + 'moved': 'map-strip-moved', } -_VIEW_FILL = QColor(255, 255, 255, 26) -_VIEW_BORDER = QColor(190, 190, 190, 120) def _row_text(r): @@ -92,7 +92,9 @@ def _visible_rows(self): def paintEvent(self, event): p = QPainter(self) - p.fillRect(self.rect(), QColor(_BG)) + # colours are read on every paint, not cached: a theme switch is then + # just an update() away, with no state of its own to keep in step + p.fillRect(self.rect(), QColor(theme.c('map-bg'))) rows = self._rows n = len(rows) if not n: @@ -111,8 +113,9 @@ def paintEvent(self, event): continue prev_y = y if is_change: - p.fillRect(0, y, self.width(), bh, _STRIP.get(r.mode, _STRIP['real'])) - token = QColor(_TOKEN.get(r.mode, _TOKEN['ctx'])) + p.fillRect(0, y, self.width(), bh, + QColor(theme.c(_STRIP.get(r.mode, _STRIP['real'])))) + token = QColor(theme.c(_TOKEN.get(r.mode, _TOKEN['ctx']))) self._paint_tokens(p, _row_text(r), y, cw, bh, token) self._paint_viewport(p, n, h, lh) @@ -135,8 +138,9 @@ def _paint_viewport(self, p, n, h, lh): count = self._visible_rows() y0 = int(first * lh) y1 = int(min((first + count) * lh, n * lh)) - p.fillRect(0, y0, self.width(), max(y1 - y0, 2), _VIEW_FILL) - p.setPen(_VIEW_BORDER) + p.fillRect(0, y0, self.width(), max(y1 - y0, 2), + QColor(theme.c('map-view-fill'))) + p.setPen(QColor(theme.c('map-view-border'))) p.drawRect(0, y0, self.width() - 1, max(y1 - y0 - 1, 2)) # --- interaction: click / drag scrolls the driven editor --- diff --git a/compare_tool/qtviewer/summary.py b/compare_tool/qtviewer/summary.py index 4b3e896..16ef26e 100644 --- a/compare_tool/qtviewer/summary.py +++ b/compare_tool/qtviewer/summary.py @@ -8,12 +8,14 @@ from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import QHeaderView, QTreeWidget, QTreeWidgetItem +from .. import theme from .summary_model import summary_sections REL_ROLE = Qt.UserRole KEY_ROLE = Qt.UserRole + 1 -_SIGN_COLOR = {'+': '#7bd88a', '−': '#ff7b7b', '~': '#7fb3d9'} +# added / removed / changed, in the same colours the tree and the report use +_SIGN_COLOR = {'+': 'st-add', '−': 'st-real', '~': 'mv-fg'} _EMPTY = 'No AUTOSAR / A2L changes' @@ -34,8 +36,15 @@ def __init__(self): header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setStretchLastSection(True) self.itemClicked.connect(self._on_click) + self._results = {} + + def apply_theme(self): + """Item colours are set per row, so a theme switch has to rebuild them + -- from the results the panel was last given, never from the widget.""" + self.set_results(self._results) def set_results(self, results): + self._results = results self.clear() sections = summary_sections(results) if results else [] if not sections: @@ -46,14 +55,14 @@ def set_results(self, results): font = head.font(0) font.setBold(True) head.setFont(0, font) - head.setForeground(0, QBrush(QColor('#dcdcaa'))) + head.setForeground(0, QBrush(QColor(theme.c('accent')))) self.addTopLevelItem(head) for row in rows: item = QTreeWidgetItem(['{} {}'.format(row.sign, row.name), row.detail]) - item.setForeground(0, QBrush(QColor(_SIGN_COLOR.get(row.sign, - '#d4d4d4')))) - item.setForeground(1, QBrush(QColor('#9a9a9a'))) + item.setForeground(0, QBrush(QColor(theme.c( + _SIGN_COLOR.get(row.sign, 'fg'))))) + item.setForeground(1, QBrush(QColor(theme.c('fg-dim')))) item.setToolTip(0, row.rel) item.setData(0, REL_ROLE, row.rel) item.setData(0, KEY_ROLE, row.key) diff --git a/compare_tool/qtviewer/tree.py b/compare_tool/qtviewer/tree.py index 90d3c56..84cf293 100644 --- a/compare_tool/qtviewer/tree.py +++ b/compare_tool/qtviewer/tree.py @@ -7,30 +7,45 @@ from collections import namedtuple -# status -> (tree marker, display label, hex colour). Mirrors the HTML +from .. import theme + +# status -> (tree marker, display label, theme role). Mirrors the HTML # report's verdict vocabulary (Modified / Unimportant / Added / Deleted / -# Identical) and its colours so the viewer and the report read the same. +# Identical) and reads its colours from the SAME roles the report's CSS does, +# so the viewer and the report cannot disagree about what Modified looks like +# -- in either theme. STATUS = { - 'real-change': ('≠', 'Modified', '#ff7b7b'), # not-equal sign + 'real-change': ('≠', 'Modified', 'st-real'), # not-equal sign # the two noise verdicts are grey on purpose: grey is what "this does not # count" looks like, and it keeps red/green meaning removed/added only - 'comment-only': ('≉', 'Comment', '#8f96a2'), # comments only - 'ignorable-only': ('≈', 'Unimportant', '#9aa1ad'), # almost-equal - 'added': ('+', 'Added', '#7bd88a'), - 'deleted': ('−', 'Deleted', '#c88ad8'), # minus sign - 'identical': ('=', 'Identical', '#8a8a8a'), - 'error': ('!', 'NOT compared', '#ff5c5c'), + 'comment-only': ('≉', 'Comment', 'st-cmt'), # comments only + 'ignorable-only': ('≈', 'Unimportant', 'st-ign'), # almost-equal + 'added': ('+', 'Added', 'st-add'), + 'deleted': ('−', 'Deleted', 'st-del'), # minus sign + 'identical': ('=', 'Identical', 'st-id'), + 'error': ('!', 'NOT compared', 'st-err'), } + +def status_color(status): + """The current theme's colour for a verdict.""" + return theme.c(STATUS[status][2]) + # folder verdict = most significant child verdict; an uncompared 'error' path # outranks everything so a folder hiding one can never look clean PRIO = {'error': 6, 'real-change': 5, 'ignorable-only': 4, 'comment-only': 3, 'added': 2, 'deleted': 2, 'identical': 1} -# review progress -> colour, for the tree column that only exists in review +# review progress -> theme role, for the tree column that only exists in review # mode. Same three colours as the status chip on the status bar: green is a # finished state, amber is in flight, grey is nothing yet. -REVIEW_COLOR = {'done': '#7bd88a', 'partial': '#e2c16b', 'none': '#8a8f98'} +REVIEW_COLOR = {'done': 'review-done', 'partial': 'review-partial', + 'none': 'review-none'} + + +def review_color(state): + """The current theme's colour for a review-progress state.""" + return theme.c(REVIEW_COLOR[state]) def review_state(reviewed, total): diff --git a/compare_tool/report.py b/compare_tool/report.py index cad9d13..cf97f5a 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -1,11 +1,17 @@ -"""Self-contained HTML report. Summary badges toggle each change category.""" +"""Self-contained HTML report. Summary badges toggle each change category. + +Every colour comes from :mod:`compare_tool.theme` as a CSS custom property, and +BOTH palettes are written into the page -- so the reader's dark/light button is +an attribute flip with nothing to fetch, on a machine with no internet, which is +where these reports are usually opened. +""" import datetime import html import re from pathlib import Path -from . import review +from . import review, theme from .diff_engine import ruleset_for from .scanner import (looks_binary, read_text, summarize, summarize_a2l, summarize_ifaces, summarize_rte, summarize_swcs) @@ -15,172 +21,242 @@ MAX_CONTENT = 400 # max lines shown for added/deleted file content _CSS = """ -body { font-family: Segoe UI, Arial, sans-serif; background: #1e1f22; color: #d4d4d4; +body { font-family: Segoe UI, Arial, sans-serif; background: var(--bg); color: var(--fg); margin: 0; padding: 24px; } -h1 { font-size: 20px; } h2 { font-size: 15px; margin: 28px 0 6px; color: #e8e8e8; } -.meta { color: #9a9a9a; font-size: 13px; margin-bottom: 4px; } +h1 { font-size: 20px; } h2 { font-size: 15px; margin: 28px 0 6px; color: var(--fg-strong); } +.meta { color: var(--fg-dim); font-size: 13px; margin-bottom: 4px; } .summary { margin: 14px 0 22px; } .badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; margin-right: 8px; cursor: pointer; user-select: none; border: 1px solid transparent; } -.badge:hover { border-color: #888; } +.badge:hover { border-color: var(--fg-muted); } .badge.off { opacity: .35; text-decoration: line-through; } -.b-real { background: #6e2b2b; color: #ffb3b3; } .b-ign { background: #3a3b40; color: #c3c7cd; } -.b-id { background: #333; color: #aaa; } +.b-real { background: var(--tag-real-bg); color: var(--tag-real-fg); } +.b-ign { background: var(--tag-ign-bg); color: var(--tag-ign-fg); } +.b-id { background: var(--tag-id-bg); color: var(--tag-id-fg); } /* added and deleted share one control: both are "a whole file appeared or vanished", and a reviewer flips them together */ -.b-adddel { background: #33404a; color: #cfe0ec; } -.bgroup + .bgroup { border-left: 1px solid #43454c; margin-left: 4px; padding-left: 18px; } -.b-err { background: #7a1f1f; color: #ffc2c2; border-color: #b04a4a; cursor: default; } -.b-ok { background: #2b5232; color: #a8e6b0; cursor: default; } -.errbox { background: #4a1d1d; border: 1px solid #b04a4a; border-radius: 6px; - padding: 10px 14px; margin: 14px 0 20px; color: #ffd6d6; font-size: 13px; } +.b-adddel { background: var(--tag-adddel-bg); color: var(--tag-adddel-fg); } +.bgroup + .bgroup { border-left: 1px solid var(--border-strong); margin-left: 4px; + padding-left: 18px; } +.b-err { background: var(--tag-err-bg); color: var(--tag-err-fg); + border-color: var(--err-border); cursor: default; } +.b-ok { background: var(--tag-add-bg); color: var(--tag-add-fg); cursor: default; } +.errbox { background: var(--err-bg); border: 1px solid var(--err-border); border-radius: 6px; + padding: 10px 14px; margin: 14px 0 20px; color: var(--err-fg); font-size: 13px; } .errbox .errtitle { font-weight: 700; font-size: 14px; margin-bottom: 6px; } .errbox div { padding: 1px 0; } -.errbox code { background: #5c2626; } -.hint { color: #7a7a7a; font-size: 11px; margin: -14px 0 18px; } +.errbox code { background: var(--err-code-bg); color: var(--err-fg); } +.hint { color: var(--fg-faint); font-size: 11px; margin: -14px 0 18px; } body.hide-real .sec-real, body.hide-ign .sec-ign, body.hide-add .sec-add, body.hide-del .sec-del { display: none; } ul.files { margin: 4px 0 14px; padding-left: 22px; font-size: 13px; } ul.files li { margin: 2px 0; } -.kinds { color: #8a8a8a; font-size: 12px; } -.tree { font-family: Consolas, monospace; font-size: 13px; background: #232427; - border: 1px solid #333; border-radius: 6px; padding: 10px 14px; margin: 0 0 20px; } +.kinds { color: var(--fg-muted); font-size: 12px; } +.tree { font-family: Consolas, monospace; font-size: 13px; background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; margin: 0 0 20px; } .tree details.dir > summary { cursor: pointer; list-style: none; padding: 1px 0; - user-select: none; color: #dcdcaa; } + user-select: none; color: var(--accent); } .tree details.dir > summary::-webkit-details-marker { display: none; } -.tree details.dir > summary::before { content: '▸ '; color: #8a8a8a; } +.tree details.dir > summary::before { content: '▸ '; color: var(--fg-muted); } .tree details.dir[open] > summary::before { content: '▾ '; } .tree details.dir > *:not(summary) { margin-left: 18px; } .tf { padding: 1px 0; } -.tf a { color: inherit; text-decoration: none; border-bottom: 1px dotted #666; cursor: pointer; } -.tf a:hover { color: #fff; } +.tf a { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--link-underline); + cursor: pointer; } +.tf a:hover { color: var(--link-hover); } .tmark { display: inline-block; width: 14px; font-weight: bold; } -.t-real { color: #ff7b7b; } .t-ign { color: #9aa1ad; } .t-add { color: #7bd88a; } -.t-del { color: #c88ad8; } .t-id { color: #777; } .t-err { color: #ff5c5c; } -.t-cmt { color: #8f96a2; } -.tf.tc-cmt { color: #b9bec6; } -.tf.tc-real { color: #ffb3b3; } .tf.tc-ign { color: #c3c7cd; } .tf.tc-add { color: #a8e6b0; } -.tf.tc-del { color: #d9a8e6; text-decoration: line-through; } .tf.tc-id { color: #8a8a8a; } -.tf.tc-err { color: #ffb3b3; font-weight: 700; } -.legend { color: #8a8a8a; font-size: 12px; margin: 2px 0 8px; } +.t-real { color: var(--st-real); } .t-ign { color: var(--st-ign); } +.t-add { color: var(--st-add); } +.t-del { color: var(--st-del); } .t-id { color: var(--st-id); } +.t-err { color: var(--st-err); } +.t-cmt { color: var(--st-cmt); } +.tf.tc-cmt { color: var(--st-cmt-text); } +.tf.tc-real { color: var(--tag-real-fg); } .tf.tc-ign { color: var(--tag-ign-fg); } +.tf.tc-add { color: var(--tag-add-fg); } +.tf.tc-del { color: var(--tag-del-fg); text-decoration: line-through; } +.tf.tc-id { color: var(--st-id); } +.tf.tc-err { color: var(--tag-real-fg); font-weight: 700; } +.legend { color: var(--fg-muted); font-size: 12px; margin: 2px 0 8px; } table.diff { border-collapse: collapse; width: 100%; table-layout: fixed; font-family: Consolas, monospace; font-size: 12px; margin: 6px 0 14px; } table.diff td { padding: 1px 6px; vertical-align: top; white-space: pre-wrap; word-break: break-all; border: none; } -td.ln { width: 44px; color: #6a6a6a; text-align: right; user-select: none; } -td.del { background: #3a2222; } td.add { background: #1f3a24; } -/* Noise (comment, uuid, rename, whitespace) uses the SAME red/green as a real - change, one notch dimmer -- one colour language instead of three. Yellow and - purple were a third and fourth hue competing with the syntax colours for the - reader's attention, and a diff that needs a legend to be read is too loud. - Dimmer, not identical: inside a Modified file the reviewer still has to see - which hunks are the ones that count. */ -td.delm, td.delc { background: #2f2020; } -td.addm, td.addc { background: #1e2f21; } -td.mvd, td.mva { background: #1d2f3e; } -td.ctx { color: #9a9a9a; } -td.del .chg-seg { background: #7a2f2f; color: #ffc2c2; font-weight: 700; border-radius: 2px; } -td.add .chg-seg { background: #2f6e3d; color: #c9f7d1; font-weight: 700; border-radius: 2px; } -td.delm .chg-seg, td.delc .chg-seg { background: #5e2a2a; color: #f0c4c4; font-weight: 700; - border-radius: 2px; } -td.addm .chg-seg, td.addc .chg-seg { background: #2c5738; color: #bfe8c8; font-weight: 700; - border-radius: 2px; } +td.ln { width: 44px; color: var(--ln-fg); text-align: right; user-select: none; } +td.del { background: var(--del-bg); } td.add { background: var(--add-bg); } +/* Unimportant hides behind its own badge, default OFF -- the report opens + on real changes, a click reveals the rest. Revealed rows are flat neutral + grey, not a dim red/green: a toggled-open noise section still has to read + as "off to the side", off the one-colour-language rule real changes and + moved blocks use, not a quieter member of it. No character-level highlight + either (see _row) -- marking what changed inside a line nobody was asked + to read closely would be noise on noise. Comment rows use the same grey + classes but never get a toggle: they stay hidden always (see the CSS + below), only counted in the placeholder, never rendered in the report. */ +td.delm, td.delc, td.addm, td.addc { background: var(--muted-bg); color: var(--muted-fg); } +td.mvd, td.mva { background: var(--mv-bg); } +td.ctx { color: var(--fg-dim); } +td.del .chg-seg { background: var(--seg-del-bg); color: var(--seg-del-fg); font-weight: 700; + border-radius: 2px; } +td.add .chg-seg { background: var(--seg-add-bg); color: var(--seg-add-fg); font-weight: 700; + border-radius: 2px; } .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin: 0 4px 0 2px; vertical-align: -1px; } -.sw-del { background: #7a2f2f; } .sw-add { background: #2f6e3d; } -.sw-mv { background: #2f5a7a; } -tr.gap td { text-align: center; color: #666; background: #26272b; font-size: 11px; } -tr.mvnote td { text-align: center; color: #7fb3d9; background: #26272b; font-size: 11px; } -body.hide-ign tr.minor, body.hide-ign .grp-min { display: none; } +.sw-del { background: var(--seg-del-bg); } .sw-add { background: var(--seg-add-bg); } +.sw-mv { background: var(--seg-mv-bg); } .sw-mut { background: var(--muted-bg); } +tr.gap td { text-align: center; color: var(--gap-fg); background: var(--panel-2); + font-size: 11px; } +tr.mvnote td { text-align: center; color: var(--mv-fg); background: var(--panel-2); + font-size: 11px; } +/* Unimportant rows hide per ROW, not per group: a group used to be wrapped + whole and hidden together, which took the placeholder below and the + ordinary context lines around it down with it -- a noise-only file opened + to an empty box under its own summary line. Comment rows are never shown + in the report -- unlike Unimportant they have no badge and no toggle, only + the placeholder below stating how many lines were hidden, so a comment + change is never silently dropped from the record, just never rendered. */ +body.hide-ign tr.minor { display: none; } +tr.comment { display: none; } tr.minorph { display: none; } body.hide-ign tr.minorph { display: table-row; } -/* Comment churn is not a reported category here at all -- a regenerated banner - is the noisiest and least informative thing a codegen diff produces. The rows - stay IN the file, because the report is the record, but they are never shown; - the placeholder always states how many lines were folded, so nothing is - silently dropped. (The viewer still shows them in full -- it is the reading - surface, this is the record to send.) */ -tr.comment, .grp-cmt { display: none; } -tr.commentph { display: table-row; color: #8f96a2; } -tr.minorph td { color: #8f96a2; } -.filenote { color: #8a8a8a; font-size: 12px; margin: 2px 0 10px; } -.renames { font-size: 12px; color: #9aa1ad; margin: 2px 0 8px; } -.iflist { font-family: Consolas, monospace; font-size: 13px; background: #232427; - border: 1px solid #333; border-radius: 6px; padding: 10px 14px; margin: 0 0 20px; } +tr.commentph { display: table-row; } +tr.minorph td, tr.commentph td { color: var(--st-cmt); } +.filenote { color: var(--fg-muted); font-size: 12px; margin: 2px 0 10px; } +.renames { font-size: 12px; color: var(--st-ign); margin: 2px 0 8px; } +.iflist { font-family: Consolas, monospace; font-size: 13px; background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; + margin: 0 0 20px; } .iflist div { padding: 1px 0; } -.if-add { color: #7bd88a; } .if-del { color: #ff7b7b; } -.iflist a { color: #9a9a9a; text-decoration: none; border-bottom: 1px dotted #666; - cursor: pointer; } -.iflist a:hover { color: #fff; } -.ifnote { font-size: 12px; color: #7fb3d9; margin: 2px 0 8px; } -code { background: #2b2c30; padding: 1px 5px; border-radius: 4px; } -details.file { margin: 10px 0; border: 1px solid #333; border-radius: 6px; background: #232427; } +.if-add { color: var(--st-add); } .if-del { color: var(--st-real); } +.iflist a { color: var(--fg-dim); text-decoration: none; + border-bottom: 1px dotted var(--link-underline); cursor: pointer; } +.iflist a:hover { color: var(--link-hover); } +.ifnote { font-size: 12px; color: var(--mv-fg); margin: 2px 0 8px; } +code { background: var(--panel-3); padding: 1px 5px; border-radius: 4px; } +details.file { margin: 10px 0; border: 1px solid var(--border); border-radius: 6px; + background: var(--panel); } details.file > summary { list-style: none; cursor: pointer; padding: 10px 14px; - font-size: 15px; color: #e8e8e8; display: flex; align-items: center; gap: 10px; user-select: none; } + font-size: 15px; color: var(--fg-strong); display: flex; align-items: center; gap: 10px; + user-select: none; } details.file > summary::-webkit-details-marker { display: none; } -details.file > summary::before { content: '▶'; font-size: 10px; color: #8a8a8a; transition: transform .15s; } +details.file > summary::before { content: '▶'; font-size: 10px; color: var(--fg-muted); + transition: transform .15s; } details.file[open] > summary::before { transform: rotate(90deg); } -details.file > summary:hover { background: #2a2b2f; } +details.file > summary:hover { background: var(--panel-hover); } details.file > .body { padding: 0 14px 12px; } -summary .hcount { color: #8a8a8a; font-size: 12px; font-weight: normal; } +summary .hcount { color: var(--fg-muted); font-size: 12px; font-weight: normal; } summary .tag { display: inline-block; padding: 1px 8px; border-radius: 8px; font-size: 11px; } -.tag-real { background: #6e2b2b; color: #ffb3b3; } .tag-ign { background: #3a3b40; color: #c3c7cd; } -.tag-cmt { background: #33353a; color: #b9bec6; } -.tag-add { background: #2b5232; color: #a8e6b0; } .tag-del { background: #4a2b52; color: #d9a8e6; } -.tag-err { background: #7a1f1f; color: #ffc2c2; } -.hunklabel { color: #9aa1ad; font-size: 11px; margin: 10px 0 0; text-transform: uppercase; +.tag-real { background: var(--tag-real-bg); color: var(--tag-real-fg); } +.tag-ign { background: var(--tag-ign-bg); color: var(--tag-ign-fg); } +.tag-cmt { background: var(--tag-cmt-bg); color: var(--tag-cmt-fg); } +.tag-add { background: var(--tag-add-bg); color: var(--tag-add-fg); } +.tag-del { background: var(--tag-del-bg); color: var(--tag-del-fg); } +.tag-err { background: var(--tag-err-bg); color: var(--tag-err-fg); } +.hunklabel { color: var(--st-ign); font-size: 11px; margin: 10px 0 0; text-transform: uppercase; letter-spacing: .5px; } .toolbar { margin: 4px 0 16px; } -.toolbar button { background: #2b2c30; color: #d4d4d4; border: 1px solid #444; border-radius: 4px; +.toolbar button { background: var(--btn-bg); color: var(--btn-fg); + border: 1px solid var(--btn-border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; margin-right: 6px; } -.toolbar button:hover { background: #35363b; } -#flt { background: #2b2c30; color: #d4d4d4; border: 1px solid #444; border-radius: 4px; - padding: 4px 10px; font-size: 12px; width: 280px; margin-left: 10px; } -#flt:focus { outline: none; border-color: #6a6a6a; } +.toolbar button:hover { background: var(--btn-hover); } +#flt { background: var(--btn-bg); color: var(--btn-fg); border: 1px solid var(--btn-border); + border-radius: 4px; padding: 4px 10px; font-size: 12px; width: 280px; margin-left: 10px; } +#flt:focus { outline: none; border-color: var(--btn-focus); } +/* the dark/light switch. Fixed, top right, out of the reading column: it is a + preference about the page, not a fact about the compare, so it must not sit + among the verdict badges where it would read as one. */ +#thm { position: fixed; top: 14px; right: 18px; z-index: 9; background: var(--btn-bg); + color: var(--btn-fg); border: 1px solid var(--btn-border); border-radius: 6px; + padding: 4px 10px; font-size: 12px; cursor: pointer; + font-family: Segoe UI, Arial, sans-serif; } +#thm:hover { background: var(--btn-hover); } table.ov { border-collapse: collapse; font-size: 13px; margin: 4px 0 22px; } -table.ov th { text-align: left; color: #8a8a8a; font-weight: normal; font-size: 12px; - padding: 3px 18px 4px 0; border-bottom: 1px solid #3a3b40; } -table.ov td { padding: 5px 18px 5px 0; border-bottom: 1px solid #2c2d31; vertical-align: top; } -table.ov a { color: #dcdcaa; text-decoration: none; border-bottom: 1px dotted #666; - cursor: pointer; } -table.ov a:hover { color: #fff; } +table.ov th { text-align: left; color: var(--fg-muted); font-weight: normal; font-size: 12px; + padding: 3px 18px 4px 0; border-bottom: 1px solid var(--border-strong); } +table.ov td { padding: 5px 18px 5px 0; border-bottom: 1px solid var(--border-soft); + vertical-align: top; } +table.ov a { color: var(--accent); text-decoration: none; + border-bottom: 1px dotted var(--link-underline); cursor: pointer; } +table.ov a:hover { color: var(--link-hover); } .cnt { margin-right: 10px; white-space: nowrap; } -.cnt-real { color: #ffb3b3; } .cnt-add { color: #a8e6b0; } .cnt-del { color: #d9a8e6; } -.cnt-ign { color: #c3c7cd; } .cnt-id { color: #8a8a8a; } .cnt-err { color: #ff9d9d; font-weight: 700; } -.cnt-cmt { color: #b9bec6; } -.aut { color: #9a9a9a; } -.aut .a-add { color: #7bd88a; } .aut .a-del { color: #ff7b7b; } .aut .a-chg { color: #7fb3d9; } -.ifgroup { color: #8a8a8a; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; - margin: 8px 0 2px; } +.cnt-real { color: var(--tag-real-fg); } .cnt-add { color: var(--tag-add-fg); } +.cnt-del { color: var(--tag-del-fg); } +.cnt-ign { color: var(--tag-ign-fg); } .cnt-id { color: var(--st-id); } +.cnt-err { color: var(--st-err); font-weight: 700; } +.cnt-cmt { color: var(--st-cmt-text); } +.aut { color: var(--fg-dim); } +.aut .a-add { color: var(--st-add); } .aut .a-del { color: var(--st-real); } +.aut .a-chg { color: var(--mv-fg); } +.ifgroup { color: var(--fg-muted); font-size: 11px; text-transform: uppercase; + letter-spacing: .5px; margin: 8px 0 2px; } .iflist .ifgroup:first-child { margin-top: 0; } -.if-chg { color: #7fb3d9; } -details.model { margin: 16px 0; border: 1px solid #3a3b40; border-radius: 8px; - background: #202124; } +.if-chg { color: var(--mv-fg); } +details.model { margin: 16px 0; border: 1px solid var(--border-strong); border-radius: 8px; + background: var(--panel-alt); } details.model > summary { list-style: none; cursor: pointer; padding: 9px 14px; - font-size: 15px; color: #dcdcaa; user-select: none; display: flex; + font-size: 15px; color: var(--accent); user-select: none; display: flex; align-items: center; gap: 10px; } details.model > summary::-webkit-details-marker { display: none; } -details.model > summary::before { content: '▶'; font-size: 10px; color: #8a8a8a; +details.model > summary::before { content: '▶'; font-size: 10px; color: var(--fg-muted); transition: transform .15s; } details.model[open] > summary::before { transform: rotate(90deg); } -details.model > summary:hover { background: #26272b; } +details.model > summary:hover { background: var(--panel-2); } details.model > .mbody { padding: 0 12px 10px; } summary .mcounts { font-size: 12px; font-weight: normal; } -.b-rev { background: #274a45; color: #9fe0cf; } -.rvnote { background: #22302e; border-left: 3px solid #3f8f7a; border-radius: 4px; - padding: 6px 10px; margin: 8px 0 2px; font-size: 12px; color: #cfe6df; - white-space: pre-wrap; } -.rvnote .rvtag { color: #7fd3ba; font-weight: 700; margin-right: 8px; } -.rvnote .rvwhere { color: #7d8f8b; margin-right: 8px; font-family: Consolas, monospace; } -.rvnote.pending { background: #2d2b21; border-left-color: #8a7a3f; color: #e6dcc0; } -.rvnote.pending .rvtag { color: #d8c07a; } -.rvcheck { color: #5f9e8b; } +.b-rev { background: var(--tag-rev-bg); color: var(--tag-rev-fg); } +.rvnote { background: var(--note-bg); border-left: 3px solid var(--note-border); + border-radius: 4px; padding: 6px 10px; margin: 8px 0 2px; font-size: 12px; + color: var(--note-fg); white-space: pre-wrap; } +.rvnote .rvtag { color: var(--note-tag); font-weight: 700; margin-right: 8px; } +.rvnote .rvwhere { color: var(--note-where); margin-right: 8px; + font-family: Consolas, monospace; } +.rvnote.pending { background: var(--note-pending-bg); + border-left-color: var(--note-pending-border); color: var(--note-pending-fg); } +.rvnote.pending .rvtag { color: var(--note-pending-tag); } +.rvcheck { color: var(--note-check); } /* the Reviewed badge hides what has already been signed off, so the next pass shows only what is left. It starts SHOWN: the report is the record, and a record that opens with real changes already hidden is not one. */ body.hide-rev .grp-rev, body.hide-rev details.file.file-rev { display: none; } """ +# the switch itself, plus the script behind it. A saved preference wins over +# the flag the report was built with: the flag is the author's default for +# somebody who has never expressed one, and after that it is the reader's eyes. +_THEME_BUTTON = ('') +# `save` is only true on a click. Persisting on load as well would turn the +# --theme flag INTO the reader's preference the first time they open a report +# built with it -- silently answering, on their behalf, a question they never +# answered. +_THEME_JS = ( + 'function sttheme(t,save){document.documentElement.setAttribute("data-theme",t);' + 'var b=document.getElementById("thm");' + 'if(b)b.innerHTML=(t==="dark"?"\\u2600 Light":"\\u263e Dark");' + 'if(save){try{localStorage.setItem("cgc-theme",t);}catch(e){}}}' + 'function tgtheme(){sttheme(document.documentElement.getAttribute("data-theme")' + '==="dark"?"light":"dark",true);}' + '(function(){var t=null;try{t=localStorage.getItem("cgc-theme");}catch(e){}' + 'sttheme(t==="dark"||t==="light"?t' + ':document.documentElement.getAttribute("data-theme"),false);})();') + + +def _head(title, initial, body_class=''): + """Everything up to and including the opening ````. + + BOTH palettes go into the page: a report is mailed around and opened on a + machine that may have no network, so the dark/light switch has to be an + attribute flip with nothing left to fetch. ``initial`` only decides which + one the page opens with. + """ + cls = ' class="{}"'.format(body_class) if body_class else '' + return ('' + '{}{}' + .format(theme.normalize(initial), title, + theme.css_vars(theme.DARK), theme.css_vars(theme.LIGHT), + _CSS, cls, _THEME_BUTTON)) + def _esc(s): return html.escape(s, quote=False) @@ -342,29 +418,20 @@ def _group_notes(group, notes): def _groups_html(old_lines, new_lines, hunks, notes=None): - """All hunk groups of one file. A group with no real/moved hunk is - wrapped in .grp-min so the Unimportant badge hides it (label + context - included); minor rows inside mixed groups hide individually via tr.minor. + """All hunk groups of one file. Comment and Unimportant rows hide behind + their own badge individually (``tr.comment`` / ``tr.minor`` in the CSS) -- + a group used to be wrapped whole and hidden together when every hunk in it + was noise, which took the placeholder and the ordinary context lines + around it down with the rest: a noise-only file opened to an empty box. Moved blocks never hide: they are real changes, just shown in blue. - A group whose every real/moved hunk is signed off also gets .grp-rev, so - the Reviewed badge can fold it away -- with its notes, which belong to the + A group whose every real/moved hunk is signed off gets .grp-rev, so the + Reviewed badge can fold it away -- with its notes, which belong to the changes being hidden.""" out = [] for g in _group_hunks(hunks): - kinds = {h['kind'] for h in g} - # a group hides as a whole only when it is ONE hideable category; a - # group mixing comment with other noise would otherwise vanish behind - # a single badge, so its rows hide individually instead - if kinds == {'comment'}: - cls = ' grp-cmt' - elif not (kinds & {'real', 'moved'}): - cls = ' grp-min' - else: - cls = '' notes_html, done = _group_notes(g, notes) - if done: - cls += ' grp-rev' + cls = ' grp-rev' if done else '' out.append('
'.format(cls)) if any(h['kind'] != 'real' for h in g): out.append('
{}
'.format(_esc(_group_label(g)))) @@ -408,7 +475,9 @@ def _row(o_no, o_txt, n_no, n_txt, mode): dcls, acls = _MODE_CLS[mode] lcls = dcls if o_txt is not None else '' rcls = acls if n_txt is not None else '' - if o_txt is not None and n_txt is not None: + # comment/minor rows are muted grey, not a diff colour, when revealed + # -- so there is no changed SPAN to point at inside them either + if o_txt is not None and n_txt is not None and mode not in _MODE_TR: l, r = _char_diff(o_txt, n_txt) else: l = _esc(o_txt) if o_txt is not None else '' @@ -456,6 +525,15 @@ def _row(o_no, o_txt, n_no, n_txt, mode): _ARXML_SPLIT_RE = re.compile( r'(.+)_(component|datatypes?|interfaces?|implementation|behavior|timing)$', re.IGNORECASE) +# Embedded Coder companion file: _data.c holds the model's constant/ +# calibration tables. Without this, "SWC_data.c" is its own candidate model +# name and -- being longer than "SWC" -- wins the match against itself, +# splitting off into its own (usually <3-file, so Shared) group instead of +# joining SWC's. Dropped only when the base name is evidenced by ANOTHER +# file: a model genuinely called Foo_data, with no Foo.c beside it, keeps +# its own name -- otherwise Rte_Foo_data.h would match nothing and the +# group would be labelled with a model that does not exist. +_C_DATA_RE = re.compile(r'(.+)_data$', re.IGNORECASE) # which statuses get a detail section, and in what order. 'comment-only' and # 'identical' are absent: neither is a reported category in this report, so a # section for them would be markup nothing could ever reveal. Both keep their @@ -472,7 +550,9 @@ def _stem(rel): def _detect_models(paths): """Model-name candidates: X for any X.c, plus X for the modular arxml - export names (X_component.arxml, X_interface.arxml, ...).""" + export names (X_component.arxml, X_interface.arxml, ...). X_data is then + dropped whenever X itself is a candidate, so SWC_data.c joins SWC instead + of out-ranking it -- see _C_DATA_RE.""" cands = set() for rel in paths: low = rel.lower() @@ -482,6 +562,10 @@ def _detect_models(paths): m = _ARXML_SPLIT_RE.match(_stem(rel)) if m: cands.add(m.group(1)) + for c in list(cands): + m = _C_DATA_RE.match(c) + if m and m.group(1) in cands: + cands.discard(c) return cands @@ -949,12 +1033,14 @@ def _safe_file_section(rel, results, old_root, new_root, anchors, rv): '
'.format(_esc(type(e).__name__), _esc(str(e)))) -def build_arxml_report(results, old_root, new_root, old_label=None): +def build_arxml_report(results, old_root, new_root, old_label=None, + theme_name=theme.DEFAULT): """Compact ARXML / A2L update report: did the AUTOSAR model or the calibration surface change, and how. ``old_label`` names the OLD side when its folder does not -- see - :func:`build_report`. + :func:`build_report`. ``theme_name`` is which palette the page opens with; + both are always embedded, and the reader can switch. Only .arxml/.xml/.a2l files are considered; other files in `results` are ignored ('error' entries of any extension always count -- a failed @@ -974,9 +1060,7 @@ def build_arxml_report(results, old_root, new_root, old_label=None): counts = summarize(ax) now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') parts = [] - parts.append('' - 'ARXML / A2L Update Report' - ''.format(_CSS)) + parts.append(_head('ARXML / A2L Update Report', theme_name)) parts.append('

ARXML / A2L Update Report

') parts.append('
{} → {} · {}
'.format( _root_html('BASELINE', old_root, old_label), _root_html('CURRENT', new_root), now)) @@ -1048,17 +1132,23 @@ def build_arxml_report(results, old_root, new_root, old_label=None): 'timestamps / comments / whitespace).

') parts.append(_autosar_section(ax, {})) + parts.append(''.format(_THEME_JS)) parts.append('') return ''.join(parts) -def build_report(results, old_root, new_root, reviews=None, old_label=None): +def build_report(results, old_root, new_root, reviews=None, old_label=None, + theme_name=theme.DEFAULT): """Full self-contained HTML report. ``old_label`` names the OLD side when its folder does not: comparing against a commit checks it out to a temp folder, and the record has to say which commit that was. + ``theme_name`` is which palette the page opens with (``--theme`` on the + CLI). Both are embedded either way, so the reader's own button -- and their + saved preference -- can override it without the file changing. + ``reviews`` is a :class:`compare_tool.review.ReviewStore` or None. When given, every change the reviewer signed off carries its note, and a Reviewed badge folds those changes away so a second pass sees only what is @@ -1110,9 +1200,8 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None): anchors, rv)) parts = [] - parts.append('' - 'AUTOSAR Code Generation Report' - ''.format(_CSS)) + parts.append(_head('AUTOSAR Code Generation Report', theme_name, + body_class='hide-ign')) parts.append('

AUTOSAR Code Generation Report

') parts.append('
{} → {} · {}
'.format( _root_html('BASELINE', old_root, old_label), _root_html('CURRENT', new_root), now)) @@ -1145,7 +1234,9 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None): '{added} Added / {deleted} Deleted' ''.format(**counts) + rev_group + '') hint = ('Click a badge to show/hide a category. Unimportant starts hidden ' - '— only real changes are shown.') + 'and, revealed, shows in grey rather than red/green — only ' + 'real changes keep that colour. Comment changes are never shown ' + 'here, only counted.') if rev_group: hint += (' Reviewed starts shown: click it to hide the changes ' 'already signed off.') @@ -1167,12 +1258,10 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None): if detail_files: parts.append('

Detailed changes

') - # two entries only: noise now shares the red/green of a real change, so - # a swatch for it would describe a colour the reader cannot tell apart, - # and comment rows are never displayed here at all parts.append('
' '/removed / added ' - 'moved block
') + 'moved block ' + 'Unimportant, revealed') parts.append('
' '' '' @@ -1205,6 +1294,7 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None): 'var any=!q;if(!any)mo.querySelectorAll("details.file").forEach(function(d){' 'if(d.style.display!=="none")any=true;});' 'mo.style.display=any?"":"none";});}' + + _THEME_JS + '') parts.append('') return ''.join(parts) diff --git a/compare_tool/syntax.py b/compare_tool/syntax.py index 1d9cd6f..b63521f 100644 --- a/compare_tool/syntax.py +++ b/compare_tool/syntax.py @@ -1,4 +1,4 @@ -"""Syntax token spans for one line of C or ARXML/XML. +"""Syntax token spans for one line of C, ARXML/XML or A2L. Line-at-a-time on purpose: the viewer paints a QTextDocument block by block, and a whole-file lexer would have to be re-run and re-mapped every time a @@ -11,10 +11,15 @@ No Qt, stdlib only: it ships in the zipapp and its tests run headless. -**Only C and XML are covered.** A2L is deliberately not highlighted -- the -format is a flat keyword soup where nearly every line would light up, which is -decoration, not information. An unknown file comes back with no spans and -renders as plain text. +**A2L is coloured from a keyword list, never by shape.** The format is a flat +soup of ALL-CAPS words, so a lazy `[A-Z_]+` rule lights up every line -- +including the calibration object names, which are the one thing a reviewer is +scanning for. Only `/begin` / `/end`, the block name that follows one of them, +and the ASAM keywords and enum literals below get a colour; an identifier stays +plain and therefore stands out. That is how ASAP2 editors show it, and it is +the reason the format is worth highlighting at all. + +An unknown file comes back with no spans and renders as plain text. Strings and comments are found by walking the line, not by regex alternation: a `/*` inside a string literal must not open a comment. Getting that wrong @@ -53,32 +58,89 @@ 'size_t|ptrdiff_t|u?int(?:8|16|32|64)_t' ) -# rules for the stretches that are neither string nor comment; first match wins +_NUMBER_RE = re.compile( + r'\b(?:0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][-+]?\d+)?)' + r'(?:[uUlL]{1,3}|[fF])?\b') + +# rules for the stretches that are neither string nor comment; first match +# wins. Each rule is (kind, regex, group): group 0 is the whole match, a +# higher group colours only part of it -- how a block name is picked out of +# '/begin CHARACTERISTIC' without a lookbehind that would have to guess how +# much whitespace sits between the two. _C_PLAIN = [ - (NUMBER, re.compile( - r'\b(?:0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][-+]?\d+)?)' - r'(?:[uUlL]{1,3}|[fF])?\b')), - (KEYWORD, re.compile(r'\b(?:{})\b'.format(_C_KEYWORDS))), - (TYPE, re.compile(r'\b(?:{}|[A-Za-z_]\w*_T)\b'.format(_C_TYPES))), - (CALL, re.compile(r'\b[A-Za-z_]\w*(?=\s*\()')), + (NUMBER, _NUMBER_RE, 0), + (KEYWORD, re.compile(r'\b(?:{})\b'.format(_C_KEYWORDS)), 0), + (TYPE, re.compile(r'\b(?:{}|[A-Za-z_]\w*_T)\b'.format(_C_TYPES)), 0), + (CALL, re.compile(r'\b[A-Za-z_]\w*(?=\s*\()'), 0), ] _XML_PLAIN = [ # the element name only, without its bracket: , - (TAG, re.compile(r'(?<=<)/?[A-Za-z_][\w.:-]*')), - (ATTR, re.compile(r'\b[A-Za-z_][\w.:-]*(?=\s*=)')), + (TAG, re.compile(r'(?<=<)/?[A-Za-z_][\w.:-]*'), 0), + (ATTR, re.compile(r'\b[A-Za-z_][\w.:-]*(?=\s*=)'), 0), +] + +# ASAM MCD-2 MC attribute keywords: the words that describe an object rather +# than name one. Curated, not '[A-Z_]+' -- see the module docstring. +_A2L_KEYWORDS = ( + 'A2ML_VERSION|ADDR_EPK|ALIGNMENT_BYTE|ALIGNMENT_FLOAT16_IEEE|' + 'ALIGNMENT_FLOAT32_IEEE|ALIGNMENT_FLOAT64_IEEE|ALIGNMENT_INT64|' + 'ALIGNMENT_LONG|ALIGNMENT_WORD|ANNOTATION_LABEL|ANNOTATION_ORIGIN|' + 'ARRAY_SIZE|ASAP2_VERSION|AXIS_PTS_REF|AXIS_PTS_[XYZ45]|' + 'AXIS_RESCALE_[XYZ45]|BIT_MASK|BYTE_ORDER|CALIBRATION_ACCESS|COEFFS|' + 'COEFFS_LINEAR|COMPARISON_QUANTITY|COMPU_TAB_REF|CPU_TYPE|CURVE_AXIS_REF|' + 'CUSTOMER_NO|CUSTOMER|DATA_SIZE|DEFAULT_VALUE_NUMERIC|DEFAULT_VALUE|' + 'DEPOSIT|DISCRETE|DISPLAY_IDENTIFIER|DIST_OP_[XYZ45]|' + 'ECU_ADDRESS_EXTENSION|ECU_ADDRESS|ECU_CALIBRATION_OFFSET|ECU|EPK|' + 'ERROR_MASK|EXTENDED_LIMITS|FIX_AXIS_PAR_DIST|FIX_AXIS_PAR|' + 'FIX_NO_AXIS_PTS_[XYZ45]|FNC_VALUES|FORMAT|FORMULA_INV|FORMULA|' + 'GUARD_RAILS|IDENTIFICATION|LEFT_SHIFT|MATRIX_DIM|MAX_DIFF|MAX_GRAD|' + 'MAX_REFRESH|MODEL_LINK|MONOTONY|NO_AXIS_PTS_[XYZ45]|NO_OF_INTERVALS|' + 'NO_RESCALE_[XYZ45]|NUMBER|OFFSET_[XYZ45]|PHONE_NO|PHYS_UNIT|PROJECT_NO|' + 'READ_ONLY|READ_WRITE|REF_MEMORY_SEGMENT|REF_UNIT|RIGHT_SHIFT|' + 'RIP_ADDR_[WXYZ45]|SHIFT_OP_[XYZ45]|SI_EXPONENTS|SRC_ADDR_[XYZ45]|' + 'STATIC_RECORD_LAYOUT|STATUS_STRING_REF|STEP_SIZE|SUPPLIER|' + 'SYMBOL_TYPE_LINK|SYMBOL_LINK|SYSTEM_CONSTANT|UNIT_CONVERSION|USER|VERSION' +) +# the closed vocabularies: data types, byte orders, conversion and layout +# kinds, access rights. They share the block names' colour because that is what +# they are -- the type of the thing, not its name. +_A2L_LITERALS = ( + 'A_INT64|A_UINT64|ASCII|BIG_ENDIAN|BYTE|CALIBRATION_VARIABLES|CALIBRATION|' + 'CODE|COLUMN_DIR|COM_AXIS|CUBOID|CUB4|CUB5|CURVE_AXIS|CURVE|DATA|DERIVED|' + 'DIRECT|EXCLUDE_FROM_FLASH|EXTERN|FIX_AXIS|FLOAT16_IEEE|FLOAT32_IEEE|' + 'FLOAT64_IEEE|FORM|IDENTICAL|INDEX_DECR|INDEX_INCR|INTERN|LINEAR|' + 'LITTLE_ENDIAN|LONG|MAP|MON_DECREASE|MON_INCREASE|MONOTONOUS|MSB_FIRST|' + 'MSB_LAST|NOT_IN_ECU|NOT_IN_MCD_SYSTEM|NOT_MON|NO_CALIBRATION|' + 'OFFLINE_CALIBRATION|OFFLINE_DATA|PBYTE|PLONG|PWORD|RAT_FUNC|RES_AXIS|' + 'RESERVED|ROW_DIR|SBYTE|SERAM|SLONG|STD_AXIS|STRICT_DECREASE|' + 'STRICT_INCREASE|STRICT_MON|SWORD|TAB_INTP|TAB_NOINTP|TAB_VERB|UBYTE|' + 'ULONG|UWORD|VAL_BLK|VALUE|VARIABLE|WORD|WORM|RO|RW|WO' +) + +_A2L_PLAIN = [ + (KEYWORD, re.compile(r'/(?:begin|end)\b', re.IGNORECASE), 0), + # whatever a block opens or closes IS its type, whether or not this module + # has heard of it -- so a vendor block reads like every other one + (TYPE, re.compile(r'/(?:begin|end)\s+([A-Za-z_]\w*)', re.IGNORECASE), 1), + (NUMBER, _NUMBER_RE, 0), + (KEYWORD, re.compile(r'\b(?:{})\b'.format(_A2L_KEYWORDS)), 0), + (TYPE, re.compile(r'\b(?:{})\b'.format(_A2L_LITERALS)), 0), ] class _Lang: - """Everything that differs between the two languages, in one place.""" + """Everything that differs between the languages, in one place.""" - def __init__(self, plain, block, line_comment=None, quotes='"', escape=False): + def __init__(self, plain, block, line_comment=None, quotes='"', + escape=False, doubled_quote=False, preproc=False): self.plain = plain self.block_open, self.block_close = block self.line_comment = line_comment self.quotes = quotes self.escape = escape + self.doubled_quote = doubled_quote + self.preproc = preproc parts = [re.escape(self.block_open)] if line_comment: parts.insert(0, re.escape(line_comment)) # '//' before '/*' @@ -88,8 +150,14 @@ def __init__(self, plain, block, line_comment=None, quotes='"', escape=False): _LANGS = { 'c': _Lang(_C_PLAIN, ('/*', '*/'), line_comment='//', quotes='"\'', - escape=True), + escape=True, preproc=True), 'arxml': _Lang(_XML_PLAIN, (''), quotes='"\''), + # A2L strings are not C strings: a backslash is a literal character + # (Windows paths appear verbatim) and a quote is escaped by doubling it. + # Treating '\' as an escape would swallow the code after a path ending in + # one -- the same trap a2l_rules.strip_a2l_comments exists to avoid. + 'a2l': _Lang(_A2L_PLAIN, ('/*', '*/'), line_comment='//', quotes='"', + doubled_quote=True), } _PREPROC = re.compile(r'^\s*#\s*\w+') @@ -108,22 +176,25 @@ def language_for(rel): def _plain_spans(text, rules, start, end): """Non-overlapping spans in ``text[start:end]``; earlier rules win.""" taken, out = [], [] - for kind, rx in rules: + for kind, rx, group in rules: for m in rx.finditer(text, start, end): - a, b = m.span() - if a == b or any(a < tb and ta < b for ta, tb in taken): + a, b = m.span(group) + if a < 0 or a == b or any(a < tb and ta < b for ta, tb in taken): continue taken.append((a, b)) out.append((a, b, kind)) return out -def _string_end(text, start, quote, escape): +def _string_end(text, start, quote, escape, doubled=False): """Index just past the closing quote, or len(text) when it never closes. An unterminated string ends at the newline rather than leaking into the next line: C has no multi-line string literals worth the state, and a diff row is shown one line at a time anyway. + + ``doubled`` is A2L's escape rule -- a quote inside a string is written + twice -- so `""` continues the literal instead of ending it. """ i = start + 1 while i < len(text): @@ -131,6 +202,9 @@ def _string_end(text, start, quote, escape): i += 2 continue if text[i] == quote: + if doubled and text[i + 1:i + 2] == quote: + i += 2 + continue return i + 1 i += 1 return len(text) @@ -157,7 +231,7 @@ def spans(text, language, state=PLAIN): return [(0, len(text), COMMENT)], IN_BLOCK_COMMENT pos = close + len(lang.block_close) out.append((0, pos, COMMENT)) - elif lang.line_comment: + elif lang.preproc: # anchored, so it has to be handled before the scan rather than as one # more alternative inside it m = _PREPROC.match(text) @@ -185,7 +259,8 @@ def spans(text, language, state=PLAIN): pos = close + len(lang.block_close) out.append((m.start(), pos, COMMENT)) else: - end = _string_end(text, m.start(), tok, lang.escape) + end = _string_end(text, m.start(), tok, lang.escape, + lang.doubled_quote) out.append((m.start(), end, STRING)) pos = end out.sort() diff --git a/compare_tool/theme.py b/compare_tool/theme.py new file mode 100644 index 0000000..90bc62b --- /dev/null +++ b/compare_tool/theme.py @@ -0,0 +1,361 @@ +"""The colour palettes, in one place, for every surface that paints something. + +Rule 3 of this repo -- one seam per shared decision -- applied to colour. The +HTML report, the Qt panes, the minimap, the folder tree and the syntax +highlighter all used to carry their own hex literals, which is fine until a +second theme exists: then "dark red for a removed line" has to be answered five +times and the five answers drift. + +So every colour is a **role** here, named once, with one value per theme. A role +name is a valid CSS custom-property name on purpose: the report emits the whole +palette as ``--role: value`` pairs and uses ``var(--role)``, while the Qt layer +looks the same role up with :func:`c`. Neither side can invent a colour the +other does not have. + +stdlib only, no Qt, no HTML: this ships in the zipapp and its tests run +headless. + +Two conventions worth knowing before adding a role: + +* Values are ``#rrggbb``, except a handful of overlay roles which are + ``#aarrggbb`` -- Qt's own notation, used for things painted *over* the code + (the current-change wash, the minimap strips). :func:`css_vars` leaves those + out, since CSS spells alpha the other way round and the report does not use + them. +* Red means removed and green means added, in both themes, on every surface. + Noise is the same pair one notch closer to the background, never a fourth + hue -- see the note in ``qtviewer/diffpane.py``. +""" + +DARK = 'dark' +LIGHT = 'light' +THEMES = (DARK, LIGHT) +DEFAULT = DARK + +_DARK = { + # --- page and chrome --- + 'bg': '#1e1f22', + 'fg': '#d4d4d4', + 'fg-strong': '#e8e8e8', + 'fg-dim': '#9a9a9a', + 'fg-muted': '#8a8a8a', + 'fg-faint': '#7a7a7a', + 'panel': '#232427', + 'panel-alt': '#202124', + 'panel-2': '#26272b', + 'panel-3': '#2b2c30', + 'panel-hover': '#2a2b2f', + 'border': '#34363c', + 'border-soft': '#2c2d31', + 'border-strong': '#43454c', + 'accent': '#dcdcaa', + 'accent-2': '#7c8cf8', + 'link-underline': '#666666', + 'link-hover': '#ffffff', + + # --- verdict colours (tree marks, counts, status text) --- + 'st-real': '#ff7b7b', + 'st-cmt': '#8f96a2', + 'st-cmt-text': '#b9bec6', + 'st-ign': '#9aa1ad', + 'st-add': '#7bd88a', + 'st-del': '#c88ad8', + 'st-id': '#8a8a8a', + 'st-err': '#ff5c5c', + + # --- verdict chips / badges --- + 'tag-real-bg': '#6e2b2b', 'tag-real-fg': '#ffb3b3', + 'tag-cmt-bg': '#33353a', 'tag-cmt-fg': '#b9bec6', + 'tag-ign-bg': '#3a3b40', 'tag-ign-fg': '#c3c7cd', + 'tag-add-bg': '#2b5232', 'tag-add-fg': '#a8e6b0', + 'tag-del-bg': '#4a2b52', 'tag-del-fg': '#d9a8e6', + 'tag-err-bg': '#7a1f1f', 'tag-err-fg': '#ffc2c2', + 'tag-id-bg': '#333333', 'tag-id-fg': '#aaaaaa', + 'tag-adddel-bg': '#33404a', 'tag-adddel-fg': '#cfe0ec', + 'tag-rev-bg': '#274a45', 'tag-rev-fg': '#9fe0cf', + + # --- the incomplete-compare banner --- + 'err-bg': '#4a1d1d', + 'err-border': '#b04a4a', + 'err-fg': '#ffd6d6', + 'err-code-bg': '#5c2626', + + # --- diff rows --- + 'del-bg': '#3a2222', 'add-bg': '#1f3a24', + 'del-bg-dim': '#2f2020', 'add-bg-dim': '#1e2f21', + 'mv-bg': '#1d2f3e', + 'seg-del-bg': '#7a2f2f', 'seg-del-fg': '#ffc2c2', + 'seg-add-bg': '#2f6e3d', 'seg-add-fg': '#c9f7d1', + 'seg-del-dim-bg': '#5e2a2a', 'seg-del-dim-fg': '#f0c4c4', + 'seg-add-dim-bg': '#2c5738', 'seg-add-dim-fg': '#bfe8c8', + 'seg-mv-bg': '#2f5a7a', + 'mv-fg': '#7fb3d9', + 'ln-fg': '#6a6a6a', + 'gap-fg': '#666666', + # a row the current compare rules do not report: still on screen, still + # readable, painted so the eye slides off it. The band has to be visibly + # OFF the editor background -- a wash one shade away just reads as ordinary + # context, and then nothing on screen says the category was switched off. + 'muted-bg': '#2c2e33', + 'muted-fg': '#6c7178', + + # --- review notes --- + 'note-bg': '#22302e', 'note-border': '#3f8f7a', 'note-fg': '#cfe6df', + 'note-tag': '#7fd3ba', 'note-where': '#7d8f8b', + 'note-pending-bg': '#2d2b21', 'note-pending-border': '#8a7a3f', + 'note-pending-fg': '#e6dcc0', 'note-pending-tag': '#d8c07a', + 'note-check': '#5f9e8b', + + # --- buttons and inputs (report toolbar; the viewer's QSS uses these too) --- + 'btn-bg': '#2b2c30', 'btn-fg': '#d4d4d4', 'btn-border': '#444444', + 'btn-hover': '#35363b', 'btn-focus': '#6a6a6a', + + # --- viewer: editors, gutter, overlays --- + 'code-bg': '#232427', + 'code-fg': '#d4d4d4', + 'gutter-bg': '#1e1f22', + 'gutter-fg': '#6a6a6a', + 'filler-bg': '#26272b', + 'cur-row': '#22ffffff', + 'find-bg': '#5a4715', + 'find-cur-bg': '#8f7220', + 'pane-banner-bg': '#2a2c31', + 'pane-old-accent': '#c98b8b', + 'pane-new-accent': '#8ec69a', + + # --- viewer: minimap --- + 'map-bg': '#202124', + 'map-ctx': '#565b62', + 'map-real': '#e8908d', + 'map-noise': '#a4706e', + 'map-moved': '#7fb0d9', + 'map-muted': '#3f4348', + 'map-strip-real': '#46d9524f', + 'map-strip-noise': '#28d9524f', + 'map-strip-moved': '#463f7fb0', + 'map-view-fill': '#1affffff', + 'map-view-border': '#78bebebe', + + # --- viewer: window chrome --- + 'chrome-bg': '#25262a', + 'chrome-hover': '#34363c', + 'chrome-pressed': '#3d404a', + 'chrome-checked-bg': '#343a63', + 'chrome-checked-fg': '#e8e8ff', + 'chrome-checked-hover': '#454c80', + 'chrome-bar-bg': '#212226', + 'chrome-disabled-bg': '#2b2d33', + 'chrome-disabled-fg': '#6a6a6a', + 'tree-selected': '#3a4a7a', + 'header-bg': '#2a2c31', + 'header-fg': '#b9b9b9', + 'progress-chunk': '#4f46e5', + 'status-fg': '#b0b0b0', + 'icon-tint': '#d7d7d7', + 'state-idle': '#8a8f98', + 'state-busy': '#e2c16b', + 'state-ready': '#7bd88a', + 'state-error': '#ff7b7b', + 'review-done': '#7bd88a', + 'review-partial': '#e2c16b', + 'review-none': '#8a8f98', + + # --- syntax tokens (foreground only; never red or green -- those two mean + # removed and added on the very same line) --- + 'syn-comment': '#8f96a2', + 'syn-string': '#e0a860', + 'syn-number': '#c5a3e8', + 'syn-keyword': '#7aa2e3', + 'syn-type': '#57b6a9', + 'syn-preproc': '#b58ac4', + 'syn-call': '#d8c99a', + 'syn-tag': '#7aa2e3', + 'syn-attr': '#57b6a9', +} + +_LIGHT = { + 'bg': '#ffffff', + 'fg': '#1f2328', + 'fg-strong': '#0d1117', + 'fg-dim': '#57606a', + 'fg-muted': '#6e7781', + 'fg-faint': '#8c959f', + 'panel': '#f6f8fa', + 'panel-alt': '#f0f3f6', + 'panel-2': '#eef1f4', + 'panel-3': '#eff2f5', + 'panel-hover': '#eaeef2', + 'border': '#d0d7de', + 'border-soft': '#e4e8ed', + 'border-strong': '#c2c8d0', + 'accent': '#6f42c1', + 'accent-2': '#4f46e5', + 'link-underline': '#b8c0c8', + 'link-hover': '#0550ae', + + 'st-real': '#cf222e', + 'st-cmt': '#7d848d', + 'st-cmt-text': '#57606a', + 'st-ign': '#6e7781', + 'st-add': '#1a7f37', + 'st-del': '#8250df', + 'st-id': '#8c959f', + 'st-err': '#d1242f', + + 'tag-real-bg': '#ffe1e1', 'tag-real-fg': '#a40e26', + 'tag-cmt-bg': '#eef1f4', 'tag-cmt-fg': '#57606a', + 'tag-ign-bg': '#eaeef2', 'tag-ign-fg': '#4a525c', + 'tag-add-bg': '#dafbe1', 'tag-add-fg': '#0f6626', + 'tag-del-bg': '#f5eafd', 'tag-del-fg': '#6639ba', + 'tag-err-bg': '#ffdcdc', 'tag-err-fg': '#a40e26', + 'tag-id-bg': '#eaeef2', 'tag-id-fg': '#6e7781', + 'tag-adddel-bg': '#ddf4ff', 'tag-adddel-fg': '#0a3069', + 'tag-rev-bg': '#d7f5ec', 'tag-rev-fg': '#0f5d4e', + + 'err-bg': '#fff5f5', + 'err-border': '#e0989b', + 'err-fg': '#a40e26', + 'err-code-bg': '#ffe0e0', + + 'del-bg': '#ffebe9', 'add-bg': '#e6ffec', + 'del-bg-dim': '#fff5f4', 'add-bg-dim': '#f1fff5', + 'mv-bg': '#ddf4ff', + 'seg-del-bg': '#ffc9c4', 'seg-del-fg': '#6e0a17', + 'seg-add-bg': '#abefc0', 'seg-add-fg': '#03502a', + 'seg-del-dim-bg': '#ffdedb', 'seg-del-dim-fg': '#8a2b2b', + 'seg-add-dim-bg': '#cdf3d8', 'seg-add-dim-fg': '#17512c', + 'seg-mv-bg': '#b6e3ff', + 'mv-fg': '#0969da', + 'ln-fg': '#8c959f', + 'gap-fg': '#8c959f', + 'muted-bg': '#eaecef', + 'muted-fg': '#9aa1a9', + + 'note-bg': '#eafaf4', 'note-border': '#3f8f7a', 'note-fg': '#12463c', + 'note-tag': '#0f6d5b', 'note-where': '#6a7d78', + 'note-pending-bg': '#fdf6e3', 'note-pending-border': '#b8a04f', + 'note-pending-fg': '#5c4c10', 'note-pending-tag': '#8a6d0a', + 'note-check': '#1b7f6a', + + 'btn-bg': '#f6f8fa', 'btn-fg': '#24292f', 'btn-border': '#d0d7de', + 'btn-hover': '#eaeef2', 'btn-focus': '#8c959f', + + 'code-bg': '#ffffff', + 'code-fg': '#1f2328', + 'gutter-bg': '#f6f8fa', + 'gutter-fg': '#8c959f', + 'filler-bg': '#f1f2f4', + 'cur-row': '#1e000000', + 'find-bg': '#fff0b3', + 'find-cur-bg': '#ffd633', + 'pane-banner-bg': '#eef1f4', + 'pane-old-accent': '#a4343a', + 'pane-new-accent': '#1a7f37', + + 'map-bg': '#f0f2f5', + 'map-ctx': '#c2c8d0', + 'map-real': '#e5484d', + 'map-noise': '#efa8a8', + 'map-moved': '#5b9bd5', + 'map-muted': '#dfe3e8', + 'map-strip-real': '#38d9524f', + 'map-strip-noise': '#1cd9524f', + 'map-strip-moved': '#383f7fb0', + 'map-view-fill': '#14000000', + 'map-view-border': '#78606a76', + + 'chrome-bg': '#f0f2f5', + 'chrome-hover': '#e2e6eb', + 'chrome-pressed': '#d5dae0', + 'chrome-checked-bg': '#dde3ff', + 'chrome-checked-fg': '#24306b', + 'chrome-checked-hover': '#ccd5ff', + 'chrome-bar-bg': '#f2f4f7', + 'chrome-disabled-bg': '#e6e9ee', + 'chrome-disabled-fg': '#a0a6ae', + 'tree-selected': '#cfe3ff', + 'header-bg': '#eaeef2', + 'header-fg': '#4a525c', + 'progress-chunk': '#4f46e5', + 'status-fg': '#57606a', + 'icon-tint': '#3d414a', + 'state-idle': '#8c959f', + 'state-busy': '#b58900', + 'state-ready': '#1a7f37', + 'state-error': '#cf222e', + 'review-done': '#1a7f37', + 'review-partial': '#b58900', + 'review-none': '#8c959f', + + 'syn-comment': '#6a737d', + 'syn-string': '#a04a00', + 'syn-number': '#6f42c1', + 'syn-keyword': '#1c39bb', + 'syn-type': '#0d7a86', + 'syn-preproc': '#8250df', + 'syn-call': '#7a5c00', + 'syn-tag': '#1c39bb', + 'syn-attr': '#0d7a86', +} + +PALETTES = {DARK: _DARK, LIGHT: _LIGHT} + +# a palette missing a role the other one has is a bug that only shows up on the +# surface nobody looked at, so it is caught at import instead +assert set(_DARK) == set(_LIGHT), sorted(set(_DARK) ^ set(_LIGHT)) + +_current = DEFAULT + + +def normalize(name): + """A theme name from anywhere (CLI flag, saved setting) mapped onto one we + actually have. Unknown names fall back to the default rather than raising: + a colour scheme is never worth refusing to open the tool over.""" + return name if name in PALETTES else DEFAULT + + +def palette(name=None): + """The whole role -> colour mapping for one theme (the current one when + `name` is None).""" + return PALETTES[normalize(name or _current)] + + +def color(role, name=None): + """One role's colour. Raises on an unknown role -- a typo must not become a + silently missing colour on one surface only.""" + return palette(name)[role] + + +def c(role): + """:func:`color` in the current theme -- what the Qt widgets call.""" + return PALETTES[_current][role] + + +def current(): + return _current + + +def set_current(name): + """Switch the theme every Qt surface paints from. Returns the name that + actually took effect.""" + global _current + _current = normalize(name) + return _current + + +def other(name=None): + """The theme the toggle would switch to.""" + return LIGHT if normalize(name or _current) == DARK else DARK + + +def css_vars(name): + """One theme's palette as CSS custom properties, ready for a rule body. + + Roles carrying an alpha channel are skipped: they are written in Qt's + ``#aarrggbb`` order, which CSS reads as ``#rrggbbaa`` -- so emitting them + would define a wrong colour rather than an unused one. Nothing in the + report uses them. + """ + return ''.join('--{}:{};'.format(k, v) + for k, v in sorted(palette(name).items()) + if len(v) == 7) diff --git a/compare_tool/view_model.py b/compare_tool/view_model.py index 87a3493..4904f57 100644 --- a/compare_tool/view_model.py +++ b/compare_tool/view_model.py @@ -22,17 +22,22 @@ # mode: how a row is painted. 'ctx' = equal line (context), 'real' = real # change (red/green), 'comment' = comment-only noise, 'minor' = the other # ignorable noise (both painted in the same red/green, dimmer), 'moved' = -# moved block (blue). Comments get +# moved block (blue), 'muted' = a noise row the current compare rules do not +# report (see mute_rows). Comments get # their own mode for the same reason they get their own file verdict: banner # churn reads very differently from a renamed identifier. kind = the # underlying hunk kind ('equal' for ctx rows, otherwise straight from the hunk). Row = namedtuple('Row', 'old_no old_txt new_no new_txt mode kind') -# the only row modes a caller may collapse out of sight. 'real' and 'moved' are -# absent by construction: a UI toggle must never be able to fold away a change -# the reviewer has not seen. +# the only row modes a caller may mute. 'real' and 'moved' are absent by +# construction: a UI toggle must never be able to play down a change the +# reviewer has not seen. FOLDABLE_MODES = ('comment', 'minor') +# what a muted row's mode becomes. Its `kind` is left alone, so the row still +# says WHY it was played down (uuid, comment, rename, …). +MUTED = 'muted' + def char_span(old_txt, new_txt): """Character offsets of the single changed span on each side of one line @@ -64,46 +69,30 @@ def mode_of(kind): return 'minor' -def collapse_rows(rows, modes): - """Fold every run of noise rows into ONE placeholder row. +def mute_rows(rows, modes): + """Play noise rows DOWN instead of taking them away. - ``modes`` names the paint modes to fold (see :data:`FOLDABLE_MODES`; any - other mode passed in is ignored, so `real` and `moved` can never be folded - away by a caller's mistake). Each run becomes a single row carrying the same - ``⋯ N uuid lines hidden`` text on BOTH sides -- identical text, so it reads - as context rather than as a difference, and the two panes keep the same - block count and stay in scroll lockstep. + ``modes`` names the paint modes to mute (see :data:`FOLDABLE_MODES`; any + other mode passed in is ignored, so `real` and `moved` can never be muted + by a caller's mistake). Each matching row keeps its line numbers and its + text and comes back with mode :data:`MUTED`; the renderer paints it in a + flat grey with no diff colour, so it reads as "still here, does not count". - The count is always stated: this hides noise, it does not drop it, and a - reviewer must be able to see that something was folded and how much. + Collapsing those runs into a ``⋯ N lines hidden`` placeholder is what this + used to do, and it cost the reviewer the one thing a side-by-side view is + for: the code around a change. A regenerated file is mostly banner churn, + so folding it removed most of the file and left the surviving hunks without + context to read them in. Greying keeps the line count, the scroll position + and the shape of the file intact -- and, because row indices no longer + move, whatever the caller holds (navigation stops, find hits) stays valid. - Returns ``(rows, row_map)``. ``row_map[i]`` is where original row *i* now - lives, so a caller holding row indices (navigation stops) can move them - across; a folded row maps to its placeholder. + Returns a new list; the input is untouched, so the modes can be toggled + back and forth off one alignment. """ modes = tuple(m for m in modes if m in FOLDABLE_MODES) if not modes: - return list(rows), list(range(len(rows))) - out, row_map = [], [0] * len(rows) - i = 0 - while i < len(rows): - if rows[i].mode not in modes: - row_map[i] = len(out) - out.append(rows[i]) - i += 1 - continue - j, kinds = i, [] - while j < len(rows) and rows[j].mode in modes: - if rows[j].kind not in kinds: - kinds.append(rows[j].kind) - row_map[j] = len(out) - j += 1 - n = j - i - label = '{} {} {} line{} hidden'.format( - '⋯', n, ' + '.join(kinds), '' if n == 1 else 's') - out.append(Row(None, label, None, label, 'folded', ' + '.join(kinds))) - i = j - return out, row_map + return list(rows) + return [r._replace(mode=MUTED) if r.mode in modes else r for r in rows] def hunk_row_starts(hunks): diff --git a/docs/architecture.md b/docs/architecture.md index b62c64a..b5f715b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ flowchart TD end subgraph shared[Shared seams] VM[view_model.py
mode_of · char_span · aligned_rows] + TH[theme.py
dark/light palettes by role] RV[review.py
notes keyed by content] SY[syntax.py
token spans, Qt-free] end @@ -50,6 +51,8 @@ flowchart TD QT --> RP RP --> VM QT --> VM + RP --> TH + QT --> TH QT --> SY RP --> RV QT --> RV @@ -58,7 +61,7 @@ flowchart TD Two rules hold this shape: **The core imports nothing but the standard library.** `scanner`, `diff_engine`, -the three rule modules, `report`, `review`, `view_model`, `syntax` and +the three rule modules, `report`, `review`, `view_model`, `theme`, `syntax` and `gitsource` are what ships in `compare_tool.pyz` — ~110 KB, no install, the documented fallback for machines where antivirus blocks the `.exe`. One third-party import in `scanner.py` and the zipapp stops running there. PySide6 @@ -66,8 +69,9 @@ lives only under `compare_tool/qtviewer/` and is imported lazily, when the viewer opens, so the test suite runs headless. **Arrows only point down.** The core never imports a front end. `syntax.py` -says *what* a stretch of text is and never what colour it gets, so the Qt layer -and any second surface can reuse it without the mapping being written twice. +says *what* a stretch of text is and never what colour it gets — that is +`theme.py`'s job, answered once for both surfaces — so the Qt layer and any +second surface can reuse it without the mapping being written twice. ## Data flow of one compare @@ -152,6 +156,13 @@ than mutates, so the rules can be toggled back and forth. The viewer keeps the untouched scan in `MainWindow._raw_results` and folds into `self.results` for display. +Folding a category changes two things, and only these two: the file's +**verdict** (it comes back `identical`, or `real-change` if something real +remains) and how its rows are **painted** — `view_model.mute_rows` greys them, +the minimap stops striping them and `F7`/`F8` never stopped on them anyway. The +lines themselves stay on screen. The hunks are never touched, so the exported +report, built from `_raw_results`, cannot notice that a category was folded. + ## The result dict is the contract Everything downstream — CLI summary, HTML report, viewer tree, review store — @@ -190,10 +201,17 @@ until someone adds a new kind to one of them. - **`view_model.char_span`** — the intra-line highlight as plain character offsets. The report wraps them in a ``; the viewer applies a `QTextCharFormat` over the same numbers. -- **`view_model.aligned_rows` / `collapse_rows`** — whole-file two-pane - alignment, and folding a run of noise rows into one `⋯ N uuid lines hidden` - placeholder that reads as context on both sides so the panes stay in scroll - lockstep. The count is always stated: this hides noise, it does not drop it. +- **`view_model.aligned_rows` / `mute_rows`** — whole-file two-pane alignment, + and playing a switched-off noise category *down* rather than away: the rows + keep their place, their line numbers and their text, and come back with mode + `muted` for the renderer to paint flat grey. Muting moves no row, so + navigation stops and find hits stay valid without translation, and the + reviewer keeps the context the surviving hunks have to be read in. +- **`theme.py`** — every colour as a named role, one value per theme. The + report emits the whole palette as CSS custom properties and uses + `var(--role)`; the Qt widgets look the same role up with `theme.c`. Adding a + role means adding it to **both** palettes — an import-time assert says so, + because the alternative is a `KeyError` on whichever surface nobody opened. - **`review.py`** — notes and sign-offs keyed by a hash of the change's own text, not by line number, so an unrelated edit elsewhere in the file does not detach them on the next scan. @@ -246,6 +264,9 @@ quick-changes rollup. **The HTML report is self-contained.** CSS and JS inline, no CDN, nothing fetched when the file is opened. It gets mailed around and opened on machines with no internet; a report that renders blank there is worse than no report. +That is also why the page carries *both* palettes rather than the one +`--theme` asked for: the reader's dark/light button has to be an attribute +flip, with nothing left to download. **Cosmetic failures degrade, the compare does not.** A missing icon leaves a button with its text label (`resources.py` getters return `None` and callers @@ -272,5 +293,6 @@ un-hidden on a crash. | New file type | `RULES` in `diff_engine.py`, a `*_rules.py` module, shadow + variants | | New semantic extraction | `*_rules.py` extractor, wire into `scanner.compare_file` and `_single_info`, then a `summarize_*` rollup | | Anything both renderers show | `view_model.py` — never inline in one of them | +| A colour, anywhere | `theme.py`, as a role in **both** palettes; the report uses `var(--role)`, Qt uses `theme.c(role)` | | New verdict | `diff_engine._status_of`, and decide explicitly whether it belongs in `scanner.FOLDABLE` (default: no) | | Viewer layout or colour | render it and look at it (`widget.grab().save(png)` under `QT_QPA_PLATFORM=offscreen`), then a real window | diff --git a/docs/vi/README.md b/docs/vi/README.md index 7117bfb..39a87fa 100644 --- a/docs/vi/README.md +++ b/docs/vi/README.md @@ -97,6 +97,7 @@ Những gì lần scan tìm được vẫn được in ra. | `--exit-zero` | Luôn exit 0 kể cả khi có thay đổi thật (chế độ chỉ ghi report cho pipeline). Lỗi compare vẫn exit 2 | | `--arxml-only` | Chỉ scan `.arxml`/`.xml`/`.a2l` và ghi report gọn theo từng loại file (mặc định `arxml_update.html`) — luôn được ghi, kể cả khi không có gì đổi | | `--review FILE` | Render note và sign-off từ review file (`codegen-review.json`, do viewer ghi) ngay cạnh change tương ứng, kèm badge `Reviewed` để ẩn các change đã ký duyệt. Phải chỉ tên tường minh — một report không được vô tình mang sign-off của người khác; không có tác dụng với `--arxml-only` | +| `--theme dark\|light` | Bảng màu lúc mở của report và viewer (mặc định `dark`). Report mang sẵn **cả hai** và có nút đổi riêng, nên cờ này chỉ quyết định người đọc thấy màu nào trước | | `--qt`, `--viewer` | Mở viewer trên hai thư mục truyền ở command line, thay vì so sánh trong terminal. Cần extra `viewer` (xem dưới) | Bỏ `old_dir`/`new_dir` thì viewer mở. `--gui` (panel tkinter) đã bị bỏ ở 1.1.0. @@ -132,6 +133,12 @@ mục đó, lấy commit bạn chọn ra một thư mục tạm (read-only — w identifier xuyên suốt lần compare được. - `Hide identical` chỉ để lại các file có khác biệt trên cây. Đây là view: verdict, số đếm và report export ra đều không đổi. +- Bỏ tick `Comment` / `Unimportant` sẽ **làm mờ các dòng đó** chứ không xoá đi: + chúng ở nguyên chỗ cũ, giữ số dòng, mất màu đỏ/xanh, và biến khỏi minimap lẫn + `F7`/`F8`. Phần code xung quanh mới là thứ giúp đọc được một change, mà file + regenerate thì phần lớn là banner churn — gộp chúng lại là gộp mất gần cả file. +- `☀ Light` / `☾ Dark` trên toolbar đổi bảng màu; `--theme` chọn màu lúc mở. C, + ARXML và A2L đều được tô cú pháp ở cả hai theme. `Review mode` bật hộp note và cột `Review` trên cây — xanh khi mọi change trong dòng đã ký duyệt, hổ phách khi mới một phần, xám khi chưa cái nào. Ký duyệt một @@ -182,8 +189,10 @@ một phần của tên, không phải đuôi mangle. được báo là **Comment**, tách khỏi **Unimportant** (UUID, timestamp, SW-VERSION, rename, whitespace) — một banner comment bị viết lại triage khác hẳn một identifier bị đổi tên. Đếm riêng trong summary của CLI và có marker riêng trên cây của viewer. -File trộn comment *với* noise loại khác thì vẫn là Unimportant. HTML report giữ -verdict nhưng không hiện nội dung comment — xem [HTML report](#html-report). +File trộn comment *với* noise loại khác thì vẫn là Unimportant. Trong viewer, +`Comment` và `Unimportant` mỗi cái có rule bật/tắt riêng; trong HTML report, +comment không hiện dòng nào cả, chỉ `Unimportant` có badge để bấm hiện — xem +[HTML report](#html-report). ## Phát hiện block bị di chuyển @@ -227,14 +236,25 @@ File có XML parse lỗi bị bỏ khỏi phần summary này (text diff của n ## Nhóm theo model / SWC File được nhóm theo **model Simulink** dựa trên quy ước đặt tên AUTOSAR của Embedded -Coder (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, bộ ARXML modular, …). File không khớp -model nào rơi vào nhóm cuối **Shared / other**. +Coder (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, `X_data.c`, bộ ARXML modular, …). File +không khớp model nào rơi vào nhóm cuối **Shared / other**. ## HTML report File self-contained, mỗi lần compare một file: badge bật/tắt, cây thư mục, ô lọc, -diff xếp gọn được theo từng file. Mở lên với `Unimportant` đã ẩn và `Modified` đã -mở, để mở ra là thấy ngay cái đáng xem. +diff xếp gọn được theo từng file. Mở lên với `Unimportant` đã ẩn, `Modified` đã +mở, để mở ra là thấy ngay cái đáng xem. Bấm badge `Unimportant` thì hiện đúng các +dòng noise loại đó — tô màu xám phẳng thay vì đỏ/xanh, để dù hiện ra rồi vẫn đọc +được ngay là "không tính", không lẫn với thay đổi thật. Thay đổi comment thì +**không hiện trong report ở bất kỳ trạng thái nào** — chỉ có placeholder đếm số +dòng bị ẩn; report là bản ghi để gửi đi nên bỏ hẳn comment churn ra khỏi đó, còn +viewer (xem file theo file) vẫn hiện đầy đủ, tô xám. Nút `☀ Light` / `☾ Dark` +nằm ở góc trên bên phải — cả hai palette đều nhúng sẵn trong file, nên đổi màu +không tải gì và chạy được trên máy không có internet. + +Một file mà toàn bộ khác biệt chỉ là comment thì vẫn không có mục chi tiết riêng +(không còn gì ngoài comment để mà xem) — nhưng vẫn giữ marker `≉` và đếm vào +`Comment` trên cây thư mục. ![Report viewer](../../resources/pic/report_page.png) @@ -307,7 +327,8 @@ compare_tool/ ├── arxml_rules.py # rule ARXML: UUID, ADMIN-DATA, DATE, comment + trích port interface, SWC (port/runnable/event) ├── a2l_rules.py # rule A2L: bóc comment kiểu C + trích CHARACTERISTIC/MEASUREMENT ├── view_model.py # view model không phụ thuộc renderer (paint mode, span trong dòng, canh dòng) dùng chung cho report và viewer -├── syntax.py # token span C / XML theo từng dòng, không dính Qt nên ship được trong .pyz +├── theme.py # palette sáng và tối dưới dạng role có tên, dùng chung cho CSS của report và mọi mặt Qt +├── syntax.py # token span C / XML / A2L theo từng dòng, không dính Qt nên ship được trong .pyz ├── review.py # note và sign-off của reviewer, khoá theo nội dung change nên sống sót qua lần scan sau ├── gitsource.py # `git archive` read-only một commit ra thư mục tạm, để commit đóng vai bên OLD └── report.py # HTML report self-contained (badge bật/tắt, tổng quan theo model, nhóm, lọc, diff xếp gọn) @@ -315,9 +336,9 @@ compare_tool/ [architecture.md](architecture.md) nói các mảnh này ghép với nhau ra sao và tại sao: hai lượt diff, chỗ verdict được quyết định, các seam dùng chung và contract -của result dict. Cái gì cả hai renderer đều cần thì nằm ở `view_model.py` — viết -lại một mapping ngay tại chỗ là cách để HTML report và viewer trôi lệch nhau về -chuyện cái gì đã đổi. +của result dict. Cái gì cả hai renderer đều cần thì nằm ở `view_model.py` (cái gì +đã đổi) hoặc `theme.py` (nó mang màu gì) — viết lại một mapping ngay tại chỗ là +cách để HTML report và viewer trôi lệch nhau. Thêm một rule: viết hàm strip trong `c_rules.py` / `arxml_rules.py` / `a2l_rules.py`, nối nó vào shadow của ruleset đó, đăng ký một variant có nhãn trong diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md index 3f53e20..133e8cb 100644 --- a/docs/vi/architecture.md +++ b/docs/vi/architecture.md @@ -35,6 +35,7 @@ flowchart TD end subgraph shared[Seam dùng chung] VM[view_model.py
mode_of · char_span · aligned_rows] + TH[theme.py
palette sáng/tối theo role] RV[review.py
note khoá theo nội dung] SY[syntax.py
token span, không dính Qt] end @@ -50,6 +51,8 @@ flowchart TD QT --> RP RP --> VM QT --> VM + RP --> TH + QT --> TH QT --> SY RP --> RV QT --> RV @@ -58,15 +61,16 @@ flowchart TD Hai luật giữ cho hình dạng này đứng vững: **Core không import gì ngoài standard library.** `scanner`, `diff_engine`, ba module -rule, `report`, `review`, `view_model`, `syntax` và `gitsource` là những gì ship +rule, `report`, `review`, `view_model`, `theme`, `syntax` và `gitsource` là những gì ship trong `compare_tool.pyz` — ~110 KB, không cần cài, và là phương án dự phòng đã được ghi rõ cho các máy bị antivirus chặn `.exe`. Chỉ cần một import thư viện ngoài trong `scanner.py` là zipapp hết chạy ở đó. PySide6 chỉ nằm dưới `compare_tool/qtviewer/` và được import lười, lúc viewer mở, nên bộ test chạy headless được. **Mũi tên chỉ đi xuống.** Core không bao giờ import front end. `syntax.py` nói một -đoạn text *là gì* chứ không nói nó tô màu gì, nên lớp Qt và bất kỳ surface thứ hai -nào cũng dùng lại được mà không phải viết mapping đó hai lần. +đoạn text *là gì* chứ không nói nó tô màu gì — màu là việc của `theme.py`, trả lời +một lần cho cả hai surface — nên lớp Qt và bất kỳ surface thứ hai nào cũng dùng lại +được mà không phải viết mapping đó hai lần. ## Luồng dữ liệu của một lần compare @@ -148,6 +152,13 @@ cái đó là phí công. Nó copy chứ không sửa tại chỗ, nên bật t mái. Viewer giữ nguyên lần scan gốc trong `MainWindow._raw_results` và fold vào `self.results` để hiển thị. +Fold một nhóm chỉ đổi đúng hai thứ: **verdict** của file (thành `identical`, hoặc +`real-change` nếu còn thay đổi thật) và cách các dòng đó được **tô** — +`view_model.mute_rows` làm chúng xám đi, minimap thôi kẻ vạch cho chúng, còn +`F7`/`F8` thì vốn đã không dừng ở đó. Bản thân các dòng vẫn nằm trên màn hình. Hunk +không bị đụng tới, nên report xuất ra từ `_raw_results` không thể biết là có nhóm +nào đã bị fold. + ## Result dict là contract Mọi thứ ở phía sau — summary của CLI, HTML report, cây của viewer, review store — @@ -186,11 +197,15 @@ khớp nhau hoàn hảo cho tới lúc ai đó thêm một kind mới vào một - **`view_model.char_span`** — vùng highlight trong dòng, dưới dạng offset ký tự trần. Report bọc nó trong một ``; viewer áp `QTextCharFormat` lên đúng những con số đó. -- **`view_model.aligned_rows` / `collapse_rows`** — canh dòng hai pane cho cả file, - và gộp một chuỗi dòng noise thành một placeholder `⋯ N uuid lines hidden`, đọc - như context ở cả hai bên nên hai pane giữ được cuộn đồng bộ. Số lượng luôn được - ghi ra: cái này giấu noise, không vứt noise, và reviewer phải thấy được là có gì - đó đã bị gộp và gộp bao nhiêu. +- **`view_model.aligned_rows` / `mute_rows`** — canh dòng hai pane cho cả file, và + làm *mờ* một nhóm noise bị tắt thay vì bỏ nó đi: dòng giữ nguyên vị trí, số dòng + và nội dung, chỉ đổi mode thành `muted` để renderer tô một màu xám phẳng. Vì mute + không dời dòng nào, các mốc điều hướng và kết quả tìm kiếm vẫn đúng chỉ số, và + reviewer giữ được phần code xung quanh — thứ giúp đọc được những hunk còn lại. +- **`theme.py`** — mọi màu là một role có tên, mỗi theme một giá trị. Report xuất + cả palette ra CSS custom property rồi dùng `var(--role)`; widget Qt tra cùng role + đó bằng `theme.c`. Thêm role nghĩa là thêm vào **cả hai** palette — có assert lúc + import bắt việc này, vì nếu không thì mặt nào không ai mở sẽ nổ `KeyError`. - **`review.py`** — note và sign-off khoá theo hash nội dung của chính change đó, không theo số dòng, nên một sửa đổi không liên quan ở chỗ khác trong file không làm chúng rớt ra ở lần scan sau. @@ -240,7 +255,9 @@ theo đúng luật đó. **HTML report là self-contained.** CSS và JS nội tuyến, không CDN, mở file không tải gì về. Nó bị gửi email lòng vòng và mở trên máy không có internet; một report render -ra trắng bóc ở đó còn tệ hơn là không có report. +ra trắng bóc ở đó còn tệ hơn là không có report. Cũng vì thế mà trang nhúng *cả hai* +palette chứ không chỉ cái `--theme` yêu cầu: nút sáng/tối của người đọc phải chỉ là +đổi một attribute, không còn gì để tải. **Hỏng phần trang trí thì xuống cấp, hỏng phần compare thì kêu to.** Thiếu icon thì nút còn lại chữ (`resources.py` trả `None`, phía gọi tự lo); không có PySide6 thì @@ -265,5 +282,6 @@ chờ tiến trình nữa và vứt mất exit code, tức là gãy CI gate. Nê | Loại file mới | `RULES` trong `diff_engine.py`, một module `*_rules.py`, shadow + variant | | Trích ngữ nghĩa mới | extractor trong `*_rules.py`, nối vào `scanner.compare_file` và `_single_info`, rồi một rollup `summarize_*` | | Thứ cả hai renderer cùng hiện | `view_model.py` — đừng bao giờ viết thẳng vào một trong hai | +| Một màu bất kỳ | `theme.py`, thành role có trong **cả hai** palette; report dùng `var(--role)`, Qt dùng `theme.c(role)` | | Verdict mới | `diff_engine._status_of`, và quyết định rõ ràng xem nó có thuộc `scanner.FOLDABLE` không (mặc định: không) | | Layout hay màu của viewer | render ra rồi nhìn tận mắt (`widget.grab().save(png)` dưới `QT_QPA_PLATFORM=offscreen`), sau đó mở cửa sổ thật | diff --git a/tests/test_cli_modes.py b/tests/test_cli_modes.py index 823aed7..c5ea3c8 100644 --- a/tests/test_cli_modes.py +++ b/tests/test_cli_modes.py @@ -47,6 +47,30 @@ def test_help_and_bad_usage_keep_the_console(self): self.assertFalse(quiet(viewer_requested, ['--no-such-flag'])) +class TestThemeFlag(unittest.TestCase): + def _parse(self, argv): + from compare_tool.main import _parser + return _parser().parse_args(argv) + + def test_the_default_is_dark(self): + from compare_tool import theme + self.assertEqual(self._parse(['old', 'new']).theme, theme.DARK) + + def test_light_is_accepted(self): + self.assertEqual(self._parse(['old', 'new', '--theme', 'light']).theme, + 'light') + + def test_an_unknown_scheme_is_a_usage_error_not_a_silent_fallback(self): + # on the command line a typo should be told, not guessed at; the + # fallback in theme.normalize is for values read back from a file + with self.assertRaises(SystemExit): + quiet(self._parse, ['old', 'new', '--theme', 'puce']) + + def test_the_flag_does_not_count_as_a_folder(self): + self.assertTrue(viewer_requested(['--theme', 'light'])) + self.assertFalse(viewer_requested(['old', 'new', '--theme', 'light'])) + + class TestTkinterPanelIsGone(unittest.TestCase): def test_gui_flag_is_rejected(self): self.assertFalse(quiet(viewer_requested, ['--gui'])) diff --git a/tests/test_diffpane_qt.py b/tests/test_diffpane_qt.py index 6742790..4f56515 100644 --- a/tests/test_diffpane_qt.py +++ b/tests/test_diffpane_qt.py @@ -505,5 +505,173 @@ def test_hiding_rows_never_changes_a_verdict_or_the_counts(self): self.assertEqual(set(self.win._raw_results), set(raw)) +@unittest.skipUnless(HAVE_QT, 'PySide6 not installed') +class TestMutedCategories(unittest.TestCase): + """Switching a noise category off greys its lines instead of removing them. + + Two claims have to hold together. The lines stay -- they are the context + the surviving changes are read in, and a regenerated file is mostly banner + churn, so dropping them left the real hunks floating. And they stop + counting as changes everywhere that answers "where should I look next": + the minimap and F7/F8. + """ + + REL = 'a2l/cal.a2l' # one comment hunk and one real one + + def setUp(self): + from compare_tool.qtviewer.diffpane import DiffPane + self.app = _app() + self.results = scan(FIX / 'old', FIX / 'new') + self.pane = DiffPane() + self.addCleanup(self.pane.deleteLater) + + def _show(self, muted=()): + self.pane.set_muted_modes(muted) + self.pane.show_file(self.REL, self.results[self.REL], + str(FIX / 'old'), str(FIX / 'new')) + for _ in range(5): + self.app.processEvents() + return self.pane.rows + + def test_no_line_is_taken_away(self): + plain = [(r.old_no, r.old_txt, r.new_no, r.new_txt) for r in self._show()] + muted = [(r.old_no, r.old_txt, r.new_no, r.new_txt) + for r in self._show(('comment',))] + self.assertEqual(muted, plain) + + def test_the_comment_rows_lose_their_diff_colour(self): + from compare_tool import theme + from compare_tool.view_model import MUTED + rows = self._show() + i = next(k for k, r in enumerate(rows) if r.mode == 'comment') + doc = self.pane.old_edit.document() + before = doc.findBlockByNumber(i).blockFormat().background().color().name() + self.assertEqual(before, theme.c('del-bg-dim')) + rows = self._show(('comment',)) + self.assertEqual(rows[i].mode, MUTED) + doc = self.pane.old_edit.document() + after = doc.findBlockByNumber(i).blockFormat().background().color().name() + self.assertEqual(after, theme.c('muted-bg')) + + def test_a_muted_row_carries_no_inline_highlight(self): + i = next(k for k, r in enumerate(self._show()) if r.mode == 'comment') + self._show(('comment',)) + self.assertEqual(_backgrounds(self.pane.old_edit)[i], ['block']) + + def test_the_minimap_stops_marking_them_as_changes(self): + self._show() + with_noise = [k for k, r in enumerate(self.pane.minimap._rows) + if r.mode not in ('ctx', 'muted')] + self._show(('comment',)) + without = [k for k, r in enumerate(self.pane.minimap._rows) + if r.mode not in ('ctx', 'muted')] + self.assertLess(len(without), len(with_noise)) + self.assertTrue(without, 'the real change must still be on the map') + + def test_the_real_change_still_stops_where_it_did(self): + # muting moves no row, so a navigation stop needs no translation -- + # this is the assertion that would catch it if one ever did + before = list(self._show()) and list(self.pane._stops) + self._show(('comment', 'minor')) + self.assertEqual(self.pane._stops, before) + self.assertTrue(self.pane._stops) + + def test_a_real_change_is_never_muted(self): + from compare_tool.view_model import MUTED + rows = self._show(('real', 'moved', 'comment', 'minor')) + self.assertIn('real', [r.mode for r in rows]) + self.assertNotIn(MUTED, [r.mode for r in rows if r.kind == 'real']) + + +@unittest.skipUnless(HAVE_QT, 'PySide6 not installed') +class TestThemeSwitch(unittest.TestCase): + """The light theme has to reach every surface that stamps a colour in. + + Row backgrounds are block formats inside the document and tree colours are + per item, so neither follows a stylesheet swap -- the failure mode is half + a window in the old theme, which no assertion about the palette alone + would catch. + """ + + def setUp(self): + from compare_tool import theme + from compare_tool.qtviewer.app import MainWindow + self.theme = theme + self.app = _app() + self.addCleanup(theme.set_current, theme.DEFAULT) + self.win = MainWindow(str(FIX / 'old'), str(FIX / 'new')) + self.win.resize(1200, 800) + self.win.setAttribute(Qt.WA_DontShowOnScreen, True) + self.win.show() + self.addCleanup(self.win.close) + _settle(self.app, self.win) + + def _settle_ui(self): + for _ in range(5): + self.app.processEvents() + + def _row_bgs(self): + doc = self.win.diff.old_edit.document() + return {doc.findBlockByNumber(i).blockFormat().background().color().name() + for i in range(doc.blockCount())} + + def test_the_viewer_opens_in_the_theme_it_was_asked_for(self): + from compare_tool.qtviewer.app import MainWindow + win = MainWindow(str(FIX / 'old'), str(FIX / 'new'), + theme_name=self.theme.LIGHT) + self.addCleanup(win.close) + self.assertEqual(self.theme.current(), self.theme.LIGHT) + + def test_switching_repaints_the_diff_rows_not_just_the_chrome(self): + self.win._reselect('src/real_change.c') + self._settle_ui() + dark = self._row_bgs() + self.assertIn(self.theme.color('del-bg', self.theme.DARK), dark) + self.win._set_theme(self.theme.LIGHT) + self._settle_ui() + light = self._row_bgs() + self.assertIn(self.theme.color('del-bg', self.theme.LIGHT), light) + self.assertNotIn(self.theme.color('del-bg', self.theme.DARK), light) + + def test_switching_repaints_the_tree_verdict_colours(self): + def first_colour(): + item = self.win.tree.topLevelItem(0) + return item.foreground(0).color().name() + + dark = first_colour() + self.win._set_theme(self.theme.LIGHT) + self._settle_ui() + self.assertNotEqual(first_colour(), dark) + + def test_the_file_on_screen_survives_the_switch(self): + self.win._reselect('src/real_change.c') + self._settle_ui() + self.win._set_theme(self.theme.LIGHT) + self._settle_ui() + self.assertEqual(self.win._selected_rel(), 'src/real_change.c') + self.assertEqual(self.win.diff._rel, 'src/real_change.c') + + def test_the_change_being_read_survives_the_switch(self): + # re-rendering the file parks on change 1; a colour switch is not a + # navigation command, so the reviewer must come back to where they were + self.win._reselect('src/rename_conflict.c') + self._settle_ui() + self.assertTrue(self.win.diff.next_change()) + self._settle_ui() + row = self.win.diff._drive.textCursor().blockNumber() + idx = self.win.diff._cur_idx + self.assertGreater(idx, 0) + self.win._set_theme(self.theme.LIGHT) + self._settle_ui() + self.assertEqual(self.win.diff._drive.textCursor().blockNumber(), row) + self.assertEqual(self.win.diff._cur_idx, idx) + + def test_the_toggle_goes_back_and_forth(self): + self.win._toggle_theme() + self.assertEqual(self.theme.current(), self.theme.LIGHT) + self.win._toggle_theme() + self.assertEqual(self.theme.current(), self.theme.DARK) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_qtviewer.py b/tests/test_qtviewer.py index 473e7ef..b1bbf62 100644 --- a/tests/test_qtviewer.py +++ b/tests/test_qtviewer.py @@ -3,6 +3,7 @@ import unittest +from compare_tool import theme from compare_tool.qtviewer.tree import (PRIO, REVIEW_COLOR, STATUS, build_nodes, filter_nodes, review_state) @@ -54,8 +55,12 @@ def test_backslash_paths_split_like_posix(self): def test_every_status_has_metadata(self): for st in PRIO: self.assertIn(st, STATUS) - marker, label, color = STATUS[st] - self.assertTrue(marker and label and color.startswith('#')) + marker, label, role = STATUS[st] + self.assertTrue(marker and label and role) + # the role has to exist in every theme, or the tree paints one + # verdict with a KeyError instead of a colour + for name in theme.THEMES: + self.assertTrue(theme.color(role, name).startswith('#')) class TestFilterNodes(unittest.TestCase): @@ -144,7 +149,10 @@ def test_one_unreviewed_change_is_never_done(self): def test_every_state_has_a_colour(self): for reviewed, total in ((0, 1), (1, 2), (2, 2)): - self.assertIn(review_state(reviewed, total), REVIEW_COLOR) + state = review_state(reviewed, total) + self.assertIn(state, REVIEW_COLOR) + for name in theme.THEMES: + self.assertTrue(theme.color(REVIEW_COLOR[state], name)) if __name__ == '__main__': diff --git a/tests/test_report.py b/tests/test_report.py index ed70458..fdaace4 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -55,7 +55,9 @@ def test_minor_hunks_get_their_own_row_class(self): self.assertIn('class="addm"', table) self.assertNotIn('class="del"', table) self.assertNotIn('class="add"', table) - self.assertIn('chg-seg', table) # char-level highlight kept + # revealed minor rows are flat grey, not a diff colour, so there is no + # changed SPAN inside them to point at either + self.assertNotIn('chg-seg', table) def test_context_is_three_lines(self): table = _group_table(self.old, self.new, _group_hunks(self.r['hunks'])[0]) @@ -95,7 +97,9 @@ def test_report_shows_minor_hunks_in_modified_files(self): class TestUnimportantToggle(unittest.TestCase): - """Unimportant badge must also hide minor changes inside Modified files.""" + """Comment and Unimportant each hide behind their own badge -- per ROW, not + per group, so a pure-noise group still shows its context and its + placeholder while collapsed (see TestNoisyGroupNeverEmpty).""" MIXED_OLD = "/* gen Mon */\nint lim = 5;\nint keep = 0;\n" MIXED_NEW = "/* gen Tue */\nint lim = 10;\nint keep = 0;\n" @@ -120,24 +124,50 @@ def test_placeholder_row_per_hidden_hunk(self): self.assertIn('commentph', table) self.assertIn('1 comment line hidden', table) - def test_minor_only_group_wrapped_grp_min(self): - r = compare_pair(OLD_ARXML, NEW_ARXML, 'f.arxml') - out = _groups_html(OLD_ARXML.split('\n'), NEW_ARXML.split('\n'), r['hunks']) - self.assertIn('
', out) - - def test_mixed_group_not_wrapped_grp_min(self): - r = compare_pair(self.MIXED_OLD, self.MIXED_NEW, 'f.c') - out = _groups_html(self.MIXED_OLD.split('\n'), self.MIXED_NEW.split('\n'), - r['hunks']) - self.assertIn('
', out) - self.assertNotIn('grp-min', out) - - def test_css_hides_minor_on_toggle(self): + def test_no_group_is_wrapped_for_hiding_any_more(self): + # a whole-group wrapper (grp-min / grp-cmt) used to carry the + # display:none for a pure-noise group, and took its placeholder and + # its own context lines down with it -- hiding is per row now, so no + # group-level class drives visibility at all (grp-rev, for a fully + # reviewed group, is unrelated and still applies) + for old, new, rel in ((OLD_ARXML, NEW_ARXML, 'f.arxml'), + (self.MIXED_OLD, self.MIXED_NEW, 'f.c')): + r = compare_pair(old, new, rel) + out = _groups_html(old.split('\n'), new.split('\n'), r['hunks']) + self.assertIn('
', out) + self.assertNotIn('grp-min', out) + self.assertNotIn('grp-cmt', out) + + def test_css_hides_minor_on_toggle_and_comment_unconditionally(self): results = scan(FIX / 'old', FIX / 'new') page = build_report(results, FIX / 'old', FIX / 'new') - self.assertIn('body.hide-ign tr.minor, body.hide-ign .grp-min { display: none; }', - page) + self.assertIn('body.hide-ign tr.minor { display: none; }', page) self.assertIn('body.hide-ign tr.minorph { display: table-row; }', page) + self.assertIn('\ntr.comment { display: none; }', page) + self.assertIn('\ntr.commentph { display: table-row; }', page) + # decisive: the comment rules carry NO body.hide-cmt qualifier, so + # there is no state in which they turn back on. Asserting the bare + # rule alone would not prove it -- it is a substring of the + # qualified one. + self.assertNotIn('hide-cmt', page) + + +class TestNoisyGroupNeverEmpty(unittest.TestCase): + """Regression: a group whose every hunk is noise must still show its + leading/trailing context and its placeholder while collapsed. + + It used to be wrapped whole in a hideable div, so a file that was ENTIRELY + Unimportant (e.g. uuid_only.arxml) rendered nothing at all under its own + summary line until the reviewer clicked the badge -- not even the count + the placeholder is supposed to state. Hiding moved to the row level to fix + this; this test is what would have caught the bug.""" + + def test_a_pure_noise_group_still_shows_context_and_a_placeholder(self): + r = compare_pair(OLD_ARXML, NEW_ARXML, 'f.arxml') + out = _groups_html(OLD_ARXML.split('\n'), NEW_ARXML.split('\n'), r['hunks']) + self.assertIn('class="ctx"', out) # the lines around the change + self.assertIn('class="gap minorph"', out) + self.assertIn('', out) # in the record, just hidden class TestCharDiff(unittest.TestCase): @@ -183,6 +213,7 @@ def setUpClass(cls): def test_unimportant_hidden_by_default(self): self.assertIn('', self.page) self.assertRegex(self.page, r'badge b-ign off[^>]*>\d+ Unimportant<') + self.assertNotIn('class="badge b-cmt', self.page) def test_added_and_deleted_share_one_badge(self): self.assertRegex(self.page, @@ -191,21 +222,28 @@ def test_added_and_deleted_share_one_badge(self): self.assertNotIn('class="badge b-add"', self.page) self.assertNotIn('class="badge b-del"', self.page) - def test_comment_and_identical_are_not_reported_categories(self): - # no badge, no toggle, no detail section -- but the files keep their row - # and verdict mark in the folder tree, so nothing goes unaccounted for - self.assertNotIn('b-cmt', self.page) + def test_comment_only_files_still_have_no_detail_section(self): + # a whole file whose only differences are comments still gets no + # section of its own -- there is nothing beyond the comment lines to + # show it, and it keeps its row and verdict mark in the folder tree + # either way, so nothing goes unaccounted for. Individual comment + # HUNKS mixed into a real-change or Unimportant file's section are a + # separate thing and DO show, behind the Comment badge (see below). self.assertNotIn('badge b-id', self.page) self.assertNotIn('

Identical files

', self.page) self.assertNotIn('
', self.page) + self.assertNotIn('class="badge b-cmt', self.page) + self.assertNotIn('hide-cmt', self.page) def test_modified_files_expanded_by_default(self): self.assertRegex(self.page, r'
]* open>') @@ -259,17 +297,27 @@ def test_no_section_without_arxml_iface_info(self): class TestOneColourLanguage(unittest.TestCase): - """Every category of difference is red on the left and green on the right. + """Real changes and moved blocks are red / green / blue, always visible. - Noise used to get a yellow and a purple of its own. With syntax colours in - the panes that made four hues compete, and a diff whose colours need a - legend is not readable at a glance. Noise is now the same red/green, dimmer - -- dimmer and not identical, because inside a Modified file the reviewer - still has to see which hunks count.""" + Comment and Unimportant used to share that same red/green, one notch + dimmer, so a diff never needed a legend to be read. They are hidden by + default now and, when a badge reveals them, painted a flat NEUTRAL grey + instead -- on purpose: unlike a permanently-visible dim tint, a + toggled-open noise section has to read as "off to the side", not as a + quieter member of the same red/green language real changes own.""" @staticmethod - def _bg(selector): - """The background colour a CSS rule sets, as (r, g, b).""" + def _rgb(value): + h = value.lstrip('#') + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + + def _bg(self, selector, theme_name): + """The background colour a CSS rule sets in one theme, as (r, g, b). + + The rules name theme roles, so the var has to be resolved against the + palette the page would be showing -- which is also what makes these + claims testable in BOTH themes instead of only the dark one.""" + from compare_tool import theme from compare_tool.report import _CSS # comments carry commas of their own, which would land inside the # selector list of the rule that follows them @@ -278,35 +326,64 @@ def _bg(selector): head, _, body = block.partition('{') if selector not in [s.strip() for s in head.split(',')]: continue - m = re.search(r'background:\s*#([0-9a-fA-F]{6})', body) + m = re.search(r'background:\s*var\(--([\w-]+)\)', body) if m: - h = m.group(1) - return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + return self._rgb(theme.color(m.group(1), theme_name)) raise AssertionError('no background for ' + selector) - def test_removed_rows_are_red_on_every_category(self): - for sel in ('td.del', 'td.delm', 'td.delc'): - r, g, b = self._bg(sel) - self.assertGreater(r, g, sel) - self.assertGreater(r, b, sel) - - def test_added_rows_are_green_on_every_category(self): - for sel in ('td.add', 'td.addm', 'td.addc'): - r, g, b = self._bg(sel) - self.assertGreater(g, r, sel) - self.assertGreater(g, b, sel) - - def test_noise_is_dimmer_than_a_real_change(self): - self.assertLess(sum(self._bg('td.delm')), sum(self._bg('td.del'))) - self.assertLess(sum(self._bg('td.addm')), sum(self._bg('td.add'))) - - def test_the_legend_no_longer_offers_a_noise_swatch(self): - # a swatch for a colour the reader cannot tell from 'real change' - # explains nothing + def _themes(self): + from compare_tool import theme + return theme.THEMES + + def test_removed_rows_are_red(self): + for name in self._themes(): + r, g, b = self._bg('td.del', name) + self.assertGreater(r, g, name) + self.assertGreater(r, b, name) + + def test_added_rows_are_green(self): + for name in self._themes(): + r, g, b = self._bg('td.add', name) + self.assertGreater(g, r, name) + self.assertGreater(g, b, name) + + def test_revealed_noise_is_neutral_grey_not_red_or_green(self): + # neither channel dominates -- unlike td.del/td.add, this colour makes + # no claim about removed or added. A slight cool cast is fine (that's + # what makes a grey read as UI chrome rather than paper); a channel + # spread anywhere near a real red/green background's (~150+) is not. + for name in self._themes(): + for sel in ('td.delm', 'td.delc', 'td.addm', 'td.addc'): + r, g, b = self._bg(sel, name) + self.assertLessEqual(max(r, g, b) - min(r, g, b), 12, (name, sel)) + + def test_every_noise_selector_shares_the_same_grey(self): + # comment and minor rows share one muted role: two greys would be its + # own small violation of "one colour per meaning" + for name in self._themes(): + shades = {self._bg(sel, name) + for sel in ('td.delm', 'td.delc', 'td.addm', 'td.addc')} + self.assertEqual(len(shades), 1, name) + + def test_revealed_noise_is_visibly_off_the_page(self): + # 'muted' still has to mean something visible, not a wash one shade + # from invisible against the panel it sits on + from compare_tool import theme + for name in self._themes(): + panel = self._rgb(theme.color('panel', name)) + grey = self._bg('td.delm', name) + gap = sum(abs(a - b) for a, b in zip(grey, panel)) + self.assertGreater(gap, 15, name) + + def test_the_legend_still_has_no_noise_colour_swatch_for_it(self): + # the muted grey gets its own swatch (sw-mut) now that noise can be + # revealed; what it must NOT do is reuse or resemble the real-change + # red/green swatches, which is what sw-min / sw-cmt would have implied page = build_report(scan(FIX / 'old', FIX / 'new'), FIX / 'old', FIX / 'new') legend = page.split('class="legend"')[1].split('
')[0] self.assertNotIn('sw-min', legend) self.assertNotIn('sw-cmt', legend) + self.assertIn('sw-mut', legend) class TestOldSideNaming(unittest.TestCase): @@ -349,6 +426,71 @@ def test_a_label_is_escaped_like_any_other_text(self): self.assertIn('<script>', page) +class TestPageTheme(unittest.TestCase): + """The report carries BOTH palettes and a switch between them. + + It is mailed around and opened on machines with no internet, so the switch + cannot fetch a stylesheet; and the flag the report was built with is only + a default, because whoever opens it is the one looking at it. + """ + + def setUp(self): + self.results = scan(FIX / 'old', FIX / 'new') + + def _page(self, **kw): + return build_report(self.results, FIX / 'old', FIX / 'new', **kw) + + def test_the_default_is_dark(self): + from compare_tool import theme + self.assertEqual(theme.DEFAULT, theme.DARK) + self.assertIn('', self._page()) + + def test_the_flag_chooses_which_one_it_opens_with(self): + self.assertIn('', self._page(theme_name='light')) + + def test_an_unknown_theme_name_falls_back_instead_of_raising(self): + self.assertIn('', self._page(theme_name='puce')) + + def test_both_palettes_are_embedded_whichever_one_it_opens_with(self): + from compare_tool import theme + for name in (None, 'light'): + page = self._page() if name is None else self._page(theme_name=name) + self.assertIn(theme.color('bg', theme.DARK), page) + self.assertIn(theme.color('bg', theme.LIGHT), page) + self.assertIn('html[data-theme="light"]', page) + + def test_the_switch_is_on_the_page_and_needs_nothing_downloaded(self): + page = self._page() + self.assertIn('id="thm"', page) + self.assertIn('tgtheme()', page) + # the compared files' own text is full of URLs (xmlns=…), so what is + # checked is the ways a PAGE fetches something, not the string http + for fetch in (''):] + # the load-time call passes save=false; only the click passes true + self.assertIn('sttheme(t==="dark"||t==="light"?t', js) + self.assertIn('),false);})();', js) + self.assertIn('"dark",true);}', js) + self.assertEqual(js.count('localStorage.setItem'), 1) + self.assertIn('if(save){try{localStorage.setItem', js) + + def test_the_arxml_report_switches_too(self): + page = build_arxml_report(self.results, FIX / 'old', FIX / 'new', + theme_name='light') + self.assertIn('', page) + self.assertIn('id="thm"', page) + + class TestModelGrouping(unittest.TestCase): """File grouping by Embedded Coder model naming (X.c, X_*.h, Rte_X.h).""" @@ -383,6 +525,33 @@ def test_longest_model_name_wins(self): def test_no_models_returns_none(self): self.assertIsNone(_model_groups(self._results(['readme.txt', 'a.h']))) + def test_data_companion_joins_its_model_not_shared(self): + # SWC_data.c used to out-rank "SWC" as its own (longer) candidate + # model, splitting it into a <3-file group that fell to Shared. + paths = ['SWC.c', 'SWC.h', 'SWC_types.h', 'SWC_data.c', + 'SWC_data.h', 'Rte_SWC.h'] + g = _model_groups(self._results(paths)) + self.assertEqual(list(g), ['SWC']) + self.assertEqual(g['SWC'], sorted(paths)) + + def test_data_companion_named_from_an_arxml_model_too(self): + paths = ['SWC_component.arxml', 'SWC_interface.arxml', + 'SWC_data.c', 'SWC_data.h'] + g = _model_groups(self._results(paths)) + self.assertEqual(list(g), ['SWC']) + self.assertEqual(g['SWC'], sorted(paths)) + + def test_a_model_genuinely_named_x_data_keeps_its_own_name(self): + # the _data suffix is only stripped when the base name is evidenced + # elsewhere. With no Foo.c anywhere, Foo_data IS the model: stripping + # it would label the group after a model that does not exist and + # leave Rte_Foo_data.h matching nothing. + paths = ['Foo_data.c', 'Foo_data.h', 'Foo_data_types.h', + 'Rte_Foo_data.h'] + g = _model_groups(self._results(paths)) + self.assertEqual(list(g), ['Foo_data']) + self.assertEqual(g['Foo_data'], sorted(paths)) + class TestModelReport(unittest.TestCase): """Full report over the model fixtures: overview table, grouped details, diff --git a/tests/test_syntax.py b/tests/test_syntax.py index 930b4ac..2d9e4e6 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -27,9 +27,8 @@ def test_arxml_and_xml(self): self.assertEqual(syntax.language_for('swc.arxml'), 'arxml') self.assertEqual(syntax.language_for('a.xml'), 'arxml') - def test_a2l_is_deliberately_not_highlighted(self): - # a flat keyword soup: colouring it lights up nearly every line - self.assertIsNone(syntax.language_for('project.a2l')) + def test_a2l(self): + self.assertEqual(syntax.language_for('project.a2l'), 'a2l') def test_unknown_extension_stays_plain(self): self.assertIsNone(syntax.language_for('notes.txt')) @@ -128,6 +127,74 @@ def test_c_line_comment_is_not_a_comment_in_xml(self): self.assertEqual([k for _t, k in got if k == syntax.COMMENT], []) +class TestA2L(unittest.TestCase): + """A2L colouring earns its keep only if the object NAMES stay plain. + + The format is a soup of ALL-CAPS words; a `[A-Z_]+` rule would light up the + calibration names too, and those are the one thing a reviewer scans an a2l + diff for. So: keywords and enum literals from a list, block names from + their position, and everything else untouched. + """ + + LINE = ' /begin CHARACTERISTIC K_Gain "controller gain" VALUE 0x8000' + + def test_begin_and_end_are_keywords(self): + self.assertIn(('/begin', syntax.KEYWORD), kinds(self.LINE, 'a2l')) + self.assertIn(('/end', syntax.KEYWORD), + kinds(' /end CHARACTERISTIC', 'a2l')) + + def test_the_block_name_is_typed_by_its_position(self): + # so a vendor block nobody listed still reads like every other one + self.assertIn(('CHARACTERISTIC', syntax.TYPE), kinds(self.LINE, 'a2l')) + self.assertIn(('ACME_PRIVATE', syntax.TYPE), + kinds('/begin ACME_PRIVATE x', 'a2l')) + + def test_the_object_name_stays_plain(self): + self.assertNotIn('K_Gain', dict(kinds(self.LINE, 'a2l'))) + + def test_an_all_caps_object_name_is_not_mistaken_for_a_keyword(self): + got = dict(kinds('/begin MEASUREMENT ENG_SPD_MAX "rpm" UWORD', 'a2l')) + self.assertNotIn('ENG_SPD_MAX', got) + self.assertEqual(got.get('UWORD'), syntax.TYPE) + + def test_attribute_keywords_and_numbers(self): + got = kinds(' ECU_ADDRESS 0x40001000', 'a2l') + self.assertIn(('ECU_ADDRESS', syntax.KEYWORD), got) + self.assertIn(('0x40001000', syntax.NUMBER), got) + + def test_a_doubled_quote_stays_inside_the_string(self): + # A2L escapes a quote by doubling it; ending the literal at the first + # one would colour the rest of the line as code and the next string as + # its own + got = kinds('x "say ""hi"" now" FORMAT', 'a2l') + self.assertIn(('"say ""hi"" now"', syntax.STRING), got) + self.assertIn(('FORMAT', syntax.KEYWORD), got) + + def test_a_backslash_is_a_literal_not_an_escape(self): + # a Windows path ending in one would otherwise swallow the code after + # it -- the trap a2l_rules exists to avoid, in the highlighter too + got = kinds(r'PROJECT "C:\build\out\" FORMAT', 'a2l') + self.assertIn((r'"C:\build\out\"', syntax.STRING), got) + self.assertIn(('FORMAT', syntax.KEYWORD), got) + + def test_comment_markers_inside_a_string_do_not_open_a_comment(self): + got, state = syntax.spans('DESC "a /* b" ECU_ADDRESS 1', 'a2l') + self.assertEqual(state, PLAIN) + self.assertEqual([k for _a, _b, k in got if k == syntax.COMMENT], []) + + def test_block_comments_carry_across_lines(self): + _spans, state = syntax.spans('/* generated by the toolchain', 'a2l') + self.assertEqual(state, IN_BLOCK_COMMENT) + _spans, state = syntax.spans(' on some date */ ASAP2_VERSION 1 71', + 'a2l', state) + self.assertEqual(state, PLAIN) + + def test_a_hash_is_not_a_preprocessor_line(self): + # A2L has no preprocessor; the C rule must not follow the language in + self.assertEqual([k for _a, _b, k in syntax.spans('# 1 "x"', 'a2l')[0] + if k == syntax.PREPROC], []) + + class TestSpanShape(unittest.TestCase): """Whatever the line, the Qt layer must be able to paint the spans in order without them fighting each other.""" @@ -139,6 +206,9 @@ class TestSpanShape(unittest.TestCase): ('c', 'if (strcmp(s, "/*") == 0) { return 1; }'), ('arxml', ' text'), ('arxml', ''), + ('a2l', ' /begin CHARACTERISTIC K_Gain "gain" VALUE 0x8000 RL 0 CM 1 2'), + ('a2l', ' ECU_ADDRESS 0x40001000 /* linker map */'), + ('a2l', ' /begin MOD_PAR "C:\\\\build\\\\out" // path with a backslash'), ] def test_spans_are_sorted_and_never_overlap(self): diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..98b9c50 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,96 @@ +"""The shared colour palettes. + +The point of one seam per shared decision is that the surfaces cannot drift, so +what is tested here is that neither palette can go missing a role the other has, +that a role always resolves, and that the CSS emitter never writes a colour the +browser would read differently from Qt. +""" + +import re +import unittest + +from compare_tool import theme + + +class TestPalettes(unittest.TestCase): + def test_both_themes_define_exactly_the_same_roles(self): + # a role in one palette only paints one theme correctly and raises on + # the other -- on whichever surface nobody happened to look at + self.assertEqual(set(theme.PALETTES[theme.DARK]), + set(theme.PALETTES[theme.LIGHT])) + + def test_every_value_is_a_hex_colour(self): + for name in theme.THEMES: + for role, value in theme.palette(name).items(): + self.assertRegex(value, r'^#(?:[0-9a-f]{6}|[0-9a-f]{8})$', + '{} {}'.format(name, role)) + + def test_the_two_themes_are_actually_different(self): + dark, light = theme.palette(theme.DARK), theme.palette(theme.LIGHT) + self.assertNotEqual(dark['bg'], light['bg']) + + def test_dark_is_dark_and_light_is_light(self): + def lum(value): + h = value.lstrip('#') + return sum(int(h[i:i + 2], 16) for i in (0, 2, 4)) + + self.assertLess(lum(theme.color('bg', theme.DARK)), 250) + self.assertGreater(lum(theme.color('bg', theme.LIGHT)), 500) + + def test_text_contrasts_with_its_own_background(self): + # not a WCAG check, just the failure that actually happens: a role + # copied from the other theme and left there + for name in theme.THEMES: + for fg, bg in (('fg', 'bg'), ('code-fg', 'code-bg'), + ('muted-fg', 'muted-bg')): + def lum(role): + h = theme.color(role, name).lstrip('#') + return sum(int(h[i:i + 2], 16) for i in (0, 2, 4)) / 3 + + self.assertGreater(abs(lum(fg) - lum(bg)), 40, + '{} {} on {}'.format(name, fg, bg)) + + +class TestLookup(unittest.TestCase): + def test_an_unknown_theme_falls_back_instead_of_raising(self): + # a colour scheme is never worth refusing to open the tool over + self.assertEqual(theme.normalize('solarized'), theme.DEFAULT) + self.assertEqual(theme.normalize(None), theme.DEFAULT) + + def test_an_unknown_role_raises(self): + # the opposite call: a typo must not become a silently absent colour + with self.assertRaises(KeyError): + theme.color('no-such-role') + + def test_other_is_the_one_the_toggle_goes_to(self): + self.assertEqual(theme.other(theme.DARK), theme.LIGHT) + self.assertEqual(theme.other(theme.LIGHT), theme.DARK) + + def test_set_current_returns_what_took_effect(self): + before = theme.current() + try: + self.assertEqual(theme.set_current(theme.LIGHT), theme.LIGHT) + self.assertEqual(theme.c('bg'), theme.color('bg', theme.LIGHT)) + self.assertEqual(theme.set_current('nonsense'), theme.DEFAULT) + finally: + theme.set_current(before) + + +class TestCssVars(unittest.TestCase): + def test_every_var_is_a_six_digit_hex(self): + # Qt writes #aarrggbb and CSS reads #rrggbbaa, so an alpha role emitted + # here would define a WRONG colour, not merely an unused one + for name in theme.THEMES: + for value in re.findall(r'--[\w-]+:(#[0-9a-f]+);', + theme.css_vars(name)): + self.assertEqual(len(value), 7, '{} {}'.format(name, value)) + + def test_the_roles_the_report_uses_are_all_emitted(self): + from compare_tool.report import _CSS + emitted = set(re.findall(r'--([\w-]+):', theme.css_vars(theme.DARK))) + for role in set(re.findall(r'var\(--([\w-]+)\)', _CSS)): + self.assertIn(role, emitted, role) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_view_model.py b/tests/test_view_model.py index 3cf932f..0f5cb68 100644 --- a/tests/test_view_model.py +++ b/tests/test_view_model.py @@ -7,8 +7,8 @@ from compare_tool.diff_engine import compare_pair from compare_tool.report import _char_diff -from compare_tool.view_model import (Row, aligned_rows, char_span, - collapse_rows, hunk_row_starts, row_with) +from compare_tool.view_model import (MUTED, Row, aligned_rows, char_span, + hunk_row_starts, mute_rows, row_with) class TestCharSpan(unittest.TestCase): @@ -156,60 +156,62 @@ def test_no_hunks_no_starts(self): self.assertEqual(hunk_row_starts([]), []) -class TestCollapseRows(unittest.TestCase): - """Unticking a compare category hides its lines in the panes too. What must - hold: a real change can never be folded, and a fold always says how much it - hid.""" +class TestMuteRows(unittest.TestCase): + """Unticking a compare category plays its lines down in the panes. What + must hold: the lines stay (they are the context the surviving changes are + read in), they lose their diff colour, and a real change can never be + muted.""" def _rows(self, *specs): return [Row(i + 1, 'o{}'.format(i), i + 1, 'n{}'.format(i), mode, kind) for i, (mode, kind) in enumerate(specs)] - def test_a_run_becomes_one_row_stating_the_count(self): + def test_muted_rows_keep_their_place_text_and_numbers(self): rows = self._rows(('ctx', 'equal'), ('minor', 'uuid'), ('minor', 'uuid'), ('minor', 'uuid'), ('ctx', 'equal')) - out, _m = collapse_rows(rows, ['minor']) - self.assertEqual([r.mode for r in out], ['ctx', 'folded', 'ctx']) - self.assertIn('3 uuid lines hidden', out[1].old_txt) - - def test_both_sides_carry_the_same_placeholder_text(self): - # identical text on both sides: the row must read as context, not as a - # difference, and the panes must keep the same block count - out, _m = collapse_rows(self._rows(('minor', 'uuid')), ['minor']) - self.assertEqual(out[0].old_txt, out[0].new_txt) - self.assertIsNone(out[0].old_no) - self.assertIn('1 uuid line hidden', out[0].old_txt) - - def test_real_and_moved_can_never_be_folded(self): + out = mute_rows(rows, ['minor']) + self.assertEqual([r.mode for r in out], + ['ctx', MUTED, MUTED, MUTED, 'ctx']) + self.assertEqual(len(out), len(rows)) + for before, after in zip(rows, out): + self.assertEqual((before.old_no, before.old_txt, + before.new_no, before.new_txt), + (after.old_no, after.old_txt, + after.new_no, after.new_txt)) + + def test_a_muted_row_still_says_why(self): + # the kind survives, so the pane and the map can still tell a muted + # comment from a muted uuid without going back to the hunks + out = mute_rows(self._rows(('minor', 'uuid')), ['minor']) + self.assertEqual(out[0].kind, 'uuid') + + def test_real_and_moved_can_never_be_muted(self): rows = self._rows(('real', 'real'), ('moved', 'moved')) - out, _m = collapse_rows(rows, ['real', 'moved', 'minor']) + out = mute_rows(rows, ['real', 'moved', 'minor']) self.assertEqual([r.mode for r in out], ['real', 'moved']) - def test_only_the_unticked_category_folds(self): + def test_only_the_unticked_category_is_muted(self): rows = self._rows(('comment', 'comment'), ('minor', 'uuid')) - out, _m = collapse_rows(rows, ['comment']) - self.assertEqual([r.mode for r in out], ['folded', 'minor']) - out, _m = collapse_rows(rows, ['comment', 'minor']) - self.assertEqual([r.mode for r in out], ['folded']) - self.assertIn('comment + uuid', out[0].old_txt) + out = mute_rows(rows, ['comment']) + self.assertEqual([r.mode for r in out], [MUTED, 'minor']) + out = mute_rows(rows, ['comment', 'minor']) + self.assertEqual([r.mode for r in out], [MUTED, MUTED]) - def test_row_map_carries_navigation_stops_across(self): + def test_row_indices_are_untouched_so_navigation_stops_still_point_home(self): rows = self._rows(('minor', 'uuid'), ('minor', 'uuid'), ('real', 'real'), ('minor', 'uuid'), ('real', 'real')) - out, row_map = collapse_rows(rows, ['minor']) - self.assertEqual([r.mode for r in out], - ['folded', 'real', 'folded', 'real']) - self.assertEqual(row_map[2], 1) # the real rows still land on themselves - self.assertEqual(row_map[4], 3) - for i, r in enumerate(rows): - if r.mode == 'real': - self.assertEqual(out[row_map[i]].mode, 'real') + out = mute_rows(rows, ['minor']) + self.assertEqual([i for i, r in enumerate(out) if r.mode == 'real'], + [i for i, r in enumerate(rows) if r.mode == 'real']) def test_no_modes_is_the_identity(self): rows = self._rows(('minor', 'uuid'), ('real', 'real')) - out, row_map = collapse_rows(rows, []) - self.assertEqual(out, rows) - self.assertEqual(row_map, [0, 1]) + self.assertEqual(mute_rows(rows, []), rows) + + def test_the_input_is_left_alone(self): + rows = self._rows(('minor', 'uuid')) + mute_rows(rows, ['minor']) + self.assertEqual(rows[0].mode, 'minor') if __name__ == '__main__':