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.

@@ -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('
')
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('
'
''
''
@@ -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.

@@ -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)
- 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__':