mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Give the tray an icon a black bar cannot swallow
A session that names no desktop, which is what i3 and a bare X11 login are, leaves Qt with hicolor as its only icon theme, and hicolor has none of the four names the tray asks for. So fromTheme returns nothing and the shapes drawn in trayicon.py are used, as they are on macOS. They were drawn in black, which macOS recolours through the mask and X11 does not, and i3's bar is black: the icon was there all along, painted onto a bar of its own colour. Outside macOS they are now white over a dark copy of themselves spread a pixel out, which stands out on a dark bar and stays readable on a light one, and the mask is set only where something reads it. The .desktop files had the same hole from the other side. They named audio-input-microphone, which is in Breeze and in Adwaita but not in hicolor, so the menu entry and the autostart entry were blank on the same systems. install.sh now draws the application icon into ~/.local/share/icons/hicolor and both entries name it; uninstall.sh takes it back. The windows carry it too on X11, where a window has no .desktop file to be looked up in. Closes #27
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()
|
||||||
+142
-45
@@ -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] = result
|
_cache[(name, mask)] = 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,20 +271,48 @@ 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:
|
||||||
|
if hicolor:
|
||||||
|
for path in write_hicolor(argv[2]):
|
||||||
|
print(path)
|
||||||
|
else:
|
||||||
print(write_iconset(argv[1]))
|
print(write_iconset(argv[1]))
|
||||||
finally:
|
finally:
|
||||||
del app
|
del app
|
||||||
|
|||||||
@@ -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