From b04d6d90fabc80eaffd8ea79d9f2c453d36aa962 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 10 Feb 2026 22:44:43 -0500 Subject: [PATCH] feat: add Sankey "Money Flow" chart to analysis tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-column Sankey diagram (Income → Categories → Tags) rendered with custom matplotlib bezier paths. Features gradient-fill bands that interpolate color from source to destination nodes, neon glow effects on nodes and bands, and a surplus node when income exceeds expenses. Also fixes QFont::setPointSize warning by switching dark.qss font-size units from px to pt, preventing Qt delegate code from receiving -1 point sizes during inline cell editing. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-10-sankey-money-flow-design.md | 114 ++++++ src/services/analysis.py | 14 + src/ui/analysis_view.py | 366 +++++++++++++++++- src/ui/themes/dark.qss | 14 +- 4 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-02-10-sankey-money-flow-design.md diff --git a/docs/plans/2026-02-10-sankey-money-flow-design.md b/docs/plans/2026-02-10-sankey-money-flow-design.md new file mode 100644 index 0000000..64354df --- /dev/null +++ b/docs/plans/2026-02-10-sankey-money-flow-design.md @@ -0,0 +1,114 @@ +# Sankey "Money Flow" Chart — Design + +## Summary + +Add a new "Money Flow" tab to the Analysis view that renders a three-column Sankey diagram showing how income flows through spending categories into needs/wants/savings tags. Rendered entirely with matplotlib using custom bezier path drawing — no new dependencies. + +## Data Structure + +Three columns of nodes connected by curved bands: + +``` +Income ──► Categories ──► Tags +``` + +- **Left**: Single "Income" node — sum of all positive-amount transactions in the filtered period. +- **Middle**: One node per spending category — sized by total absolute spend. Plus a "Surplus" node if income exceeds total expenses. +- **Right**: Needs, Wants, Savings, Untagged — sized by total absolute spend per tag. + +Bands represent dollar amounts flowing between nodes. Thickness is proportional to amount. All values come from `AnalysisService` queries filtered by the standard `_FilterBar` (date range, account, person). + +## Visual Design + +### Theme Integration (Cyberpunk "Matrix 2026") + +- Background: `VOID` (#0a0a0f), matching all other charts +- Axes styled via existing `_style_ax()` / `_style_figure()` helpers +- Color palette: reuses `CHART_COLORS`, `TAG_COLORS`, `NEON_GREEN`, `NEON_AMBER` + +### Nodes + +- Thin vertical rectangles (width ~0.02 in axes coords) +- Fill: node color at 60% opacity +- Border: node color at full opacity, 1.5px +- Glow: 2-3 progressively wider/more-transparent copies behind for neon bloom + +Colors: +- Income node: `NEON_GREEN` (#00ff41) +- Category nodes: `CHART_COLORS` palette (same as existing charts) +- Tag nodes: `TAG_COLORS` (needs=green, wants=magenta, savings=cyan) +- Surplus node: `NEON_AMBER` (#ffb627) +- Untagged node: `TEXT_DIM` (#4a5568) + +### Bands (Flows) + +- Smooth cubic bezier curves (matplotlib `Path` with `CURVE4` codes) +- **Gradient fill**: Each band sliced into ~50 vertical strips, color interpolated from source to destination node color +- **Glow effect**: A wider, more-transparent copy of each band rendered behind it for neon bloom +- Alpha: ~0.35 for main band, ~0.08-0.15 for glow passes +- Sorting: Largest bands drawn first (back), smallest on top + +### Labels + +- Node name + dollar amount (e.g., "Groceries $1,234") +- Left column labels: right-aligned, to the left of the node +- Middle column labels: centered below/above nodes +- Right column labels: left-aligned, to the right of the node +- Font: `TEXT` color (#b8c4d4), fontsize 8 +- Dollar amounts: bold + +### Layout + +- Three columns at x = 0.1, 0.5, 0.9 (in axes normalized coords) +- Nodes stacked vertically, sorted largest-on-top +- Small gaps between nodes (~2% of total height) +- Axes limits: x=[0, 1], y=[0, 1], all axes/spines hidden + +## Implementation + +### New Class: `_MoneyFlowTab(QWidget)` + +Follows the same pattern as `_SpendingOverTimeTab`, `_CategoryBreakdownTab`, and `_ForecastingTab`: + +```python +class _MoneyFlowTab(QWidget): + def __init__(self, session, parent=None): + # _FilterBar, Figure, FigureCanvasQTAgg, NavigationToolbar2QT + # Connect filter signals to refresh() + + def refresh(self): + # 1. Query income total, category totals, tag totals + # 2. Build node positions and sizes + # 3. Draw nodes, bands, labels via _draw_sankey() +``` + +### Helper: `_draw_sankey(ax, income, categories, tags)` + +Separated rendering logic for testability and clarity: + +1. Calculate node heights proportional to amounts +2. Position nodes in three columns with vertical gaps +3. For each flow (income→category, category→tag): draw gradient bezier bands with glow +4. Draw node rectangles with glow +5. Draw labels + +### Data Queries + +Reuses existing `AnalysisService` methods: +- `spending_by_category()` — for category totals +- `spending_by_tag()` — for tag totals +- Direct query for income total (sum of positive amounts in filtered period) +- Cross-query: category-to-tag amounts (new query needed — group by category_id + tag) + +### Tab Placement + +4th tab in `AnalysisView._tabs`: "Money Flow" — inserted after "Category Breakdown", before "Forecasting". + +## Files Changed + +- `src/ui/analysis_view.py` — add `_MoneyFlowTab` class and register in `AnalysisView.__init__` +- `src/services/analysis.py` — add `spending_by_category_and_tag()` method for the category→tag cross-query + +## No New Dependencies + +Everything uses matplotlib's existing `PathPatch`, `Path`, `Rectangle`, and color utilities — all already available via the `matplotlib>=3.8` dependency. diff --git a/src/services/analysis.py b/src/services/analysis.py index b413a2b..5505c8c 100644 --- a/src/services/analysis.py +++ b/src/services/analysis.py @@ -70,6 +70,20 @@ class AnalysisService: ) return [{"category": r.category, "total": float(r.total), "count": r.count} for r in q.all()] + def spending_by_category_and_tag(self, start=None, end=None, **filters) -> list[dict]: + q = self._base_query(start=start, end=end, **filters) + q = ( + q.join(Category, Transaction.category_id == Category.id, isouter=True) + .with_entities( + func.coalesce(Category.name, "Uncategorized").label("category"), + func.coalesce(Transaction.tag, "untagged").label("tag"), + func.sum(Transaction.amount).label("total"), + ) + .group_by("category", "tag") + .order_by(func.sum(Transaction.amount)) + ) + return [{"category": r.category, "tag": r.tag, "total": float(r.total)} for r in q.all()] + def spending_by_tag(self, start=None, end=None, **filters) -> list[dict]: q = self._base_query(start=start, end=end, **filters) q = ( diff --git a/src/ui/analysis_view.py b/src/ui/analysis_view.py index 979bba9..fff946c 100644 --- a/src/ui/analysis_view.py +++ b/src/ui/analysis_view.py @@ -1,5 +1,5 @@ # src/ui/analysis_view.py -"""Analysis view with three tabs: Spending Over Time, Category Breakdown, Forecasting.""" +"""Analysis view with four tabs: Spending Over Time, Category Breakdown, Money Flow, Forecasting.""" from __future__ import annotations @@ -27,8 +27,10 @@ from sqlalchemy import func from sqlalchemy.orm import Session from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT +from matplotlib.colors import to_rgba from matplotlib.figure import Figure -from matplotlib.patches import Circle +from matplotlib.patches import Circle, PathPatch, Rectangle +from matplotlib.path import Path as MplPath from src.models.account import Account from src.models.category import Category @@ -38,7 +40,7 @@ from src.services.analysis import AnalysisService from src.services.forecasting import ForecastingService from src.services.recurring import RecurringDetector from src.ui.themes import ( - VOID, SURFACE, TEXT, TEXT_BRIGHT, TEXT_DIM, BORDER, NEON_GREEN, + VOID, SURFACE, TEXT, TEXT_BRIGHT, TEXT_DIM, BORDER, NEON_GREEN, NEON_AMBER, CHART_COLORS, TAG_COLORS, chart_color, ) @@ -510,11 +512,364 @@ class _ForecastingTab(QWidget): self._canvas.draw() +# ====================================================================== +# Tab 4 -- Money Flow (Sankey) +# ====================================================================== + +# -- Sankey drawing helpers ------------------------------------------------ + +def _bezier_band(ax, x0, y0_top, y0_bot, x1, y1_top, y1_bot, + color_src, color_dst, n_slices=50, alpha=0.35, glow=True): + """Draw a gradient-filled bezier band between two vertical positions. + + The band curves from (x0, y0) to (x1, y1) using cubic bezier control + points at the horizontal midpoint. Gradient is achieved by slicing + the band into thin vertical strips with interpolated colors. + """ + cx = (x0 + x1) / 2 # bezier control-point x + + rgba_src = to_rgba(color_src, alpha) + rgba_dst = to_rgba(color_dst, alpha) + + # Glow pass — wider, more transparent, drawn first + if glow: + glow_expand = (y0_top - y0_bot) * 0.15 # expand by 15% of band height + for g_alpha_mult, g_expand_mult in [(0.08, 3.0), (0.15, 1.5)]: + ge = glow_expand * g_expand_mult + _bezier_band(ax, + x0, y0_top + ge, y0_bot - ge, + x1, y1_top + ge, y1_bot - ge, + color_src, color_dst, + n_slices=max(20, n_slices // 2), + alpha=g_alpha_mult, glow=False) + + # Slice the band into vertical strips for gradient effect + for i in range(n_slices): + t0 = i / n_slices + t1 = (i + 1) / n_slices + + # Interpolate color + t_mid = (t0 + t1) / 2 + r = rgba_src[0] + (rgba_dst[0] - rgba_src[0]) * t_mid + g = rgba_src[1] + (rgba_dst[1] - rgba_src[1]) * t_mid + b = rgba_src[2] + (rgba_dst[2] - rgba_src[2]) * t_mid + a = rgba_src[3] + (rgba_dst[3] - rgba_src[3]) * t_mid + color = (r, g, b, a) + + # Evaluate bezier curve at t0 and t1 for top and bottom edges + def _bezier_y(t, ya, yb): + """Cubic bezier y-value with control points at midpoint.""" + # P0=ya, P1=ya, P2=yb, P3=yb (smooth S-curve) + return ya * (1 - t) ** 3 + ya * 3 * (1 - t) ** 2 * t + yb * 3 * (1 - t) * t ** 2 + yb * t ** 3 + + def _bezier_x(t): + return x0 * (1 - t) ** 3 + cx * 3 * (1 - t) ** 2 * t + cx * 3 * (1 - t) * t ** 2 + x1 * t ** 3 + + xl = _bezier_x(t0) + xr = _bezier_x(t1) + yt0_l = _bezier_y(t0, y0_top, y1_top) + yb0_l = _bezier_y(t0, y0_bot, y1_bot) + yt0_r = _bezier_y(t1, y0_top, y1_top) + yb0_r = _bezier_y(t1, y0_bot, y1_bot) + + # Draw filled quad + verts = [(xl, yb0_l), (xl, yt0_l), (xr, yt0_r), (xr, yb0_r), (xl, yb0_l)] + codes = [MplPath.MOVETO, MplPath.LINETO, MplPath.LINETO, MplPath.LINETO, MplPath.CLOSEPOLY] + patch = PathPatch(MplPath(verts, codes), facecolor=color, edgecolor="none", linewidth=0) + ax.add_patch(patch) + + +def _draw_node(ax, x, y_bot, height, color, width=0.03, glow=True): + """Draw a glowing node rectangle.""" + rgba_fill = to_rgba(color, 0.6) + rgba_edge = to_rgba(color, 1.0) + + if glow: + for g_alpha, g_pad in [(0.06, 0.012), (0.12, 0.006)]: + glow_rect = Rectangle( + (x - width / 2 - g_pad, y_bot - g_pad), + width + 2 * g_pad, height + 2 * g_pad, + facecolor=to_rgba(color, g_alpha), edgecolor="none", linewidth=0, + ) + ax.add_patch(glow_rect) + + rect = Rectangle( + (x - width / 2, y_bot), width, height, + facecolor=rgba_fill, edgecolor=rgba_edge, linewidth=1.5, + ) + ax.add_patch(rect) + + +def _draw_sankey(ax, income_total, cat_totals, tag_totals, cat_tag_totals): + """Render the full Sankey diagram on the given axes. + + Parameters + ---------- + income_total : float + Total income (positive amount sum). + cat_totals : list[dict] + Each dict has 'category' and 'total' (positive). + tag_totals : list[dict] + Each dict has 'tag' and 'total' (positive). + cat_tag_totals : list[dict] + Each dict has 'category', 'tag', and 'total' (positive). + """ + ax.set_xlim(-0.15, 1.15) + ax.set_ylim(-0.05, 1.05) + ax.axis("off") + _style_ax(ax) + + expense_total = sum(c["total"] for c in cat_totals) + surplus = max(0, income_total - expense_total) + + if income_total <= 0: + ax.text(0.5, 0.5, "No income data for selected filters", + ha="center", va="center", color=TEXT_COLOR, fontsize=10, + transform=ax.transAxes) + return + + # Column x-positions + x_left, x_mid, x_right = 0.08, 0.50, 0.92 + node_gap = 0.012 # vertical gap between nodes + node_w = 0.03 + + # -- Build nodes ------------------------------------------------------- + # Left: single Income node + usable_height = 0.9 # leave margins top/bottom + + # Middle: categories + surplus, sorted by total descending + mid_items = sorted(cat_totals, key=lambda d: d["total"], reverse=True) + if surplus > 0: + mid_items.append({"category": "Surplus", "total": surplus}) + mid_total = sum(d["total"] for d in mid_items) + + # Right: tags, sorted by total descending + right_items = sorted(tag_totals, key=lambda d: d["total"], reverse=True) + right_total = sum(d["total"] for d in right_items) + + # Assign colors + cat_colors = {} + for i, item in enumerate(mid_items): + if item["category"] == "Surplus": + cat_colors["Surplus"] = NEON_AMBER + else: + cat_colors[item["category"]] = chart_color(i) + + tag_color_map = { + "needs": TAG_COLORS.get("need", NEON_GREEN), + "wants": TAG_COLORS.get("want", NEON_GREEN), + "savings": TAG_COLORS.get("savings", NEON_GREEN), + "untagged": TEXT_DIM, + } + + # -- Compute vertical positions ---------------------------------------- + def _layout_column(items, total_val, key="total"): + """Return list of (y_bot, y_top) for each item, stacked top-to-bottom.""" + n = len(items) + total_gap = node_gap * max(0, n - 1) + available = usable_height - total_gap + positions = [] + y = 0.95 # start from top + for item in items: + frac = item[key] / total_val if total_val > 0 else 1.0 / max(n, 1) + h = available * frac + y_top = y + y_bot = y - h + positions.append((y_bot, y_top)) + y = y_bot - node_gap + return positions + + # Income — single node spanning full usable height + inc_positions = [(0.95 - usable_height, 0.95)] + + # Middle nodes + mid_positions = _layout_column(mid_items, mid_total) + + # Right nodes + right_positions = _layout_column(right_items, right_total) + + # -- Draw bands (Income → Category) ------------------------------------ + # Track current y-offset within income node for stacking bands + inc_y_cursor = inc_positions[0][1] # start from top of income node + inc_height = inc_positions[0][1] - inc_positions[0][0] + + for i, item in enumerate(mid_items): + cat_name = item["category"] + band_frac = item["total"] / income_total if income_total > 0 else 0 + band_h = inc_height * band_frac + src_top = inc_y_cursor + src_bot = inc_y_cursor - band_h + inc_y_cursor = src_bot + + dst_bot, dst_top = mid_positions[i] + color = cat_colors.get(cat_name, chart_color(i)) + _bezier_band(ax, x_left + node_w / 2, src_top, src_bot, + x_mid - node_w / 2, dst_top, dst_bot, + NEON_GREEN, color) + + # -- Draw bands (Category → Tag) --------------------------------------- + # Build a lookup: {category: {tag: amount}} + cat_tag_map = defaultdict(lambda: defaultdict(float)) + for item in cat_tag_totals: + cat_tag_map[item["category"]][item["tag"]] = item["total"] + + # For each category, split its outgoing flow into tag bands + tag_y_cursors = {} # track where each tag's incoming bands stack + for j, t_item in enumerate(right_items): + tag_y_cursors[t_item["tag"]] = right_positions[j][1] # start from top + + for i, item in enumerate(mid_items): + cat_name = item["category"] + if cat_name == "Surplus": + continue # surplus doesn't flow to tags + + cat_bot, cat_top = mid_positions[i] + cat_height = cat_top - cat_bot + cat_y_cursor = cat_top + + tag_flows = cat_tag_map.get(cat_name, {}) + cat_flow_total = sum(tag_flows.values()) + + # Draw band for each tag + for t_item in right_items: + tag_name = t_item["tag"] + flow_amt = tag_flows.get(tag_name, 0) + if flow_amt <= 0: + continue + + band_frac = flow_amt / cat_flow_total if cat_flow_total > 0 else 0 + band_h_src = cat_height * band_frac + + tag_total = t_item["total"] + tag_idx = next(j for j, r in enumerate(right_items) if r["tag"] == tag_name) + tag_bot, tag_top = right_positions[tag_idx] + tag_height = tag_top - tag_bot + band_h_dst = tag_height * (flow_amt / tag_total) if tag_total > 0 else 0 + + src_top = cat_y_cursor + src_bot = cat_y_cursor - band_h_src + cat_y_cursor = src_bot + + dst_top = tag_y_cursors[tag_name] + dst_bot = dst_top - band_h_dst + tag_y_cursors[tag_name] = dst_bot + + src_color = cat_colors.get(cat_name, chart_color(i)) + dst_color = tag_color_map.get(tag_name, TEXT_DIM) + _bezier_band(ax, x_mid + node_w / 2, src_top, src_bot, + x_right - node_w / 2, dst_top, dst_bot, + src_color, dst_color) + + # -- Draw nodes -------------------------------------------------------- + # Income + _draw_node(ax, x_left, inc_positions[0][0], + inc_positions[0][1] - inc_positions[0][0], NEON_GREEN, node_w) + ax.text(x_left - 0.04, (inc_positions[0][0] + inc_positions[0][1]) / 2, + f"Income\n${income_total:,.0f}", ha="right", va="center", + color=NEON_GREEN, fontsize=8, fontweight="bold") + + # Categories + for i, item in enumerate(mid_items): + cat_bot, cat_top = mid_positions[i] + color = cat_colors.get(item["category"], chart_color(i)) + _draw_node(ax, x_mid, cat_bot, cat_top - cat_bot, color, node_w) + label = item["category"] + if len(label) > 14: + label = label[:12] + ".." + ax.text(x_mid, cat_bot - 0.008, f"{label}\n${item['total']:,.0f}", + ha="center", va="top", color=TEXT_COLOR, fontsize=6.5) + + # Tags + for j, t_item in enumerate(right_items): + tag_bot, tag_top = right_positions[j] + tag_name = t_item["tag"] + color = tag_color_map.get(tag_name, TEXT_DIM) + _draw_node(ax, x_right, tag_bot, tag_top - tag_bot, color, node_w) + display_name = tag_name.capitalize() + ax.text(x_right + 0.04, (tag_bot + tag_top) / 2, + f"{display_name}\n${t_item['total']:,.0f}", ha="left", va="center", + color=color, fontsize=8, fontweight="bold") + + +class _MoneyFlowTab(QWidget): + """Sankey diagram showing Income → Categories → Tags.""" + + def __init__(self, session: Session, parent=None): + super().__init__(parent) + self._session = session + self._analysis = AnalysisService(session) + + root = QVBoxLayout(self) + + # Filters + self._filters = _FilterBar(session) + root.addWidget(self._filters) + + # Canvas + self._fig = Figure(tight_layout=True) + _style_figure(self._fig) + self._canvas = FigureCanvasQTAgg(self._fig) + self._toolbar = NavigationToolbar2QT(self._canvas, self) + root.addWidget(self._toolbar) + root.addWidget(self._canvas, stretch=1) + + # Signals + self._filters.date_from.dateChanged.connect(lambda: self.refresh()) + self._filters.date_to.dateChanged.connect(lambda: self.refresh()) + self._filters.account_combo.currentIndexChanged.connect(lambda: self.refresh()) + self._filters.person_combo.currentIndexChanged.connect(lambda: self.refresh()) + + self.refresh() + + def refresh(self): + filters = self._filters.filters() + + # Query income (positive amounts, excluding transfers) + start = filters.get("start") + end = filters.get("end") + query_kwargs = {} + if "account_id" in filters: + query_kwargs["account_id"] = filters["account_id"] + if "person_id" in filters: + query_kwargs["person_id"] = filters["person_id"] + + income_q = self._analysis._base_query(start=start, end=end, **query_kwargs) + income_q = income_q.filter(Transaction.amount > 0) + income_total = sum(float(t.amount) for t in income_q.all()) + + # Category spending (expenses only) + cat_data = self._analysis.spending_by_category(start=start, end=end, **query_kwargs) + cat_totals = [{"category": d["category"], "total": abs(d["total"])} + for d in cat_data if d["total"] < 0] + + # Tag spending + tag_data = self._analysis.spending_by_tag(start=start, end=end, **query_kwargs) + tag_totals = [{"tag": d["tag"], "total": abs(d["total"])} + for d in tag_data if d["total"] < 0] + + # Category × Tag cross data + cat_tag_data = self._analysis.spending_by_category_and_tag( + start=start, end=end, **query_kwargs + ) + cat_tag_totals = [{"category": d["category"], "tag": d["tag"], "total": abs(d["total"])} + for d in cat_tag_data if d["total"] < 0] + + # Draw + self._fig.clear() + ax = self._fig.add_subplot(111) + _style_ax(ax) + _style_figure(self._fig) + + _draw_sankey(ax, income_total, cat_totals, tag_totals, cat_tag_totals) + + self._canvas.draw() + + # ====================================================================== # Main AnalysisView # ====================================================================== class AnalysisView(QWidget): - """Three-tab analysis dashboard.""" + """Four-tab analysis dashboard.""" def __init__(self, session: Session, parent=None): super().__init__(parent) @@ -530,5 +885,8 @@ class AnalysisView(QWidget): self._category_tab = _CategoryBreakdownTab(session) self._tabs.addTab(self._category_tab, "Category Breakdown") + self._flow_tab = _MoneyFlowTab(session) + self._tabs.addTab(self._flow_tab, "Money Flow") + self._forecast_tab = _ForecastingTab(session) self._tabs.addTab(self._forecast_tab, "Forecasting") diff --git a/src/ui/themes/dark.qss b/src/ui/themes/dark.qss index 33c2865..c50ac7d 100644 --- a/src/ui/themes/dark.qss +++ b/src/ui/themes/dark.qss @@ -10,7 +10,7 @@ QMainWindow, QWidget { background-color: #0a0a0f; color: #b8c4d4; font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace; - font-size: 13px; + font-size: 10pt; } QLabel { @@ -32,7 +32,7 @@ QLabel { border-left: 3px solid transparent; padding: 12px 16px; text-align: left; - font-size: 13px; + font-size: 10pt; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; @@ -60,7 +60,7 @@ QPushButton { border-radius: 4px; padding: 8px 16px; font-weight: bold; - font-size: 13px; + font-size: 10pt; } QPushButton:hover { @@ -85,7 +85,7 @@ QTableView QPushButton, QTableWidget QPushButton { border: 1px solid #00ff41; background-color: transparent; padding: 3px 10px; - font-size: 12px; + font-size: 9pt; border-radius: 3px; } @@ -126,7 +126,7 @@ QHeaderView::section { border: none; border-bottom: 2px solid #00ff41; border-right: 1px solid #1a2332; - font-size: 13px; + font-size: 10pt; font-weight: bold; } @@ -165,7 +165,7 @@ QComboBox QAbstractItemView { background-color: #0d1117; color: #b8c4d4; border: 1px solid #00ff41; - font-size: 13px; + font-size: 10pt; selection-background-color: rgba(0,255,65,0.15); selection-color: #e8edf5; } @@ -233,7 +233,7 @@ QCalendarWidget QToolButton:hover { QCalendarWidget QAbstractItemView { background-color: #0d1117; color: #b8c4d4; - font-size: 13px; + font-size: 10pt; selection-background-color: rgba(0,255,65,0.15); selection-color: #e8edf5; }