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.
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.
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.
Set the Qt Fusion style before creating the main window.
After creating QApplication:
python
from PySide6.QtWidgets import QStyleFactory
app.setStyle(QStyleFactory.create("Fusion"))
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.
This is the most important technical change.
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.
There are two independent concepts:
Managed automatically by Qt 6, Cocoa, X11/Wayland, and Windows per-monitor DPI.
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.
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.
The two affected areas must share a computed chat font.
Update:
Assign the final chatFont() directly to the QTextEdit. Do not additionally scale it through an environment variable or stylesheet.
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.
Theme changes can remain live. UI scale changes should be saved but treated as requiring a restart. Reasons:
When the user changes UI scale:
This eliminates the current state where some controls change immediately and others change only after restart.
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.
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.
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.
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.
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.
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.
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:
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.
This phase should be isolated so scaling regressions are easy to identify.
Tests should assert:
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:
CI can run:
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.
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.