mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #35 from yusufipk/visible-tray-icon
Give the tray an icon a black bar cannot swallow
This commit is contained in:
@@ -1093,6 +1093,12 @@ def run_app(args):
|
|||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Dikte")
|
app.setApplicationName("Dikte")
|
||||||
app.setDesktopFileName("dikte")
|
app.setDesktopFileName("dikte")
|
||||||
|
# Wayland goes from that name to the .desktop file and takes the icon from
|
||||||
|
# there, and macOS takes it from the bundle, but an X11 window has only what
|
||||||
|
# it carries itself, and a settings window with no icon is a blank square in
|
||||||
|
# every task bar and alt-tab list.
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
app.setWindowIcon(trayicon.app_icon())
|
||||||
app.setQuitOnLastWindowClosed(False)
|
app.setQuitOnLastWindowClosed(False)
|
||||||
_stay_out_of_the_dock()
|
_stay_out_of_the_dock()
|
||||||
# Before Dikte is built, because building it is what may start a server, and
|
# Before Dikte is built, because building it is what may start a server, and
|
||||||
|
|||||||
+24
-3
@@ -16,6 +16,7 @@ PY="$(command -v python3)"
|
|||||||
BIN_DIR="$HOME/.local/bin"
|
BIN_DIR="$HOME/.local/bin"
|
||||||
APP_DIR="$HOME/.local/share/applications"
|
APP_DIR="$HOME/.local/share/applications"
|
||||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||||
|
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
|
||||||
SHORTCUT="${1:-Ctrl+Space}"
|
SHORTCUT="${1:-Ctrl+Space}"
|
||||||
# Without the colon, so that a second argument given as "" stays empty. That is
|
# Without the colon, so that a second argument given as "" stays empty. That is
|
||||||
# how update.sh says "this one was turned off", as against not saying anything.
|
# how update.sh says "this one was turned off", as against not saying anything.
|
||||||
@@ -81,7 +82,7 @@ if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Launchers -------------------------------------------------------------
|
# 3. Launchers -------------------------------------------------------------
|
||||||
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR"
|
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR"
|
||||||
ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte"
|
ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte"
|
||||||
chmod +x "$DIR/dikte.py"
|
chmod +x "$DIR/dikte.py"
|
||||||
ok "Command installed: $BIN_DIR/dikte"
|
ok "Command installed: $BIN_DIR/dikte"
|
||||||
@@ -90,13 +91,33 @@ case ":$PATH:" in
|
|||||||
*) warn "$BIN_DIR is not on your PATH. For fish: fish_add_path $BIN_DIR" ;;
|
*) warn "$BIN_DIR is not on your PATH. For fish: fish_add_path $BIN_DIR" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# The icon, drawn by trayicon.py so that there is no binary in the repository,
|
||||||
|
# and installed under a name of our own. Naming a theme icon like
|
||||||
|
# audio-input-microphone instead only works where a theme has it: on i3 or a
|
||||||
|
# bare X11 login Qt is left with hicolor, which has no such name, and the entry
|
||||||
|
# comes out blank. hicolor is also where this goes, since it is the theme every
|
||||||
|
# desktop must fall back to.
|
||||||
|
if "$PY" "$DIR/trayicon.py" --hicolor "$ICON_DIR" >/dev/null 2>&1; then
|
||||||
|
ICON=dikte
|
||||||
|
# Only GTK reads a cache, and only if one is already there; a stale cache
|
||||||
|
# would otherwise hide the file we just wrote.
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null \
|
||||||
|
&& [[ -f "$ICON_DIR/hicolor/icon-theme.cache" ]]; then
|
||||||
|
gtk-update-icon-cache -q -f -t "$ICON_DIR/hicolor" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
ok "Icon installed: $ICON_DIR/hicolor"
|
||||||
|
else
|
||||||
|
ICON=audio-input-microphone
|
||||||
|
warn "Could not draw the icon, so the entries name your theme's microphone"
|
||||||
|
fi
|
||||||
|
|
||||||
cat > "$APP_DIR/dikte.desktop" <<EOF
|
cat > "$APP_DIR/dikte.desktop" <<EOF
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Dikte
|
Name=Dikte
|
||||||
Comment=Voice dictation: record, transcribe, clean up, paste
|
Comment=Voice dictation: record, transcribe, clean up, paste
|
||||||
Exec=$PY $DIR/dikte.py
|
Exec=$PY $DIR/dikte.py
|
||||||
Icon=audio-input-microphone
|
Icon=$ICON
|
||||||
Categories=Utility;AudioVideo;
|
Categories=Utility;AudioVideo;
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
EOF
|
EOF
|
||||||
@@ -107,7 +128,7 @@ cat > "$AUTOSTART_DIR/dikte.desktop" <<EOF
|
|||||||
Type=Application
|
Type=Application
|
||||||
Name=Dikte
|
Name=Dikte
|
||||||
Exec=$PY $DIR/dikte.py
|
Exec=$PY $DIR/dikte.py
|
||||||
Icon=audio-input-microphone
|
Icon=$ICON
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""The drawn icons, checked against the thing that made them necessary.
|
||||||
|
|
||||||
|
A tray never says what colour it is painting over, so the only question worth
|
||||||
|
asking of these pixmaps is whether they can be seen at all: a glyph drawn in
|
||||||
|
black on i3's black bar is an empty slot, which is what issue #27 was. So the
|
||||||
|
test blends each icon onto a bar of its own and asks whether anything of it
|
||||||
|
survives, once over black and once over white.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from PyQt6.QtGui import QImage
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
import trayicon
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
# One application for the whole run; Qt allows no second one.
|
||||||
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
BLACK = (0, 0, 0)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
# Breeze's own panel, as the middle case: not every bar is at one end.
|
||||||
|
CHARCOAL = (0x31, 0x36, 0x3B)
|
||||||
|
|
||||||
|
|
||||||
|
def _pixels(pixmap):
|
||||||
|
image = pixmap.toImage().convertToFormat(QImage.Format.Format_ARGB32)
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
yield image.pixelColor(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def _stands_out_from(pixmap, background):
|
||||||
|
"""True when some pixel of this icon differs from the bar behind it.
|
||||||
|
|
||||||
|
The icons are transparent everywhere they are not drawn, so the tray gets
|
||||||
|
the blend rather than the pixmap, and a pixel that blends back into the bar
|
||||||
|
is a pixel nobody sees. 60 out of 255 is the gap being asked for, which is
|
||||||
|
well under black against white and well over the antialiasing at an edge.
|
||||||
|
"""
|
||||||
|
for colour in _pixels(pixmap):
|
||||||
|
weight = colour.alphaF()
|
||||||
|
ink = (colour.red(), colour.green(), colour.blue())
|
||||||
|
for channel, behind in zip(ink, background):
|
||||||
|
if abs(channel * weight + behind * (1 - weight) - behind) > 60:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _drawn(pixmap):
|
||||||
|
"""True when anything at all was painted onto this pixmap."""
|
||||||
|
return any(colour.alpha() > 128 for colour in _pixels(pixmap))
|
||||||
|
|
||||||
|
|
||||||
|
class Tray(DikteTest):
|
||||||
|
"""The four state icons, on a session whose theme has none of them."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# Held between calls on purpose, so a test does not read what the one
|
||||||
|
# before it drew on another platform.
|
||||||
|
self.patch_attr(trayicon, "_cache", {})
|
||||||
|
|
||||||
|
def test_a_name_we_do_not_draw_is_a_null_icon(self):
|
||||||
|
# dikte.py asks the theme first and falls through to here, so anything
|
||||||
|
# answered with a picture would be one the theme should have given.
|
||||||
|
self.assertTrue(trayicon.icon("emblem-important").isNull())
|
||||||
|
|
||||||
|
def test_every_state_has_a_shape(self):
|
||||||
|
for name in trayicon.SHAPES:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
icon = trayicon.icon(name)
|
||||||
|
self.assertFalse(icon.isNull())
|
||||||
|
for size in trayicon.SIZES:
|
||||||
|
self.assertTrue(_drawn(icon.pixmap(size, size)))
|
||||||
|
|
||||||
|
def test_visible_on_a_bar_of_any_colour(self):
|
||||||
|
# The regression: on X11 the icon is composited over a bar whose colour
|
||||||
|
# nobody declares, and i3's is black.
|
||||||
|
with mock.patch.object(sys, "platform", "linux"):
|
||||||
|
for name in trayicon.SHAPES:
|
||||||
|
for size in trayicon.SIZES:
|
||||||
|
pixmap = trayicon.icon(name).pixmap(size, size)
|
||||||
|
for background in (BLACK, WHITE, CHARCOAL):
|
||||||
|
with self.subTest(name=name, size=size, bar=background):
|
||||||
|
self.assertTrue(_stands_out_from(pixmap, background))
|
||||||
|
|
||||||
|
def test_x11_is_not_handed_a_mask(self):
|
||||||
|
# Only macOS recolours one. Setting it elsewhere would promise a
|
||||||
|
# recolouring that never comes, and the outline would be the only thing
|
||||||
|
# keeping the icon visible either way.
|
||||||
|
with mock.patch.object(sys, "platform", "linux"):
|
||||||
|
self.assertFalse(trayicon.icon("media-record").isMask())
|
||||||
|
|
||||||
|
def test_macos_gets_a_flat_black_stencil(self):
|
||||||
|
# There the colour is thrown away and only the coverage is read, so an
|
||||||
|
# outline would come back as part of the glyph.
|
||||||
|
with mock.patch.object(sys, "platform", "darwin"):
|
||||||
|
icon = trayicon.icon("audio-input-microphone")
|
||||||
|
self.assertTrue(icon.isMask())
|
||||||
|
for colour in _pixels(icon.pixmap(22, 22)):
|
||||||
|
if colour.alpha() > 128:
|
||||||
|
self.assertEqual(
|
||||||
|
(colour.red(), colour.green(), colour.blue()), BLACK)
|
||||||
|
|
||||||
|
def test_the_two_platforms_do_not_share_a_cached_icon(self):
|
||||||
|
with mock.patch.object(sys, "platform", "darwin"):
|
||||||
|
self.assertTrue(trayicon.icon("media-record").isMask())
|
||||||
|
with mock.patch.object(sys, "platform", "linux"):
|
||||||
|
self.assertFalse(trayicon.icon("media-record").isMask())
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationIcon(DikteTest):
|
||||||
|
"""The picture the menu entry, the task bar and the Finder are given."""
|
||||||
|
|
||||||
|
def test_written_where_every_desktop_looks(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
written = trayicon.write_hicolor(root)
|
||||||
|
self.assertEqual(len(written), len(trayicon.HICOLOR_SIZES))
|
||||||
|
for size, path in zip(trayicon.HICOLOR_SIZES, written):
|
||||||
|
with self.subTest(size=size):
|
||||||
|
self.assertEqual(
|
||||||
|
path.parts[-3:], (f"{size}x{size}", "apps", "dikte.png"))
|
||||||
|
self.assertTrue(path.is_file())
|
||||||
|
image = QImage(str(path))
|
||||||
|
self.assertEqual((image.width(), image.height()),
|
||||||
|
(size, size))
|
||||||
|
|
||||||
|
def test_the_installed_name_is_the_one_the_entries_use(self):
|
||||||
|
# install.sh writes Icon=dikte into both .desktop files, and a name that
|
||||||
|
# matches no installed file is the blank slot all over again.
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
self.assertTrue(
|
||||||
|
all(path.name == "dikte.png"
|
||||||
|
for path in trayicon.write_hicolor(root)))
|
||||||
|
|
||||||
|
def test_a_tile_rather_than_a_stencil(self):
|
||||||
|
# Coloured on purpose: this one is composited onto backgrounds that are
|
||||||
|
# nothing like a tray, so it carries its own ground.
|
||||||
|
pixmap = trayicon.app_pixmap(64)
|
||||||
|
for background in (BLACK, WHITE):
|
||||||
|
self.assertTrue(_stands_out_from(pixmap, background))
|
||||||
|
|
||||||
|
def test_offered_at_the_sizes_a_window_asks_for(self):
|
||||||
|
icon = trayicon.app_icon()
|
||||||
|
self.assertFalse(icon.isNull())
|
||||||
|
self.assertIn(48, [size.width() for size in icon.availableSizes()])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+144
-47
@@ -1,16 +1,25 @@
|
|||||||
"""The four tray icons, drawn here for systems that have no icon theme.
|
"""The four tray icons, drawn here for systems that have no icon theme.
|
||||||
|
|
||||||
Linux hands out `audio-input-microphone`, `media-record`, `view-refresh` and
|
A desktop hands out `audio-input-microphone`, `media-record`, `view-refresh`
|
||||||
`media-playback-pause` from whatever icon theme is installed, and Qt finds them
|
and `media-playback-pause` from whatever icon theme is installed, and Qt finds
|
||||||
through QIcon.fromTheme. macOS has no such registry: fromTheme returns a null
|
them through QIcon.fromTheme. Two systems have nothing to hand out. macOS keeps
|
||||||
icon there, and a null icon in the menu bar is an item you cannot see, which is
|
no such registry at all. And a Linux session that names no desktop, which is
|
||||||
the whole of Dikte's interface gone. So the same four shapes are drawn here,
|
what i3 and a bare X11 login are, leaves Qt with `hicolor` as its only theme,
|
||||||
and used whenever the theme has nothing to offer.
|
where none of those four names exist. On both, fromTheme returns a null icon,
|
||||||
|
and a null icon in a tray is an item you cannot see, which is the whole of
|
||||||
|
Dikte's interface gone. So the same four shapes are drawn here, and used
|
||||||
|
whenever the theme has nothing to offer.
|
||||||
|
|
||||||
They are drawn as template images: one colour, transparent everywhere else,
|
On macOS they are template images: one colour, transparent everywhere else,
|
||||||
with isMask set. That is what lets macOS invert them for a dark menu bar and
|
with isMask set. That is what lets macOS invert them for a dark menu bar and
|
||||||
grey them while the menu is open, and it is why the shapes are outlines rather
|
grey them while the menu is open, and it is why the shapes are outlines rather
|
||||||
than the coloured glyphs a Linux theme would give.
|
than the coloured glyphs a Linux theme would give.
|
||||||
|
|
||||||
|
X11 has no such contract. A tray there is given a picture, paints it over
|
||||||
|
whatever colour the bar happens to be, and never says what that colour is, so
|
||||||
|
black ink on i3's black bar is an empty slot rather than an icon. The same
|
||||||
|
shapes are drawn there in white over a dark copy of themselves spread a pixel
|
||||||
|
outwards, which stands out on a dark bar and stays readable on a light one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -23,10 +32,11 @@ from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
|
|||||||
# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the
|
# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the
|
||||||
# icon rather than one being scaled, because a scaled stroke goes soft.
|
# icon rather than one being scaled, because a scaled stroke goes soft.
|
||||||
SIZES = (22, 44)
|
SIZES = (22, 44)
|
||||||
# Drawn in black; the mask throws the colour away and keeps the coverage, and
|
# The two inks. macOS is handed the dark one and throws the colour away, keeping
|
||||||
# on a system that does not do masks black is still the right ink for a light
|
# only the coverage; everywhere else the light one is the glyph and the dark one
|
||||||
# panel and readable on a dark one.
|
# is the outline behind it.
|
||||||
INK = QColor(0, 0, 0)
|
DARK = QColor(0, 0, 0)
|
||||||
|
LIGHT = QColor(255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
def _canvas(size):
|
def _canvas(size):
|
||||||
@@ -37,11 +47,13 @@ def _canvas(size):
|
|||||||
return pixmap, painter
|
return pixmap, painter
|
||||||
|
|
||||||
|
|
||||||
def _paint_microphone(painter, size, ink):
|
def _microphone(painter, size, ink):
|
||||||
"""A capsule on a stand: idle, and the application's own mark.
|
"""A capsule on a stand: idle, and the application's own mark.
|
||||||
|
|
||||||
The colour is a parameter because the same glyph is the tray stencil, where
|
Every shape below takes its colour rather than reaching for a constant: the
|
||||||
it is black and then masked, and the white one on the application icon.
|
same glyph is drawn dark for the macOS mask, white for the tray on X11, dark
|
||||||
|
again a pixel out for the outline under it, and white on the blue tile of
|
||||||
|
the application icon.
|
||||||
"""
|
"""
|
||||||
unit = size / 22.0
|
unit = size / 22.0
|
||||||
pen = QPen(ink, 1.6 * unit)
|
pen = QPen(ink, 1.6 * unit)
|
||||||
@@ -63,23 +75,19 @@ def _paint_microphone(painter, size, ink):
|
|||||||
painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit))
|
painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit))
|
||||||
|
|
||||||
|
|
||||||
def _microphone(painter, size):
|
def _record(painter, size, ink):
|
||||||
_paint_microphone(painter, size, INK)
|
|
||||||
|
|
||||||
|
|
||||||
def _record(painter, size):
|
|
||||||
"""A filled dot: recording, and the same red dot the overlay shows."""
|
"""A filled dot: recording, and the same red dot the overlay shows."""
|
||||||
unit = size / 22.0
|
unit = size / 22.0
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(INK)
|
painter.setBrush(ink)
|
||||||
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
|
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
|
||||||
|
|
||||||
|
|
||||||
def _paused(painter, size):
|
def _paused(painter, size, ink):
|
||||||
"""Two bars: the recording is still ours, and nothing is going into it."""
|
"""Two bars: the recording is still ours, and nothing is going into it."""
|
||||||
unit = size / 22.0
|
unit = size / 22.0
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(INK)
|
painter.setBrush(ink)
|
||||||
for left in (6.4, 12.4):
|
for left in (6.4, 12.4):
|
||||||
painter.drawRoundedRect(
|
painter.drawRoundedRect(
|
||||||
QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit),
|
QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit),
|
||||||
@@ -87,10 +95,10 @@ def _paused(painter, size):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _working(painter, size):
|
def _working(painter, size, ink):
|
||||||
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
||||||
unit = size / 22.0
|
unit = size / 22.0
|
||||||
pen = QPen(INK, 2.0 * unit)
|
pen = QPen(ink, 2.0 * unit)
|
||||||
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
@@ -101,7 +109,7 @@ def _working(painter, size):
|
|||||||
# The head, as a filled triangle at the open end rather than two more
|
# The head, as a filled triangle at the open end rather than two more
|
||||||
# strokes: at 22 points a drawn arrowhead closes up into a blob.
|
# strokes: at 22 points a drawn arrowhead closes up into a blob.
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(INK)
|
painter.setBrush(ink)
|
||||||
head = QPainterPath()
|
head = QPainterPath()
|
||||||
head.moveTo(QPointF(11.0 * unit, 1.6 * unit))
|
head.moveTo(QPointF(11.0 * unit, 1.6 * unit))
|
||||||
head.lineTo(QPointF(11.0 * unit, 7.2 * unit))
|
head.lineTo(QPointF(11.0 * unit, 7.2 * unit))
|
||||||
@@ -121,43 +129,87 @@ SHAPES = {
|
|||||||
_cache = {}
|
_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _stencil(shape, size, ink, pad=0):
|
||||||
|
"""One shape in one colour, held `pad` pixels in from every edge.
|
||||||
|
|
||||||
|
The inset is what leaves room for the outline: the shapes are drawn to the
|
||||||
|
edge of their 22 point square, so a copy shifted outwards would otherwise
|
||||||
|
lose the foot of the microphone and the tip of the arrow to the crop.
|
||||||
|
"""
|
||||||
|
pixmap, painter = _canvas(size)
|
||||||
|
try:
|
||||||
|
if pad:
|
||||||
|
painter.translate(pad, pad)
|
||||||
|
painter.scale((size - 2 * pad) / size, (size - 2 * pad) / size)
|
||||||
|
shape(painter, size, ink)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
|
def _outlined(shape, size):
|
||||||
|
"""The shape in white, over a dark copy of itself spread a pixel outwards.
|
||||||
|
|
||||||
|
Eight shifted copies rather than a blur or a stroked path: the shapes are a
|
||||||
|
mix of strokes and fills, and this is the one way to put a border round all
|
||||||
|
of them without drawing each one twice by hand.
|
||||||
|
"""
|
||||||
|
pad = max(1, round(size / 22.0))
|
||||||
|
outline = _stencil(shape, size, DARK, pad)
|
||||||
|
glyph = _stencil(shape, size, LIGHT, pad)
|
||||||
|
pixmap, painter = _canvas(size)
|
||||||
|
try:
|
||||||
|
for dx in (-pad, 0, pad):
|
||||||
|
for dy in (-pad, 0, pad):
|
||||||
|
painter.drawPixmap(dx, dy, outline)
|
||||||
|
painter.drawPixmap(0, 0, glyph)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
def icon(name):
|
def icon(name):
|
||||||
"""The named icon drawn here, or a null QIcon when it is not one of ours.
|
"""The named icon drawn here, or a null QIcon when it is not one of ours.
|
||||||
|
|
||||||
Cached because the tray is refreshed on every state change and every one of
|
Cached because the tray is refreshed on every state change and every one of
|
||||||
those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the
|
those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the
|
||||||
pixmaps inside it are shared, so handing the same object out is safe.
|
pixmaps inside it are shared, so handing the same object out is safe. The
|
||||||
|
platform is part of the key rather than settled at import, so that a test
|
||||||
|
can stand on either one.
|
||||||
"""
|
"""
|
||||||
shape = SHAPES.get(name)
|
shape = SHAPES.get(name)
|
||||||
if shape is None:
|
if shape is None:
|
||||||
return QIcon()
|
return QIcon()
|
||||||
if name in _cache:
|
mask = sys.platform == "darwin"
|
||||||
return _cache[name]
|
if (name, mask) in _cache:
|
||||||
|
return _cache[(name, mask)]
|
||||||
|
|
||||||
result = QIcon()
|
result = QIcon()
|
||||||
for size in SIZES:
|
for size in SIZES:
|
||||||
pixmap, painter = _canvas(size)
|
result.addPixmap(_stencil(shape, size, DARK) if mask
|
||||||
try:
|
else _outlined(shape, size))
|
||||||
shape(painter, size)
|
if mask:
|
||||||
finally:
|
# The line that makes it a template image: macOS then owns the colour,
|
||||||
painter.end()
|
# and the icon follows the menu bar into dark mode instead of staying
|
||||||
result.addPixmap(pixmap)
|
# black. Nothing outside macOS reads it, and setting it there would only
|
||||||
# The line that makes it a template image: macOS then owns the colour, and
|
# promise a recolouring that never comes.
|
||||||
# the icon follows the menu bar into dark mode instead of staying black.
|
result.setIsMask(True)
|
||||||
result.setIsMask(True)
|
_cache[(name, mask)] = result
|
||||||
_cache[name] = result
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# --- the application icon --------------------------------------------------
|
# --- the application icon --------------------------------------------------
|
||||||
#
|
#
|
||||||
# The menu bar wants a flat stencil; the Finder, the Dock and the permission
|
# The menu bar wants a flat stencil; the Finder, the Dock, an application menu
|
||||||
# dialogs want a picture. Same microphone, on a ground of its own, and drawn
|
# and a task bar want a picture. Same microphone, on a ground of its own, and
|
||||||
# here as well so that `install-mac.sh` has an .icns to build without a binary
|
# drawn here as well so that `install-mac.sh` has an .icns and `install.sh` a
|
||||||
# blob living in the repository.
|
# set of PNGs to install without a binary blob living in the repository.
|
||||||
|
|
||||||
# What iconutil expects to find in an .iconset: each of these at 1x and 2x.
|
# What iconutil expects to find in an .iconset: each of these at 1x and 2x.
|
||||||
APP_ICON_SIZES = (16, 32, 128, 256, 512)
|
APP_ICON_SIZES = (16, 32, 128, 256, 512)
|
||||||
|
# What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a
|
||||||
|
# file dialog 16, and something scaling for a HiDPI panel wants the big ones.
|
||||||
|
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256)
|
||||||
|
|
||||||
|
|
||||||
def app_pixmap(size):
|
def app_pixmap(size):
|
||||||
@@ -184,13 +236,30 @@ def app_pixmap(size):
|
|||||||
painter.translate(size / 2.0, size / 2.0)
|
painter.translate(size / 2.0, size / 2.0)
|
||||||
painter.scale(0.64, 0.64)
|
painter.scale(0.64, 0.64)
|
||||||
painter.translate(-size / 2.0, -size / 2.0)
|
painter.translate(-size / 2.0, -size / 2.0)
|
||||||
_paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF))
|
_microphone(painter, size, LIGHT)
|
||||||
painter.restore()
|
painter.restore()
|
||||||
finally:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
return pixmap
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
|
_app_icon = None
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon():
|
||||||
|
"""The application icon as a QIcon, for the windows and whatever lists them.
|
||||||
|
|
||||||
|
Wayland reads it off the .desktop file instead, through the desktop file
|
||||||
|
name the application sets, but X11 has only what the window itself carries.
|
||||||
|
"""
|
||||||
|
global _app_icon
|
||||||
|
if _app_icon is None:
|
||||||
|
_app_icon = QIcon()
|
||||||
|
for size in (16, 22, 24, 32, 48, 64, 128):
|
||||||
|
_app_icon.addPixmap(app_pixmap(size))
|
||||||
|
return _app_icon
|
||||||
|
|
||||||
|
|
||||||
def write_iconset(directory):
|
def write_iconset(directory):
|
||||||
"""Write the PNGs `iconutil -c icns` reads. The directory it wrote to."""
|
"""Write the PNGs `iconutil -c icns` reads. The directory it wrote to."""
|
||||||
directory = pathlib.Path(directory)
|
directory = pathlib.Path(directory)
|
||||||
@@ -202,21 +271,49 @@ def write_iconset(directory):
|
|||||||
return directory
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def write_hicolor(directory, name="dikte"):
|
||||||
|
"""Install the icon into an XDG theme. The paths it wrote.
|
||||||
|
|
||||||
|
A .desktop file names its icon rather than carrying a path, and a name is
|
||||||
|
only found if some installed theme has it. `audio-input-microphone`, which
|
||||||
|
is what the entries used to name, is in Breeze and in Adwaita but not in
|
||||||
|
hicolor, and hicolor is all Qt and a panel are left with on a session that
|
||||||
|
names no desktop. Under a name of our own in hicolor it is found everywhere,
|
||||||
|
since hicolor is the one theme every desktop is required to fall back to.
|
||||||
|
"""
|
||||||
|
directory = pathlib.Path(directory)
|
||||||
|
written = []
|
||||||
|
for size in HICOLOR_SIZES:
|
||||||
|
apps = directory / "hicolor" / f"{size}x{size}" / "apps"
|
||||||
|
apps.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = apps / f"{name}.png"
|
||||||
|
app_pixmap(size).save(str(path), "PNG")
|
||||||
|
written.append(path)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
def _main(argv):
|
def _main(argv):
|
||||||
"""`python3 trayicon.py <path>.iconset`, which install-mac.sh calls.
|
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
|
||||||
|
install.sh.
|
||||||
|
|
||||||
A QGuiApplication has to exist before a QPixmap can, and offscreen because
|
A QGuiApplication has to exist before a QPixmap can, and offscreen because
|
||||||
this runs from a shell script with no window to open.
|
this runs from a shell script with no window to open.
|
||||||
"""
|
"""
|
||||||
if len(argv) != 2:
|
hicolor = len(argv) == 3 and argv[1] == "--hicolor"
|
||||||
print("usage: trayicon.py <directory>.iconset", file=sys.stderr)
|
if not hicolor and len(argv) != 2:
|
||||||
|
print("usage: trayicon.py <directory>.iconset\n"
|
||||||
|
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
from PyQt6.QtGui import QGuiApplication
|
from PyQt6.QtGui import QGuiApplication
|
||||||
QGuiApplication.setAttribute(
|
QGuiApplication.setAttribute(
|
||||||
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
|
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
|
||||||
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
|
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
|
||||||
try:
|
try:
|
||||||
print(write_iconset(argv[1]))
|
if hicolor:
|
||||||
|
for path in write_hicolor(argv[2]):
|
||||||
|
print(path)
|
||||||
|
else:
|
||||||
|
print(write_iconset(argv[1]))
|
||||||
finally:
|
finally:
|
||||||
del app
|
del app
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ else
|
|||||||
MACOS=0
|
MACOS=0
|
||||||
APP_DIR="$HOME/.local/share/applications"
|
APP_DIR="$HOME/.local/share/applications"
|
||||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||||
|
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
|
||||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
||||||
PY="$(command -v python3 || true)"
|
PY="$(command -v python3 || true)"
|
||||||
@@ -148,6 +149,19 @@ fi
|
|||||||
if ((!MACOS)); then
|
if ((!MACOS)); then
|
||||||
remove "$APP_DIR/dikte.desktop"
|
remove "$APP_DIR/dikte.desktop"
|
||||||
remove "$AUTOSTART_DIR/dikte.desktop"
|
remove "$AUTOSTART_DIR/dikte.desktop"
|
||||||
|
# The icon, at each of the sizes install.sh drew it. One line rather than
|
||||||
|
# eight, and the size directories stay: they are the theme's, not ours.
|
||||||
|
icons=0
|
||||||
|
for png in "$ICON_DIR"/hicolor/*/apps/dikte.png; do
|
||||||
|
[[ -e "$png" ]] || continue
|
||||||
|
rm -f "$png"
|
||||||
|
icons=$((icons + 1))
|
||||||
|
done
|
||||||
|
if ((icons)); then
|
||||||
|
ok "Removed the icon from $ICON_DIR/hicolor"
|
||||||
|
else
|
||||||
|
gone "Was not there: $ICON_DIR/hicolor/*/apps/dikte.png"
|
||||||
|
fi
|
||||||
# Removing the shortcut takes its desktop file with it, but an install from
|
# Removing the shortcut takes its desktop file with it, but an install from
|
||||||
# before this script existed may have left one behind on a desktop that never
|
# before this script existed may have left one behind on a desktop that never
|
||||||
# used them.
|
# used them.
|
||||||
|
|||||||
Reference in New Issue
Block a user