📋 Pengy Unified

by anon · 2026-08-03 12:56:04
← all clips 🖨️ print / PDF
Table of contents 1. Establish a cross-platform UI contract 2. Use one canonical set of UI tokens 3. Force a consistent Qt widget style Python C++ 4. Replace the current scaling model Remove application use of QT_SCALE_FACTOR Keep OS DPI and Pengy UI scaling separate 5. Define explicit typography roles 6. Make input and output use the exact same final font calculation Input Output 7. Make scaling restart-oriented and deterministic 8. Apply themes through QPalette first 9. Standardize tabs explicitly 10. Replace native tab-close indicators 11. Replace emoji action icons 12. Normalize common controls 13. Preserve a few intentional platform differences 14. Clean up fixed sizes 15. Implementation sequence Phase A — Baseline and contract Phase B — Scaling only Phase C — Unified style and palette Phase D — Tabs and icons Phase E — Metric cleanup Phase F — Port parity 16. Automated testing strategy Structural tests Screenshot tests Diagnostic logging 17. Windows-specific validation Recommended end state

Goal

Make the desktop Qt UI in Pengy, PengyR, and PengyCPP visually and behaviorally consistent on macOS, Linux, and Windows, while retaining native behavior only where it is beneficial—file dialogs, clipboard, window management, accessibility, and OS DPI handling.

The Python implementation remains the behavioral reference. PengyR and PengyCPP should implement the same UI contract.


1. Establish a cross-platform UI contract

Document the intentional behavior before changing code:

Element

Unified behavior

Widget style

Qt Fusion on all platforms

Main and Settings tabs

Left-aligned, natural width

Tab close button

Right side, custom theme-aware X

Tab overflow

Scroll buttons enabled

UI font

Platform system font, scaled by Pengy’s UI scale

Chat font

Platform fixed-width font, same scaling policy

Input font

Same role and size as chat output

Theme

Applied through a complete QPalette plus limited QSS

Icons

Bundled SVG icons rather than emoji/native glyphs

UI scale changes

Saved immediately; restart required for complete application

OS DPI scaling

Left entirely to Qt and the operating system

Native file dialogs

Retained

Native window chrome/menu bar

Retained

This lets “unified” mean controlled application content, not fighting macOS or Windows window management.


2. Use one canonical set of UI tokens

The theme definitions are currently duplicated in:

Create a language-neutral UI token file, ideally JSON:

text

ui/ui_tokens.json


It should define:

For example:

json

{

"metrics": {

"spacing_small": 4,

"spacing_medium": 8,

"spacing_large": 12,

"radius_small": 4,

"radius_medium": 6,

"control_height": 32,

"icon_small": 14,

"icon_medium": 18

},

"typography": {

"chat_ratio": 1.0,

"small_ratio": 0.9,

"heading_1_ratio": 1.4,

"heading_2_ratio": 1.3

}

}


Each repository can package a copy initially. A validation script should verify that all three copies have the same hash. That is simpler and safer than immediately introducing a shared Git submodule. Longer term, PengyR’s C++ Qt shell and PengyCPP could share an actual small Qt UI library or Git subtree because their theme and widget code is already nearly identical.


3. Force a consistent Qt widget style

Set the Qt Fusion style before creating the main window.

Python

After creating QApplication:

python

from PySide6.QtWidgets import QStyleFactory


app.setStyle(QStyleFactory.create("Fusion"))

C++

cpp

#include <QStyleFactory>


app.setStyle(QStyleFactory::create("Fusion"));


Do this in:

Fusion gives a common baseline for:

Without this, QSS is being applied over three substantially different native styles. That is the source of many of the “Qt is whack” interactions. Native file dialogs and native top-level window chrome can remain native even while application widgets use Fusion. An optional future ui_style: unified/native setting could permit native styling, but the default should be unified.


4. Replace the current scaling model

This is the most important technical change.

Remove application use of QT_SCALE_FACTOR

Do not set QT_SCALE_FACTOR from ui_scale in any implementation. Remove this behavior from:

Also remove the logic that reads and divides by QT_SCALE_FACTOR:

Pengy should not unset or override a user-supplied external QT_SCALE_FACTOR; it should simply stop using it as its own preference. If a user or desktop environment supplies one, Qt can honor it independently.

Keep OS DPI and Pengy UI scaling separate

There are two independent concepts:

  1. OS display/DPI scaling

Managed automatically by Qt 6, Cocoa, X11/Wayland, and Windows per-monitor DPI.

  1. Pengy’s UI scale preference

A multiplier applied once by Pengy. The Pengy helper becomes:

python

def ui_scale_factor(config_or_theme=None):

raw = float((config_or_theme or {}).get("ui_scale", 100))

return max(50.0, min(raw, 300.0)) / 100.0

cpp

inline double uiScaleFactor(int scale) {

return qBound(50, scale, 300) / 100.0;

}


No platform-specific branches and no environment-variable division.


5. Define explicit typography roles

Do not independently invent point sizes in individual widgets. Define roles such as:

Use the platform fonts as the starting point:

python

ui_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)

chat_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)


Rather than replacing the system font size with an unconditional 10 pt, read its normal size and multiply it:

python

base_pt = ui_font.pointSizeF()

if base_pt <= 0:

base_pt = 10.0


ui_font.setPointSizeF(base_pt * ui_scale)

app.setFont(ui_font)


For chat, choose a documented ratio relative to the UI font:

python

chat_font.setPointSizeF(base_pt * ui_scale)


Equivalent C++ logic should be used. This allows:

If exact physical sizes are more important than native font sizing, use a fixed baseline everywhere. I recommend relative system sizing because it works better with accessibility settings and Windows configurations.


6. Make input and output use the exact same final font calculation

The two affected areas must share a computed chat font.

Input

Update:

Assign the final chatFont() directly to the QTextEdit. Do not additionally scale it through an environment variable or stylesheet.

Output

Update:

The QTextBrowser and its QTextDocument should use the same final font. For rich-text CSS, prefer relative units:

css

body {

font-family: "...";

font-size: 1em;

}


.role-user,

.role-assistant {

font-size: 0.9em;

}


.reasoning-body {

font-size: 0.85em;

}


h1 { font-size: 1.4em; }

h2 { font-size: 1.3em; }

h3 { font-size: 1.1em; }

h4 { font-size: 1em; }


Set the document’s default font explicitly:

python

document.setDefaultFont(chat_font)

cpp

document()->setDefaultFont(chatFont);


Then let the CSS use em relative to that font. This avoids Qt HTML’s inconsistent treatment of absolute pt sizes. Code blocks may use the same fixed family at 1em; labels and reasoning can use relative sizes.


7. Make scaling restart-oriented and deterministic

Theme changes can remain live. UI scale changes should be saved but treated as requiring a restart. Reasons:

When the user changes UI scale:

  1. Save the new scale.
  2. Show a small message: “Restart Pengy to apply UI scale completely.”
  3. Do not attempt a partial live preview.
  4. On startup, establish fonts and metrics before constructing MainWindow.

This eliminates the current state where some controls change immediately and others change only after restart.


8. Apply themes through QPalette first

The current implementation primarily uses a large global stylesheet. That overrides widget surfaces while leaving some indicators and platform-provided icons unchanged. Build and install a complete QPalette for each theme:

Example conceptually:

python

palette.setColor(QPalette.ColorRole.Window, QColor(theme["bg"]))

palette.setColor(QPalette.ColorRole.Base, QColor(theme["input_bg"]))

palette.setColor(QPalette.ColorRole.Text, QColor(theme["input_fg"]))

palette.setColor(QPalette.ColorRole.Button, QColor(theme["panel_2"]))

palette.setColor(QPalette.ColorRole.ButtonText, QColor(theme["fg"]))

palette.setColor(QPalette.ColorRole.Highlight, QColor(theme["primary"]))

palette.setColor(QPalette.ColorRole.HighlightedText, QColor(theme["primary_fg"]))


Then apply smaller, targeted QSS for Pengy’s rounded shapes, spacing, and special surfaces. This lets Fusion draw checkboxes, radio buttons, arrows, scrollbars, and disabled states using the correct palette instead of mixing native white icons with custom dark backgrounds.


9. Standardize tabs explicitly

Do not rely on platform style hints. For every QTabWidget:

Global QSS:

css

QTabWidget::tab-bar {

alignment: left;

}


Widget setup:

python

bar = tabs.tabBar()

bar.setExpanding(False)

bar.setUsesScrollButtons(True)

cpp

QTabBar* bar = tabs->tabBar();

bar->setExpanding(false);

bar->setUsesScrollButtons(true);


Apply this to:

It may be useful to create a helper:

text

configure_tab_widget(tabs, closable=False)


so all three implementations follow the same policy.


10. Replace native tab-close indicators

Create a small custom tab-close button rather than moving Qt’s existing platform button. Requirements:

A painted X is more reliable than font glyphs:

When a tab is added:

python

bar.setTabButton(index, QTabBar.ButtonPosition.RightSide, close_button)


The button should emit the existing tab-close request using the tab’s current index, not a captured stale index after tabs are moved. Settings tabs are not closable and should not receive close buttons.


11. Replace emoji action icons

Several UI controls currently depend on characters such as:

Their appearance, alignment, color, and even availability vary significantly among macOS, Linux, and Windows. Bundle a small SVG icon set:

text

assets/icons/

settings.svg

delete.svg

save.svg

play.svg

edit.svg

refresh.svg

close.svg

stop.svg

attach.svg


Use monochrome icons that can be recolored from theme tokens, or provide light/dark variants. Keep text labels where useful:

This should improve Windows substantially, where emoji often come from a colorful fallback font and can disrupt layout.


12. Normalize common controls

After Fusion and palette support are in place, add targeted styling for:

Avoid replacing every native indicator unless Fusion plus palette fails. Custom SVG indicators create a larger accessibility and state-management burden. Every interactive control should have:

Focus should be visible in every theme, including on Windows keyboard navigation.


13. Preserve a few intentional platform differences

Do not attempt to unify these:

These are OS integration features, not application inconsistency. For Windows high-contrast mode, consider a future accessibility escape hatch that uses the system palette rather than forcing a Pengy theme.


14. Clean up fixed sizes

Audit all hard-coded dimensions and font sizes, especially:

text

font-size: 11px

font-size: 14px

setFixedSize(24, 24)

setFixedHeight(36)

padding: 28px


Classify each value:

  1. Typography — use a typography role or em.
  2. Interactive target — use a scaled metric.
  3. Decorative spacing — use a scaled token.
  4. Window dimensions — generally leave as logical pixels unless they become unusable.

Use helper methods consistently:

python

scaled_metric("control_height")

scaled_metric("icon_small")

font_for_role("chat")


Avoid globally multiplying top-level window dimensions. OS DPI already handles logical-to-device scaling, and a user UI scale should not necessarily make an 1100×700 window become 2200×1400.


15. Implementation sequence

Phase A — Baseline and contract

  1. Capture screenshots on:
  1. Capture at:
  1. Record actual fonts, font point sizes, device pixel ratios, and widget dimensions.
  2. Add the UI behavior contract to each repository.

Phase B — Scaling only

  1. Remove Pengy-controlled QT_SCALE_FACTOR.
  2. Remove division by that variable.
  3. Introduce font and metric roles.
  4. Set application font once at startup.
  5. Unify chat output and input fonts.
  6. Convert rich output CSS to relative units.
  7. Make UI scale explicitly restart-required.
  8. Test on all three operating systems before styling further.

This phase should be isolated so scaling regressions are easy to identify.

Phase C — Unified style and palette

  1. Force Fusion.
  2. Install complete theme palettes.
  3. Reduce global QSS to targeted styling.
  4. Verify menus, combo boxes, checks, radios, and scrollbars in every theme.

Phase D — Tabs and icons

  1. Add the shared tab configuration helper.
  2. Force left alignment and non-expansion.
  3. Add custom right-side close buttons.
  4. Replace emoji controls with SVGs.
  5. Validate movable-tab close behavior.

Phase E — Metric cleanup

  1. Replace remaining fixed font sizes.
  2. Scale custom button sizes and padding.
  3. Normalize dialogs and forms.
  4. Check keyboard focus and disabled states.

Phase F — Port parity

  1. Implement and validate Python first.
  2. Port the same contract to PengyR.
  3. Port to PengyCPP.
  4. Compare the two C++ implementations and keep shared files mechanically synchronized.
  5. Add token/hash checks across repositories.

16. Automated testing strategy

Structural tests

Tests should assert:

Screenshot tests

Maintain separate reference snapshots for:

Do not compare pixel-perfect screenshots across operating systems because font rasterization differs. Compare each platform against its own baseline. Useful screens:

  1. Main window with one tab
  2. Main window with several tabs
  3. Main window with tab overflow
  4. Settings/UI
  5. Settings/LLM
  6. Settings/Tools
  7. Menu open
  8. Tool confirmation dialog
  9. Long markdown response with headings, code, table, and reasoning
  10. Input box with attachments
  11. Disabled and focused controls

CI can run:

Diagnostic logging

Add an optional startup diagnostic that reports:

text

OS

Qt version

Qt style

devicePixelRatio

logical DPI

physical DPI

system UI font and point size

system fixed font and point size

Pengy UI scale

effective UI font

effective chat font

external QT_SCALE_FACTOR, if present


That will make future Windows or HiDPI reports much easier to diagnose.


17. Windows-specific validation

Windows needs explicit testing for:

Qt 6 should manage per-monitor DPI. The important rule is not to layer Pengy’s scale into Qt’s DPI scale.


The clean architecture is:

text

OS DPI scaling

↓ handled automatically by Qt


Fusion widget style

↓ common control geometry and behavior


Pengy QPalette

↓ theme-aware native control rendering


Small targeted Pengy QSS

↓ rounded corners, spacing, special surfaces


Pengy UI scale

↓ applied once to application fonts and explicit custom metrics


Typography roles

↓ shared by input, output, dialogs, and labels


Bundled SVG icons

↓ deterministic cross-platform action visuals


That will not make the three platforms pixel-identical, but it should make them clearly the same application while avoiding the current macOS/Linux scaling tradeoff.