feat: tray-first UX, text outline, start-at-login toggle, font max 200

- All settings move to flat tray menu; right-click on clock disabled
- ClockLabel: custom QPainterPath text rendering with configurable
  glyph outline (BorderColor) — verified pixel-level offscreen
- Fixed glyph positioning bug (baseline math) caught by isolated test
- Window auto-fits content (adjustSize) — no more oversized clock
- Start at Login: checkable tray item, persisted in QSettings,
  LaunchAgent enable/disable (TDD: roundTrip test caught disable()
  returning false when plist already absent)
- Color dialog: hex/HTML input + 'transparent' + native picker fallback;
  opaque colors stored as #RRGGBB
- Border: glyph outline instead of box border; background still painted
  in paintEvent (QSS doesn't render with WA_TranslucentBackground on macOS)
- Removed window shadow (setHasShadow:NO), no bold, SF Pro Rounded
- Font size dialogs allow up to 200 (was 72)
- macOS: icon template 16pt, light/dark auto-adapt; LaunchAgent points
  to /Applications install
This commit is contained in:
2026-08-19 22:41:33 +03:00
parent 6b9cb32534
commit d9008479c1
9 changed files with 402 additions and 61 deletions
+220 -31
View File
@@ -10,6 +10,69 @@
#include <QApplication>
#include <QScreen>
#include <QTimeZone>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
ClockLabel::ClockLabel(QWidget *parent)
: QLabel(parent), m_textColor(Qt::white), m_outlineColor(Qt::transparent), m_outlineWidth(0)
{
setAlignment(Qt::AlignCenter);
}
void ClockLabel::setDisplayColors(const QColor &textColor, const QColor &outlineColor, qreal outlineWidth)
{
m_textColor = textColor;
m_outlineColor = outlineColor;
m_outlineWidth = outlineWidth;
update();
}
void ClockLabel::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
const QFontMetricsF metrics(font());
const QStringList lines = text().split('\n');
const qreal lineHeight = metrics.height();
const qreal textBlockHeight = lineHeight * lines.size();
// First baseline so the whole block is vertically centered.
const qreal firstBaseline = (height() - textBlockHeight) / 2.0 + metrics.ascent();
for (int i = 0; i < lines.size(); ++i)
{
const QString &line = lines.at(i);
if (line.isEmpty())
{
continue;
}
const qreal lineWidth = metrics.horizontalAdvance(line);
QPainterPath linePath;
linePath.addText(0, 0, font(), line);
painter.save();
painter.translate((width() - lineWidth) / 2.0, firstBaseline + i * lineHeight);
// Outline under the fill so the glyphs stay readable on any backdrop.
if (m_outlineColor.alpha() > 0 && m_outlineWidth > 0)
{
painter.setPen(QPen(m_outlineColor, m_outlineWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
painter.drawPath(linePath);
}
painter.setPen(Qt::NoPen);
painter.setBrush(m_textColor);
painter.drawPath(linePath);
painter.restore();
}
}
ClockWidget::ClockWidget(QWidget *parent)
: QWidget(parent), m_dragging(false), m_resizing(false), m_resizeBorder(5)
@@ -29,6 +92,7 @@ ClockWidget::ClockWidget(QWidget *parent)
setWindowFlags(flags);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_StyledBackground); // required for widget-level QSS background/border
setMouseTracking(true); // Enable mouse tracking for resize cursor
// Set up platform-specific window properties after window creation
@@ -49,15 +113,18 @@ ClockWidget::~ClockWidget()
void ClockWidget::setupUI()
{
m_primaryTimeLabel = new QLabel(this);
m_secondaryTimeLabel = new QLabel(this);
m_primaryTimeLabel->setAlignment(Qt::AlignCenter);
m_secondaryTimeLabel->setAlignment(Qt::AlignCenter);
m_primaryTimeLabel = new ClockLabel(this);
m_primaryLocationLabel = new ClockLabel(this);
m_secondaryTimeLabel = new ClockLabel(this);
m_secondaryLocationLabel = new ClockLabel(this);
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(m_primaryTimeLabel);
layout->addWidget(m_primaryLocationLabel);
layout->addWidget(m_secondaryTimeLabel);
layout->addWidget(m_secondaryLocationLabel);
layout->setContentsMargins(10, 10, 10, 10);
layout->setSpacing(2);
}
void ClockWidget::createMenu()
@@ -75,9 +142,15 @@ void ClockWidget::createMenu()
QAction *bgColorAction = new QAction("Set Background Color", this);
connect(bgColorAction, &QAction::triggered, this, &ClockWidget::changeBackgroundColor);
QAction *fontSizeAction = new QAction("Set Font Size", this);
QAction *fontSizeAction = new QAction("Set Time Font Size", this);
connect(fontSizeAction, &QAction::triggered, this, &ClockWidget::changeFontSize);
QAction *locationFontSizeAction = new QAction("Set Location Font Size", this);
connect(locationFontSizeAction, &QAction::triggered, this, &ClockWidget::changeLocationFontSize);
QAction *borderColorAction = new QAction("Set Border Color", this);
connect(borderColorAction, &QAction::triggered, this, &ClockWidget::changeBorderColor);
QAction *primaryTimeZoneAction = new QAction("Set First Time Zone", this);
connect(primaryTimeZoneAction, &QAction::triggered, this, &ClockWidget::changePrimaryTimeZone);
@@ -94,7 +167,10 @@ void ClockWidget::createMenu()
m_contextMenu->addSeparator();
m_contextMenu->addAction(fontColorAction);
m_contextMenu->addAction(bgColorAction);
m_contextMenu->addAction(borderColorAction);
m_contextMenu->addSeparator();
m_contextMenu->addAction(fontSizeAction);
m_contextMenu->addAction(locationFontSizeAction);
m_contextMenu->addSeparator();
m_contextMenu->addAction(primaryTimeZoneAction);
m_contextMenu->addAction(secondaryTimeZoneAction);
@@ -187,13 +263,52 @@ void ClockWidget::mouseReleaseEvent(QMouseEvent *event)
void ClockWidget::contextMenuEvent(QContextMenuEvent *event)
{
m_contextMenu->exec(event->globalPos());
Q_UNUSED(event);
// Menu lives in the tray icon; right-click on the clock does nothing
// so it never interferes with dragging.
}
void ClockWidget::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
// QSS background does not render on macOS with
// WA_TranslucentBackground, so we paint it manually.
if (m_backgroundColor.alpha() > 0)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(m_backgroundColor);
painter.drawRoundedRect(QRectF(contentsRect()).adjusted(0.5, 0.5, -0.5, -0.5), 10, 10);
}
}
void ClockWidget::updateTime()
{
m_primaryTimeLabel->setText(formattedTime(m_primaryTimeZoneId));
m_secondaryTimeLabel->setText(formattedTime(m_secondaryTimeZoneId));
const QTimeZone primaryTimeZone(m_primaryTimeZoneId.toUtf8());
if (primaryTimeZone.isValid())
{
m_primaryTimeLabel->setText(QDateTime::currentDateTimeUtc().toTimeZone(primaryTimeZone).time().toString("HH:mm"));
m_primaryLocationLabel->setText(locationName(m_primaryTimeZoneId));
}
else
{
m_primaryTimeLabel->setText("Invalid time zone");
m_primaryLocationLabel->setText(m_primaryTimeZoneId);
}
const QTimeZone secondaryTimeZone(m_secondaryTimeZoneId.toUtf8());
if (secondaryTimeZone.isValid())
{
m_secondaryTimeLabel->setText(QDateTime::currentDateTimeUtc().toTimeZone(secondaryTimeZone).time().toString("HH:mm"));
m_secondaryLocationLabel->setText(locationName(m_secondaryTimeZoneId));
}
else
{
m_secondaryTimeLabel->setText("Invalid time zone");
m_secondaryLocationLabel->setText(m_secondaryTimeZoneId);
}
}
void ClockWidget::toggleAlwaysOnTop()
@@ -221,35 +336,63 @@ void ClockWidget::toggleAlwaysOnTop()
void ClockWidget::changeFontColor()
{
QColor color = QColorDialog::getColor(m_fontColor, this, "Choose Font Color");
if (color.isValid())
const std::optional<QColor> color = pickColor("Font Color", m_fontColor, this);
if (color)
{
m_fontColor = color;
m_fontColor = *color;
m_configManager->setFontColor(m_fontColor);
updateStyleSheet();
saveSettings();
}
}
void ClockWidget::changeBackgroundColor()
{
QColor color = QColorDialog::getColor(m_backgroundColor, this, "Choose Background Color");
if (color.isValid())
const std::optional<QColor> color = pickColor("Background Color", m_backgroundColor, this);
if (color)
{
m_backgroundColor = color;
m_backgroundColor = *color;
m_configManager->setBackgroundColor(m_backgroundColor);
updateStyleSheet();
saveSettings();
}
}
void ClockWidget::changeBorderColor()
{
const std::optional<QColor> color = pickColor("Border Color", m_borderColor, this);
if (color)
{
m_borderColor = *color;
m_configManager->setBorderColor(m_borderColor);
updateStyleSheet();
saveSettings();
}
}
void ClockWidget::changeFontSize()
{
bool ok;
int size = QInputDialog::getInt(this, "Set Font Size", "Font Size:", m_fontSize, 8, 72, 1, &ok);
int size = QInputDialog::getInt(this, "Set Time Font Size", "Font Size:", m_fontSize, 8, 200, 1, &ok);
if (ok)
{
m_fontSize = size;
m_configManager->setFontSize(m_fontSize);
updateStyleSheet();
saveSettings();
}
}
void ClockWidget::changeLocationFontSize()
{
bool ok;
int size = QInputDialog::getInt(this, "Set Location Font Size", "Font Size:", m_locationFontSize, 6, 200, 1, &ok);
if (ok)
{
m_locationFontSize = size;
m_configManager->setLocationFontSize(m_locationFontSize);
updateStyleSheet();
saveSettings();
}
}
@@ -325,7 +468,9 @@ void ClockWidget::loadSettings()
// Apply loaded settings
m_fontColor = m_configManager->fontColor();
m_backgroundColor = m_configManager->backgroundColor();
m_borderColor = m_configManager->borderColor();
m_fontSize = m_configManager->fontSize();
m_locationFontSize = m_configManager->locationFontSize();
m_alwaysOnTop = m_configManager->alwaysOnTop();
m_primaryTimeZoneId = m_configManager->primaryTimeZoneId();
m_secondaryTimeZoneId = m_configManager->secondaryTimeZoneId();
@@ -365,7 +510,9 @@ void ClockWidget::saveSettings()
// Update config manager with current values
m_configManager->setFontColor(m_fontColor);
m_configManager->setBackgroundColor(m_backgroundColor);
m_configManager->setBorderColor(m_borderColor);
m_configManager->setFontSize(m_fontSize);
m_configManager->setLocationFontSize(m_locationFontSize);
m_configManager->setAlwaysOnTop(m_alwaysOnTop);
m_configManager->setWindowPosition(pos());
m_configManager->setWindowSize(size());
@@ -381,23 +528,29 @@ void ClockWidget::saveSettings()
void ClockWidget::updateStyleSheet()
{
QString styleSheet = QString(
"QLabel { "
"color: %1; "
"font-family: 'SF Pro Rounded'; "
"font-size: %2pt; "
"font-weight: bold; "
"background-color: %3; "
"border-radius: 10px; "
"padding: 5px; "
"}")
.arg(m_fontColor.name(),
QString::number(m_fontSize),
m_backgroundColor.name(QColor::HexArgb));
// Fonts via QSS; glyph fill + outline via ClockLabel::paintEvent.
const auto labelFontStyleSheet = [](int size) {
return QString("QLabel { font-family: 'SF Pro Rounded'; font-size: %1pt; background: transparent; }")
.arg(size);
};
m_primaryTimeLabel->setStyleSheet(styleSheet);
m_secondaryTimeLabel->setStyleSheet(styleSheet);
setStyleSheet(QString("background-color: transparent;"));
m_primaryTimeLabel->setStyleSheet(labelFontStyleSheet(m_fontSize));
m_secondaryTimeLabel->setStyleSheet(labelFontStyleSheet(m_fontSize));
m_primaryLocationLabel->setStyleSheet(labelFontStyleSheet(m_locationFontSize));
m_secondaryLocationLabel->setStyleSheet(labelFontStyleSheet(m_locationFontSize));
// Shrink/grow the window to fit the text exactly (fonts may have changed).
adjustSize();
// Background is painted manually in paintEvent() — QSS does not render
// it on macOS with WA_TranslucentBackground. Text colors + outline go
// through ClockLabel (QSS cannot stroke glyphs).
const qreal outlineWidth = m_borderColor.alpha() > 0 ? 1.0 : 0.0;
m_primaryTimeLabel->setDisplayColors(m_fontColor, m_borderColor, outlineWidth);
m_secondaryTimeLabel->setDisplayColors(m_fontColor, m_borderColor, outlineWidth);
m_primaryLocationLabel->setDisplayColors(m_fontColor, m_borderColor, outlineWidth * 0.75);
m_secondaryLocationLabel->setDisplayColors(m_fontColor, m_borderColor, outlineWidth * 0.75);
update();
}
void ClockWidget::changeTimeZone(bool primaryClock)
@@ -457,3 +610,39 @@ QString ClockWidget::formattedTime(const QString &timeZoneId) const
return QString("%1\n%2")
.arg(timeInZone.time().toString("HH:mm"), cityName);
}
QString ClockWidget::locationName(const QString &timeZoneId) const
{
return timeZoneId.section('/', -1).replace('_', ' ');
}
std::optional<QColor> ClockWidget::pickColor(const QString &title, const QColor &current, QWidget *parent)
{
bool ok = false;
const QString input = QInputDialog::getText(
parent, "Set " + title,
QString("HTML color (#RRGGBB or #AARRGGBB), or type 'transparent':"),
QLineEdit::Normal,
current.alpha() == 0 ? QStringLiteral("transparent") : current.name(QColor::HexArgb),
&ok);
if (!ok || input.trimmed().isEmpty())
{
return std::nullopt; // cancelled — keep current color
}
const QString normalized = input.trimmed().toLower();
if (normalized == "transparent")
{
return QColor(Qt::transparent);
}
const QColor parsed(normalized);
if (parsed.isValid())
{
return parsed;
}
// Not valid HTML text — fall back to the visual color dialog.
return QColorDialog::getColor(current, parent, "Choose " + title);
}