diff --git a/dikte.py b/dikte.py index 4718c46..15faef4 100755 --- a/dikte.py +++ b/dikte.py @@ -1093,6 +1093,12 @@ def run_app(args): app = QApplication(sys.argv) app.setApplicationName("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) _stay_out_of_the_dock() # Before Dikte is built, because building it is what may start a server, and diff --git a/install.sh b/install.sh index 7d4f94f..983752c 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,7 @@ PY="$(command -v python3)" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" AUTOSTART_DIR="$HOME/.config/autostart" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons" SHORTCUT="${1:-Ctrl+Space}" # 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. @@ -81,7 +82,7 @@ if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then fi # 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" chmod +x "$DIR/dikte.py" 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" ;; 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" < "$AUTOSTART_DIR/dikte.desktop" < 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() diff --git a/trayicon.py b/trayicon.py index d016b6a..089cb20 100644 --- a/trayicon.py +++ b/trayicon.py @@ -1,16 +1,25 @@ """The four tray icons, drawn here for systems that have no icon theme. -Linux hands out `audio-input-microphone`, `media-record`, `view-refresh` and -`media-playback-pause` from whatever icon theme is installed, and Qt finds them -through QIcon.fromTheme. macOS has no such registry: fromTheme returns a null -icon there, and a null icon in the menu bar 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. +A desktop hands out `audio-input-microphone`, `media-record`, `view-refresh` +and `media-playback-pause` from whatever icon theme is installed, and Qt finds +them through QIcon.fromTheme. Two systems have nothing to hand out. macOS keeps +no such registry at all. And a Linux session that names no desktop, which is +what i3 and a bare X11 login are, leaves Qt with `hicolor` as its only theme, +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 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. + +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 @@ -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 # icon rather than one being scaled, because a scaled stroke goes soft. SIZES = (22, 44) -# Drawn in black; the mask throws the colour away and keeps the coverage, and -# on a system that does not do masks black is still the right ink for a light -# panel and readable on a dark one. -INK = QColor(0, 0, 0) +# The two inks. macOS is handed the dark one and throws the colour away, keeping +# only the coverage; everywhere else the light one is the glyph and the dark one +# is the outline behind it. +DARK = QColor(0, 0, 0) +LIGHT = QColor(255, 255, 255) def _canvas(size): @@ -37,11 +47,13 @@ def _canvas(size): 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. - The colour is a parameter because the same glyph is the tray stencil, where - it is black and then masked, and the white one on the application icon. + Every shape below takes its colour rather than reaching for a constant: the + 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 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)) -def _microphone(painter, size): - _paint_microphone(painter, size, INK) - - -def _record(painter, size): +def _record(painter, size, ink): """A filled dot: recording, and the same red dot the overlay shows.""" unit = size / 22.0 painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(INK) + painter.setBrush(ink) 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.""" unit = size / 22.0 painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(INK) + painter.setBrush(ink) for left in (6.4, 12.4): painter.drawRoundedRect( 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.""" unit = size / 22.0 - pen = QPen(INK, 2.0 * unit) + pen = QPen(ink, 2.0 * unit) pen.setCapStyle(Qt.PenCapStyle.FlatCap) painter.setPen(pen) 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 # strokes: at 22 points a drawn arrowhead closes up into a blob. painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(INK) + painter.setBrush(ink) head = QPainterPath() head.moveTo(QPointF(11.0 * unit, 1.6 * unit)) head.lineTo(QPointF(11.0 * unit, 7.2 * unit)) @@ -121,43 +129,87 @@ SHAPES = { _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): """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 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) if shape is None: return QIcon() - if name in _cache: - return _cache[name] + mask = sys.platform == "darwin" + if (name, mask) in _cache: + return _cache[(name, mask)] result = QIcon() for size in SIZES: - pixmap, painter = _canvas(size) - try: - shape(painter, size) - finally: - painter.end() - result.addPixmap(pixmap) - # The line that makes it a template image: macOS then owns the colour, and - # the icon follows the menu bar into dark mode instead of staying black. - result.setIsMask(True) - _cache[name] = result + result.addPixmap(_stencil(shape, size, DARK) if mask + else _outlined(shape, size)) + if mask: + # The line that makes it a template image: macOS then owns the colour, + # and the icon follows the menu bar into dark mode instead of staying + # black. Nothing outside macOS reads it, and setting it there would only + # promise a recolouring that never comes. + result.setIsMask(True) + _cache[(name, mask)] = result return result # --- the application icon -------------------------------------------------- # -# The menu bar wants a flat stencil; the Finder, the Dock and the permission -# dialogs want a picture. Same microphone, on a ground of its own, and drawn -# here as well so that `install-mac.sh` has an .icns to build without a binary -# blob living in the repository. +# The menu bar wants a flat stencil; the Finder, the Dock, an application menu +# and a task bar want a picture. Same microphone, on a ground of its own, and +# drawn here as well so that `install-mac.sh` has an .icns and `install.sh` a +# 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. 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): @@ -184,13 +236,30 @@ def app_pixmap(size): painter.translate(size / 2.0, size / 2.0) painter.scale(0.64, 0.64) painter.translate(-size / 2.0, -size / 2.0) - _paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF)) + _microphone(painter, size, LIGHT) painter.restore() finally: painter.end() 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): """Write the PNGs `iconutil -c icns` reads. The directory it wrote to.""" directory = pathlib.Path(directory) @@ -202,21 +271,49 @@ def write_iconset(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): - """`python3 trayicon.py .iconset`, which install-mac.sh calls. + """`trayicon.py .iconset` for install-mac.sh, `--hicolor ` for + install.sh. A QGuiApplication has to exist before a QPixmap can, and offscreen because this runs from a shell script with no window to open. """ - if len(argv) != 2: - print("usage: trayicon.py .iconset", file=sys.stderr) + hicolor = len(argv) == 3 and argv[1] == "--hicolor" + if not hicolor and len(argv) != 2: + print("usage: trayicon.py .iconset\n" + " trayicon.py --hicolor ", file=sys.stderr) return 2 from PyQt6.QtGui import QGuiApplication QGuiApplication.setAttribute( Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) app = QGuiApplication(["dikte-icon", "-platform", "offscreen"]) try: - print(write_iconset(argv[1])) + if hicolor: + for path in write_hicolor(argv[2]): + print(path) + else: + print(write_iconset(argv[1])) finally: del app return 0 diff --git a/uninstall.sh b/uninstall.sh index a9c2ad9..4bfca85 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -29,6 +29,7 @@ else MACOS=0 APP_DIR="$HOME/.local/share/applications" AUTOSTART_DIR="$HOME/.config/autostart" + ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" PY="$(command -v python3 || true)" @@ -148,6 +149,19 @@ fi if ((!MACOS)); then remove "$APP_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 # before this script existed may have left one behind on a desktop that never # used them.