Compare commits

...

13 Commits

Author SHA1 Message Date
Anna Schumaker a6cd453c63 Emmental 3.0.2
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:31:35 -04:00
Anna Schumaker 7079076857 nowplaying: Add keyboard accelerators
I add accelerators for play, pause, next track, previous track, setting
autopause, adding the current track to the favorites playlist, and
scrolling to the current track in the tracklist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:25:00 -04:00
Anna Schumaker 8d72e1375f nowplaying: Give the Controls accelerator functions and properties
I create can-activate-* properties to indicate if a specific accelerator
can be activated. At the same time, I introduce functions intended to be
called by accelerators to activate each of our widgets.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:56 -04:00
Anna Schumaker 87b92ffc90 nowplaying: Give the autopause widgets {inc,dec}rement() functions
These will be used for keyboard accelerators to set the autopause value.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:51 -04:00
Anna Schumaker bb4ca1e9c4 nowplaying: Give the autopause entry the "card" style class
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:45 -04:00
Anna Schumaker 087c378e59 nowplaying: Remove the Gtk.Frame from the Artwork
All I needed the frame for was to add rounded corners to the
Gtk.Picture, but this had some problems with the Frame expanding beyond
the edges of the picture in some cases. I can get the same effect by
adding the "card" css class to the Picture so hopefully this will work
better.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:40 -04:00
Anna Schumaker 1f434358de nowplaying: Add tooltips to the Now Playing Card
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:34 -04:00
Anna Schumaker 41cb325ad0 buttons: Give the SplitButton an activate() function
This is a keybinding function that calls into the primary button
activate() function. At the same time, I add an "activate-primary"
signal that is emitted when the primary button is activated.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:28 -04:00
Anna Schumaker 0c1e5fcace header: Add keyboard accelerators
I add accelerators for opening files, changing the volume, toggling
background mode, and running the settings editor.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:25 -04:00
Anna Schumaker 9cb927aabb header: Give the volume button public {inc,dec}rement() funcs
I made these functions part of the public interface with option
arguments so they can be used in accelerator callbacks for increasing or
decreasing the volume.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:50:22 -04:00
Anna Schumaker 397c693aef window: Add a keyboard accelerator
I use the "Escape" key as a shortcut for resetting the currently focused
widget.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:50:19 -04:00
Anna Schumaker eb162154b5 window: Add a "user-editing" property
I use the current focus widget to set the "user-editing" property, which
can be used to enable or disable accelerator actions.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:46:10 -04:00
Anna Schumaker 2b5cdaa197 action: Add an ActionEntry class
This is inspried by the Gio.ActionEntry struct, which I can't figure out
how to get working in Python. I add on a few extra helpful features,
such as:

  - Automatically creating a Gio.SimpleAction
  - Tracking the desired accelerator keys
  - Binding the "enabled" state to a specificed property at construction

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:45:58 -04:00
20 changed files with 548 additions and 41 deletions

View File

@ -3,6 +3,7 @@
import musicbrainzngs
import pathlib
from . import gsetup
from . import action
from . import audio
from . import db
from . import header
@ -20,7 +21,7 @@ from gi.repository import Adw
MAJOR_VERSION = 3
MINOR_VERSION = 0
MICRO_VERSION = 1
MICRO_VERSION = 2
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
@ -44,6 +45,11 @@ class Application(Adw.Application):
flags=Gio.ApplicationFlags.HANDLES_OPEN)
self.add_main_option_entries([options.Version])
def __add_accelerators(self, accels: list[action.ActionEntry]) -> None:
for entry in accels:
self.add_action(entry.action)
self.set_accels_for_action(f"app.{entry.name}", entry.accels)
def __load_file(self, file: pathlib.Path,
*, gapless: bool = False) -> None:
self.__stop_current_track()
@ -136,6 +142,8 @@ class Application(Adw.Application):
("audio.replaygain.mode", "rg-mode")]:
self.db.settings.bind_setting(setting, hdr, property)
self.__add_accelerators(hdr.accelerators)
hdr.connect("notify::rg-enabled", self.__set_replaygain)
hdr.connect("notify::rg-mode", self.__set_replaygain)
hdr.connect("track-requested", self.__load_path)
@ -159,6 +167,8 @@ class Application(Adw.Application):
self.db.settings.bind_setting("now-playing.prefer-artist",
playing, "prefer-artist")
self.__add_accelerators(playing.accelerators)
playing.connect("jump", self.__on_jump)
playing.connect("play", self.player.play)
playing.connect("pause", self.player.pause)
@ -193,6 +203,7 @@ class Application(Adw.Application):
now_playing=self.build_now_playing(),
sidebar=self.build_sidebar(),
tracklist=self.build_tracklist())
win.bind_property("user-editing", win.now_playing, "editing")
for (setting, property) in [("window.width", "default-width"),
("window.height", "default-height"),
@ -200,6 +211,7 @@ class Application(Adw.Application):
("sidebar.size", "sidebar-size")]:
self.db.settings.bind_setting(setting, win, property)
self.__add_accelerators(win.accelerators)
return win
def connect_mpris2(self) -> None:

39
emmental/action.py Normal file
View File

@ -0,0 +1,39 @@
# Copyright 2023 (c) Anna Schumaker.
"""A custom ActionEntry that works in Python."""
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
class ActionEntry(GObject.GObject):
"""Our own AcitionEntry class to make accelerators easier."""
enabled = GObject.Property(type=bool, default=True)
def __init__(self, name: str, func: callable, *accels: tuple[str],
enabled: tuple[GObject.GObject, str] | None = None):
"""Initialize an ActionEntry."""
super().__init__()
for accel in accels:
if not Gtk.accelerator_parse(accel)[0]:
raise ValueError
self.accels = list(accels)
self.func = func
if enabled is not None:
self.enabled = enabled[0].get_property(enabled[1])
enabled[0].bind_property(enabled[1], self, "enabled")
self.action = Gio.SimpleAction(name=name, enabled=self.enabled)
self.action.connect("activate", self.__activate)
self.bind_property("enabled", self.action, "enabled")
def __activate(self, action: Gio.SimpleAction, param) -> None:
self.func()
@property
def name(self) -> str:
"""Get then name of this ActionEntry."""
return self.action.get_name()

View File

@ -59,6 +59,7 @@ class SplitButton(Gtk.Box):
self.bind_property("icon-name", self._primary, "icon-name")
self.bind_property("icon-size", self._primary, "icon-size")
self._primary.connect("activate", self.__activate)
self._primary.connect("clicked", self.__clicked)
self.append(self._primary)
@ -67,14 +68,24 @@ class SplitButton(Gtk.Box):
self.add_css_class("emmental-splitbutton")
def __activate(self, button: Button) -> None:
self.emit("activate-primary")
def __clicked(self, button: Button) -> None:
self.emit("clicked")
def activate(self, *args) -> None:
self._primary.activate()
@GObject.Property(type=Gtk.Button, flags=GObject.ParamFlags.READABLE)
def secondary(self) -> Gtk.Button:
"""Get the secondary button attached to the SplitButton."""
return self._secondary
@GObject.Signal
def activate_primary(self) -> None:
"""Signal that the primary button has been activated."""
@GObject.Signal
def clicked(self) -> None:
"""Signal that the primary button has been clicked."""

View File

@ -5,6 +5,7 @@ import typing
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from ..action import ActionEntry
from .. import db
from .. import buttons
from .. import gsetup
@ -118,6 +119,21 @@ class Header(Gtk.HeaderBar):
path: pathlib.Path) -> None:
self.emit("track-requested", path)
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Header."""
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
ActionEntry("decrease-volume", self._volume.decrement,
"<Control>Down"),
ActionEntry("increase-volume", self._volume.increment,
"<Control>Up"),
ActionEntry("toggle-bg-mode", self._background.activate,
"<Shift><Control>b")]
if __debug__:
res.append(ActionEntry("edit-settings", self._settings.activate,
"<Shift><Control>s"))
return res
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_requested(self, path: pathlib.Path) -> None:
"""Signal that a track has been requested."""

View File

@ -41,17 +41,19 @@ class VolumeRow(Gtk.ListBoxRow):
self._box.append(self._increment)
self.set_child(self._box)
self._decrement.connect("clicked", self.__decrement)
self._decrement.connect("clicked", self.decrement)
self._scale.connect("value-changed", self.__value_changed)
self._increment.connect("clicked", self.__increment)
self._increment.connect("clicked", self.increment)
self.bind_property("volume", self._adjustment, "value",
GObject.BindingFlags.BIDIRECTIONAL)
def __decrement(self, button: Gtk.Button) -> None:
def decrement(self, button: Gtk.Button | None = None) -> None:
"""Decrease the volume by STEP_SIZE."""
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
def __increment(self, button: Gtk.Button) -> None:
def increment(self, button: Gtk.Button | None = None) -> None:
"""Increase the volume by STEP_SIZE."""
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
def __value_changed(self, range: Gtk.Range) -> None:

View File

@ -2,6 +2,7 @@
"""A card for displaying information about the currently playing track."""
from gi.repository import GObject
from gi.repository import Gtk
from ..action import ActionEntry
from .. import buttons
from . import artwork
from . import controls
@ -28,6 +29,7 @@ class Card(Gtk.Box):
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
have_db_track = GObject.Property(type=bool, default=False)
editing = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize a Now Playing Card."""
@ -39,11 +41,14 @@ class Card(Gtk.Box):
self._bottom_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
self._favorite = buttons.ImageToggle("heart-filled",
"heart-outline-thick-symbolic",
tooltip_text="add to "
"'Favorite Tracks'",
icon_size=Gtk.IconSize.LARGE,
has_frame=False, sensitive=False,
valign=Gtk.Align.CENTER)
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
icon_size=Gtk.IconSize.LARGE,
tooltip_text="scroll to current track",
valign=Gtk.Align.CENTER, sensitive=False)
self._seeker = seeker.Scale(sensitive=False)
@ -52,7 +57,8 @@ class Card(Gtk.Box):
self.bind_property(prop, self._tags, prop)
self.bind_property("prefer-artist", self._tags, "prefer-artist",
GObject.BindingFlags.BIDIRECTIONAL)
for prop in ["playing", "have-next", "have-previous", "have-track"]:
for prop in ["playing", "editing", "have-next",
"have-previous", "have-track"]:
self.bind_property(prop, self._controls, prop)
self.bind_property("have-db-track", self._jump, "sensitive")
self.bind_property("have-db-track", self._favorite, "sensitive")
@ -90,6 +96,29 @@ class Card(Gtk.Box):
value: float) -> None:
self.emit("seek", value)
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Now Playing card."""
return [ActionEntry("toggle-favorite", self._favorite.activate,
"<Control>f", enabled=(self, "have-db-track")),
ActionEntry("goto-current-track", self._jump.activate,
"<Control>g", enabled=(self, "have-db-track")),
ActionEntry("next-track", self._controls.activate_next,
"Return", enabled=(self._controls,
"can-activate-next")),
ActionEntry("previous-track", self._controls.activate_previous,
"BackSpace", enabled=(self._controls,
"can-activate-prev")),
ActionEntry("play-pause", self._controls.activate_play_pause,
"space", enabled=(self._controls,
"can-activate-play-pause")),
ActionEntry("inc-autopause", self._controls.increase_autopause,
"<Control>plus", "<Control>KP_Add",
enabled=(self, "playing")),
ActionEntry("dec-autopause", self._controls.decrease_autopause,
"<Control>minus", "<Control>KP_Subtract",
enabled=(self, "playing"))]
@GObject.Signal
def jump(self) -> None:
"""Signal that the Tracklist should be scrolled."""

View File

@ -8,39 +8,40 @@ from .. import gsetup
FALLBACK_RESOURCE = f"{gsetup.RESOURCE_ICONS}/emmental.svg"
class Artwork(Gtk.Frame):
"""Our custom Album Art widget that draws a border around a picture."""
class Artwork(Gtk.Picture):
"""Our custom Album Art widget takes a pathlib.Path."""
def __init__(self):
"""Initialize the Album Art widget."""
super().__init__(margin_top=6, margin_bottom=6, margin_start=6,
margin_end=6, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER)
self._picture = Gtk.Picture(content_fit=Gtk.ContentFit.CONTAIN)
super().__init__(content_fit=Gtk.ContentFit.CONTAIN,
margin_top=6, margin_bottom=6,
margin_start=6, margin_end=6,
halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
self._fullsize = Gtk.Picture(content_fit=Gtk.ContentFit.FILL)
self._popover = Gtk.Popover(child=self._fullsize)
self._clicked = Gtk.GestureClick()
self._popover = Gtk.Popover(child=self._fullsize)
self._popover.set_parent(self)
self._clicked = Gtk.GestureClick()
self._clicked.connect("released", self.clicked)
self.add_controller(self._clicked)
self._popover.set_parent(self)
self.set_child(self._picture)
self.add_css_class("card")
self.filepath = None
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def filepath(self) -> pathlib.Path:
"""Get the current artwork path."""
name = self._picture.get_file().get_parse_name()
name = self.get_file().get_parse_name()
return None if name.startswith("resource:") else pathlib.Path(name)
@filepath.setter
def filepath(self, path: pathlib.Path) -> None:
if path is not None:
self._picture.set_filename(str(path))
self.set_filename(str(path))
self._fullsize.set_filename(str(path))
else:
self._picture.set_resource(FALLBACK_RESOURCE)
self.set_resource(FALLBACK_RESOURCE)
self._fullsize.set_resource(FALLBACK_RESOURCE)
def clicked(self, gesture: Gtk.GestureClick, n_press: int,

View File

@ -25,6 +25,8 @@ class Entry(Gtk.Entry):
self.connect("icon_release", self.__icon_release)
self.connect("notify::value", self.__update_text)
self.add_css_class("card")
def __set_value(self, newval: int) -> bool:
if -1 <= newval <= 99:
self.value = newval
@ -89,6 +91,16 @@ class Entry(Gtk.Entry):
self.set_icon_sensitive(Gtk.EntryIconPosition.SECONDARY,
self.value < 99)
def decrement(self) -> None:
"""Decrease the autopause count by 1."""
if self.value > -1:
self.value -= 1
def increment(self) -> None:
"""Increase the autopause count by 1."""
if self.value < 99:
self.value += 1
class Button(buttons.PopoverButton):
"""A PopoverButton that displays Autopause count."""
@ -114,3 +126,11 @@ class Button(buttons.PopoverButton):
def __notify_value(self, button: buttons.PopoverButton, param) -> None:
text = str(self.value) if self.value > -1 else ""
self._count.set_markup(f"<small>{text}</small>")
def decrement(self) -> None:
"""Decrease the autopause value."""
self.popover_child.decrement()
def increment(self) -> None:
"""Increase the autopause value."""
self.popover_child.increment()

View File

@ -28,6 +28,11 @@ class Controls(Gtk.Box):
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
editing = GObject.Property(type=bool, default=False)
can_activate_next = GObject.Property(type=bool, default=False)
can_activate_prev = GObject.Property(type=bool, default=False)
can_activate_play_pause = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the Controls."""
super().__init__(valign=Gtk.Align.START, homogeneous=True,
@ -37,14 +42,16 @@ class Controls(Gtk.Box):
self._autopause = autopause.Button()
self._prev = PillButton(icon_name="media-skip-backward",
tooltip_text="previous track", sensitive=False)
self._play = PillButton(icon_name="play-large", tooltip_text="play",
sensitive=False)
self._play = PillButton(icon_name="play-large", sensitive=False)
self._pause = buttons.SplitButton(icon_name="pause-large",
icon_size=Gtk.IconSize.LARGE,
tooltip_text="pause",
secondary=self._autopause,
visible=False, sensitive=False)
self._next = PillButton(icon_name="media-skip-forward",
sensitive=False)
tooltip_text="next track", sensitive=False)
for button in [self._prev, self._play, self._pause, self._next]:
self.append(button)
@ -63,19 +70,59 @@ class Controls(Gtk.Box):
self.bind_property("have-previous", self._prev, "sensitive")
self.bind_property("have-track", self._play, "sensitive")
self.bind_property("have-track", self._pause, "sensitive")
self.connect("notify::playing", self.__notify_playing)
self.connect("notify", self.__notify)
self.add_css_class("linked")
def __on_click(self, button: Gtk.Button, signal: str) -> None:
self.emit(signal)
def __notify_playing(self, controls, param) -> None:
def __notify_playing(self, controls: Gtk.Box, param) -> None:
if not self.playing and self.autopause != -1:
if win := self.get_ancestor(window.Window):
win.post_toast("Autopause Cancelled")
self.autopause = -1
def __notify(self, controls: Gtk.Box, param) -> None:
match param.name:
case "editing":
allowed = not self.editing
self.can_activate_next = self.have_next and allowed
self.can_activate_prev = self.have_previous and allowed
self.can_activate_play_pause = self.have_track and allowed
case "playing": self.__notify_playing(controls, param)
case "have-next":
self.can_activate_next = self.have_next and not self.editing
case "have-previous":
can_activate = self.have_previous and not self.editing
self.can_activate_prev = can_activate
case "have-track":
can_activate = self.have_track and not self.editing
self.can_activate_play_pause = can_activate
def activate_next(self) -> None:
"""Activate the Next button."""
self._next.activate()
def activate_previous(self) -> None:
"""Activate the Previous button."""
self._prev.activate()
def activate_play_pause(self) -> None:
"""Activate the Play or Pause button."""
if self.playing:
self._pause.activate()
else:
self._play.activate()
def decrease_autopause(self) -> None:
"""Decrease the autopause count."""
self._autopause.decrement()
def increase_autopause(self) -> None:
"""Increase the autopause count."""
self._autopause.increment()
@GObject.Signal
def previous(self) -> None:
"""Signals that the Previous button has been clicked."""

View File

@ -3,6 +3,7 @@
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from .action import ActionEntry
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
@ -32,6 +33,7 @@ class Window(Adw.Window):
now_playing = GObject.Property(type=Gtk.Widget)
now_playing_size = GObject.Property(type=int, default=250)
tracklist = GObject.Property(type=Gtk.Widget)
user_editing = GObject.Property(type=bool, default=False)
def __init__(self, version: str, **kwargs):
"""Initialize our Window."""
@ -62,10 +64,16 @@ class Window(Adw.Window):
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("tracklist", self._inner_pane, "end-child")
self.connect("notify::focus-widget", self.__notify_focus_widget)
self._box.append(self._header)
self._box.append(self._toast)
self.set_content(self._box)
def __notify_focus_widget(self, win: Gtk.Window, param) -> None:
self.user_editing = isinstance(win.get_property("focus-widget"),
Gtk.Editable)
def close(self, *args) -> None:
"""Close the window."""
super().close()
@ -79,3 +87,8 @@ class Window(Adw.Window):
def present(self, *args) -> None:
"""Present the window."""
super().present()
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Window."""
return [ActionEntry("reset-focus", self.set_focus, "Escape")]

View File

@ -180,3 +180,27 @@ class TestHeader(tests.util.TestCase):
self.header._background)
self.assertEqual(self.header._box.get_row_at_index(2),
self.header._replaygain)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("open-file", self.header._open.activate, "<Control>o"),
("decrease-volume", self.header._volume.decrement,
"<Control>Down"),
("increase-volume", self.header._volume.increment,
"<Control>Up"),
("toggle-bg-mode", self.header._background.activate,
"<Shift><Control>b"),
("edit-settings", self.header._settings.activate,
"<Shift><Control>s")]
accels = self.header.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel) in enumerate(entries):
with self.subTest(name=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertListEqual(accels[i].accels, [accel])
self.assertEqual(len(accels), i + 1)

View File

@ -57,6 +57,11 @@ class TestVolumeRow(unittest.TestCase):
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
self.assertAlmostEqual(self.value.value, 0.95)
self.vol.decrement()
self.assertAlmostEqual(self.vol.volume, 0.90)
self.assertAlmostEqual(self.vol._scale.get_value(), 0.90)
self.assertAlmostEqual(self.value.value, 0.90)
def test_scale(self):
"""Check that the volume slider has been set up properly."""
self.assertIsInstance(self.vol._adjustment, Gtk.Adjustment)
@ -103,6 +108,11 @@ class TestVolumeRow(unittest.TestCase):
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
self.assertAlmostEqual(self.value.value, 0.95)
self.vol.increment()
self.assertAlmostEqual(self.vol.volume, 1.0)
self.assertAlmostEqual(self.vol._scale.get_value(), 1.0)
self.assertAlmostEqual(self.value.value, 1.0)
def test_format_value(self):
"""Check that the scale value is formatted correctly."""
format_value = emmental.header.volume.format_value_func

View File

@ -21,7 +21,9 @@ class TestArtwork(unittest.TestCase):
def test_init(self):
"""Test that the artwork widget is configured correctly."""
self.assertIsInstance(self.artwork, Gtk.Frame)
self.assertIsInstance(self.artwork, Gtk.Picture)
self.assertEqual(self.artwork.get_content_fit(),
Gtk.ContentFit.CONTAIN)
self.assertEqual(self.artwork.get_halign(), Gtk.Align.CENTER)
self.assertEqual(self.artwork.get_valign(), Gtk.Align.CENTER)
@ -31,27 +33,24 @@ class TestArtwork(unittest.TestCase):
self.assertEqual(self.artwork.get_margin_start(), 6)
self.assertEqual(self.artwork.get_margin_end(), 6)
def test_picture(self):
"""Test that the artwork picture is configured correctly."""
self.assertIsInstance(self.artwork._picture, Gtk.Picture)
self.assertEqual(self.artwork._picture.get_content_fit(),
Gtk.ContentFit.CONTAIN)
self.assertEqual(self.artwork.get_child(), self.artwork._picture)
self.assertTrue(self.artwork.has_css_class("card"))
def test_filepath(self):
"""Test that the filepath property works as expected."""
self.assertIsNone(self.artwork.filepath)
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
f"resource://{self.fallback}")
self.artwork.filepath = tests.util.COVER_JPG
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
str(tests.util.COVER_JPG))
self.assertEqual(self.artwork.filepath, tests.util.COVER_JPG)
self.artwork.filepath = None
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
f"resource://{self.fallback}")
def test_fullsize(self):

View File

@ -26,6 +26,7 @@ class TestAutopauseEntry(unittest.TestCase):
self.assertIsInstance(self.entry, Gtk.Entry)
self.assertTupleEqual(self.entry._timeout, (None, None))
self.assertEqual(self.entry.get_max_width_chars(), 20)
self.assertTrue(self.entry.has_css_class("card"))
def test_placeholder_text(self):
"""Test changing the placeholder text with the value."""
@ -147,6 +148,26 @@ class TestAutopauseEntry(unittest.TestCase):
self.entry.emit("activate")
self.assertEqual(self.entry.value, value, f"text=\"{text}\"")
def test_decrement(self):
"""Test the decrement() function."""
self.entry.value = 1
self.entry.decrement()
self.assertEqual(self.entry.value, 0)
self.entry.decrement()
self.assertEqual(self.entry.value, -1)
self.entry.decrement()
self.assertEqual(self.entry.value, -1)
def test_increment(self):
"""Test the increment() function."""
self.entry.value = 97
self.entry.increment()
self.assertEqual(self.entry.value, 98)
self.entry.increment()
self.assertEqual(self.entry.value, 99)
self.entry.increment()
self.assertEqual(self.entry.value, 99)
class TestAutopauseButton(unittest.TestCase):
"""Test our Autopause Popover Button."""
@ -193,3 +214,17 @@ class TestAutopauseButton(unittest.TestCase):
self.button.value = -1
self.assertEqual(self.button._count.get_text(), "")
def test_decrement(self):
"""Test the decrement() function."""
with unittest.mock.patch.object(self.button.popover_child,
"decrement") as mock_decrement:
self.button.decrement()
mock_decrement.assert_called()
def test_increment(self):
"""Test the increment() functions."""
with unittest.mock.patch.object(self.button.popover_child,
"increment") as mock_increment:
self.button.increment()
mock_increment.assert_called()

View File

@ -47,10 +47,14 @@ class TestControls(unittest.TestCase):
self.assertEqual(self.controls.get_margin_end(),
emmental.nowplaying.controls.MARGIN)
self.assertFalse(self.controls.editing)
def test_previous_button(self):
"""Test the previous button."""
self.assertIsInstance(self.controls._prev,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._prev.get_tooltip_text(),
"previous track")
self.assertEqual(self.controls._prev.icon_name, "media-skip-backward")
self.assertEqual(self.controls.get_first_child(), self.controls._prev)
@ -58,10 +62,24 @@ class TestControls(unittest.TestCase):
self.controls._prev.emit("clicked")
self.clicked.assert_called_with(self.controls._prev)
def test_activate_previous(self):
"""Test can-activate-prev and the activate_previous() function."""
self.assertFalse(self.controls.can_activate_prev)
self.controls.have_previous = True
self.assertTrue(self.controls.can_activate_prev)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_prev)
activate = unittest.mock.Mock()
self.controls._prev.connect("activate", activate)
self.controls.activate_previous()
activate.assert_called()
def test_play_button(self):
"""Test the play button."""
self.assertIsInstance(self.controls._play,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._play.get_tooltip_text(), "play")
self.assertEqual(self.controls._play.icon_name, "play-large")
self.assertEqual(self.controls._prev.get_next_sibling(),
self.controls._play)
@ -81,6 +99,7 @@ class TestControls(unittest.TestCase):
"""Test the pause button."""
self.assertIsInstance(self.controls._pause,
emmental.buttons.SplitButton)
self.assertEqual(self.controls._pause.get_tooltip_text(), "pause")
self.assertEqual(self.controls._pause.icon_name, "pause-large")
self.assertEqual(self.controls._pause.icon_size,
Gtk.IconSize.LARGE)
@ -97,6 +116,25 @@ class TestControls(unittest.TestCase):
self.controls._pause.emit("clicked")
self.clicked.assert_called_with(self.controls._pause)
def test_activate_play_pause(self):
"""Test can-activate-play-pause and the activate_play_pause() func."""
self.assertFalse(self.controls.can_activate_play_pause)
self.controls.have_track = True
self.assertTrue(self.controls.can_activate_play_pause)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_play_pause)
play = unittest.mock.Mock()
self.controls._play.connect("activate", play)
self.controls.activate_play_pause()
play.assert_called()
self.controls.playing = True
pause = unittest.mock.Mock()
self.controls._pause.connect("activate-primary", pause)
self.controls.activate_play_pause()
pause.assert_called()
def test_autopause_button(self):
"""Test the autopause button."""
self.assertIsInstance(self.controls._autopause,
@ -116,12 +154,40 @@ class TestControls(unittest.TestCase):
"""Test the next button."""
self.assertIsInstance(self.controls._next,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._next.get_tooltip_text(), "next track")
self.assertEqual(self.controls._next.icon_name, "media-skip-forward")
self.controls._next.connect("clicked", self.clicked)
self.controls._next.emit("clicked")
self.clicked.assert_called_with(self.controls._next)
def test_activate_next(self):
"""Test can-activate-next and the activate_next() function."""
self.assertFalse(self.controls.can_activate_next)
self.controls.have_next = True
self.assertTrue(self.controls.can_activate_next)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_next)
activate = unittest.mock.Mock()
self.controls._next.connect("activate", activate)
self.controls.activate_next()
activate.assert_called()
def test_decrease_autopause(self):
"""Test the decrease_autopause() function."""
with unittest.mock.patch.object(self.controls._autopause,
"decrement") as mock_decrement:
self.controls.decrease_autopause()
mock_decrement.assert_called()
def test_increase_autopause(self):
"""Test the increase_autopause() function."""
with unittest.mock.patch.object(self.controls._autopause,
"increment") as mock_increment:
self.controls.increase_autopause()
mock_increment.assert_called()
def test_have_properties(self):
"""Test the have_{next, previous, track} properties."""
self.assertFalse(self.controls.have_next)

View File

@ -91,6 +91,8 @@ class TestNowPlaying(unittest.TestCase):
self.assertEqual(self.card._favorite.active_icon_name, "heart-filled")
self.assertEqual(self.card._favorite.inactive_icon_name,
"heart-outline-thick-symbolic")
self.assertEqual(self.card._favorite.get_tooltip_text(),
"add to 'Favorite Tracks'")
self.assertEqual(self.card._favorite.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._favorite.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._favorite.get_has_frame())
@ -112,6 +114,8 @@ class TestNowPlaying(unittest.TestCase):
self.card._jump)
self.assertEqual(self.card._jump.icon_name, "go-jump")
self.assertEqual(self.card._jump.get_tooltip_text(),
"scroll to current track")
self.assertEqual(self.card._jump.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._jump.get_has_frame())
@ -141,6 +145,14 @@ class TestNowPlaying(unittest.TestCase):
self.card._seeker.emit("change-value", Gtk.ScrollType.JUMP, 10)
handler.assert_called_with(self.card, 10)
def test_editing(self):
"""Test the 'editing' property."""
self.assertFalse(self.card.editing)
self.card.editing = True
self.assertTrue(self.card._controls.editing)
self.card.editing = False
self.assertFalse(self.card._controls.editing)
def test_playing(self):
"""Test the 'playing' property."""
self.assertFalse(self.card.playing)
@ -150,7 +162,7 @@ class TestNowPlaying(unittest.TestCase):
self.assertFalse(self.card._controls.playing)
def test_have_properties(self):
"""Test the 'have-{next, previous, track} properties."""
"""Test the 'have-{next, previous, track}' properties."""
for property in ["have-next", "have-previous", "have-track"]:
with self.subTest(property=property):
self.assertFalse(self.card.get_property(property))
@ -178,3 +190,39 @@ class TestNowPlaying(unittest.TestCase):
self.assertEqual(self.card.position, 0)
self.card.position = 0.5
self.assertEqual(self.card._seeker.position, 0.5)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("toggle-favorite", self.card._favorite.activate,
["<Control>f"], self.card, "have-db-track"),
("goto-current-track", self.card._jump.activate,
["<Control>g"], self.card, "have-db-track"),
("next-track", self.card._controls.activate_next,
["Return"], self.card._controls, "can-activate-next"),
("previous-track", self.card._controls.activate_previous,
["BackSpace"], self.card._controls, "can-activate-prev"),
("play-pause", self.card._controls.activate_play_pause,
["space"], self.card._controls, "can-activate-play-pause"),
("inc-autopause", self.card._controls.increase_autopause,
["<Control>plus", "<Control>KP_Add"],
self.card, "playing"),
("dec-autopause", self.card._controls.decrease_autopause,
["<Control>minus", "<Control>KP_Subtract"],
self.card, "playing")]
accels = self.card.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel, gobject, prop) in enumerate(entries):
with self.subTest(action=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertListEqual(accels[i].accels, accel)
enabled = gobject.get_property(prop)
self.assertEqual(accels[i].enabled, enabled)
gobject.set_property(prop, not enabled)
self.assertEqual(accels[i].enabled, not enabled)
self.assertEqual(len(accels), i + 1)

66
tests/test_action.py Normal file
View File

@ -0,0 +1,66 @@
# Copyright 2023 (c) Anna Schumaker.
"""Tests our Python ActionEntry class."""
import unittest
import emmental.action
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
class TestActionEntry(unittest.TestCase):
"""Test case for our Python ActionEntry."""
def test_init(self):
"""Test constructing an ActionEntry."""
func = unittest.mock.Mock()
entry = emmental.action.ActionEntry("test-name", func, "<Control>t")
self.assertIsInstance(entry, GObject.GObject)
self.assertIsInstance(entry.action, Gio.SimpleAction)
self.assertEqual(entry.action.get_name(), "test-name")
self.assertEqual(entry.name, "test-name")
self.assertEqual(entry.func, func)
self.assertListEqual(entry.accels, ["<Control>t"])
def test_multiple_accels(self):
"""Test that multiple accelerators can be passed."""
func = unittest.mock.Mock()
entry = emmental.action.ActionEntry("test-multi", func,
"<Control>t", "<Control>u")
self.assertListEqual(entry.accels, ["<Control>t", "<Control>u"])
def test_invalid_accel(self):
"""Test that invalid accelerators are caught during construction."""
func = unittest.mock.Mock()
with self.assertRaises(ValueError):
emmental.action.ActionEntry("test-name", func, "<abcde>")
def test_activate(self):
"""Test activating the constructed action."""
func = unittest.mock.Mock()
entry = emmental.action.ActionEntry("test-name", func, "<Control>t")
entry.action.activate()
func.assert_called_with()
def test_enabled(self):
"""Test the enabled property."""
func = unittest.mock.Mock()
entry = emmental.action.ActionEntry("test-name", func, "<Control>t")
self.assertTrue(entry.enabled)
self.assertTrue(entry.action.get_enabled())
entry.enabled = False
self.assertFalse(entry.action.get_enabled())
def test_enabled_bind(self):
"""Test binding to the enabled property at construction."""
func = unittest.mock.Mock()
label = Gtk.Label(sensitive=False)
entry = emmental.action.ActionEntry("test-name", func, "<Control>t",
enabled=(label, "sensitive"))
self.assertFalse(entry.enabled)
self.assertFalse(entry.action.get_enabled())
label.set_sensitive(True)
self.assertTrue(entry.enabled)
self.assertTrue(entry.action.get_enabled())

View File

@ -148,6 +148,18 @@ class TestSplitButton(unittest.TestCase):
self.button._primary.emit("clicked")
on_click.assert_called_with(self.button)
def test_activate(self):
"""Test the activate function."""
activate = unittest.mock.Mock()
primary = unittest.mock.Mock()
self.button.connect("activate-primary", primary)
self.button._primary.connect("activate", activate)
self.button.activate()
activate.assert_called()
primary.assert_called()
class TestImageToggle(unittest.TestCase):
"""Test an ImageToggle button."""

View File

@ -22,9 +22,9 @@ class TestEmmental(unittest.TestCase):
"""Check that version constants have been set properly."""
self.assertEqual(emmental.MAJOR_VERSION, 3)
self.assertEqual(emmental.MINOR_VERSION, 0)
self.assertEqual(emmental.MICRO_VERSION, 1)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.1")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.1-debug")
self.assertEqual(emmental.MICRO_VERSION, 2)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.2")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.2-debug")
def test_application(self):
"""Check that the application instance is initialized properly."""
@ -63,7 +63,7 @@ class TestEmmental(unittest.TestCase):
mock_startup.assert_called()
mock_load.assert_called()
mock_add_window.assert_called_with(self.application.win)
mock_set_useragent.assert_called_with("emmental-debug", "3.0.1")
mock_set_useragent.assert_called_with("emmental-debug", "3.0.2")
@unittest.mock.patch("sys.stdout")
@unittest.mock.patch("gi.repository.Adw.Application.add_window")
@ -115,6 +115,26 @@ class TestEmmental(unittest.TestCase):
self.assertEqual(win.header.title, emmental.VERSION_STRING)
self.assertEqual(self.application.get_accels_for_action(
"app.reset-focus"), ["Escape"])
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_header_accels(self, mock_stdout: io.StringIO):
"""Check that accelerators have been added for header actions."""
self.application.db = emmental.db.Connection()
self.application.factory = emmental.playlist.Factory(
self.application.db)
self.application.player = emmental.audio.Player()
self.application.build_window()
for action, accel in [("app.open-file", "<Control>o"),
("app.decrease-volume", "<Control>Down"),
("app.increase-volume", "<Control>Up"),
("app.toggle-bg-mode", "<Shift><Control>b"),
("app.edit-settings", "<Shift><Control>s")]:
self.assertEqual(self.application.get_accels_for_action(action),
[accel])
@unittest.mock.patch("emmental.audio.Player.pause")
@unittest.mock.patch("emmental.audio.Player.play")
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
@ -128,6 +148,19 @@ class TestEmmental(unittest.TestCase):
self.application.player = emmental.audio.Player()
win = self.application.build_window()
for action, accel in [("app.toggle-favorite", ["<Control>f"]),
("app.goto-current-track", ["<Control>g"]),
("app.next-track", ["Return"]),
("app.previous-track", ["BackSpace"]),
("app.play-pause", ["space"]),
("app.inc-autopause", ["<Control>plus",
"<Control>KP_Add"]),
("app.dec-autopause", ["<Control>minus",
"<Control>KP_Subtract"])]:
with self.subTest(action=action):
accels = self.application.get_accels_for_action(action)
self.assertListEqual(accels, accel)
for (property, value) in [("have-track", True), ("playing", True),
("duration", 10), ("position", 5),
("artwork", "/a/b/c.jpg")]:
@ -143,6 +176,9 @@ class TestEmmental(unittest.TestCase):
self.application.factory.can_go_previous = True
self.assertTrue(win.now_playing.have_previous)
win.user_editing = True
self.assertTrue(win.now_playing.editing)
win.now_playing.emit("play")
play_func.assert_called()
win.now_playing.emit("pause")

View File

@ -132,6 +132,17 @@ class TestWindow(unittest.TestCase):
self.assertEqual(window2._inner_pane.get_end_child(),
window2.tracklist)
def test_user_editing(self):
"""Test the 'user-editing' property."""
self.window.header = Gtk.Entry()
self.window.tracklist = Gtk.Button()
self.assertFalse(self.window.user_editing)
self.window.set_focus(self.window.header)
self.assertTrue(self.window.user_editing)
self.window.set_focus(self.window.tracklist)
self.assertFalse(self.window.user_editing)
def test_post_toast(self):
"""Test posting a Toast message to the window."""
toast = self.window.post_toast("Test Toast")
@ -149,3 +160,13 @@ class TestWindow(unittest.TestCase):
with unittest.mock.patch.object(Gtk.Window, "present") as mock_present:
self.window.present(1, 2, 3, 4, 5)
mock_present.assert_called()
def test_accelerators(self):
"""Test that the Window accelerators are set up properly."""
accels = self.window.accelerators
self.assertIsInstance(accels, list)
self.assertEqual(len(accels), 1)
self.assertEqual(accels[0].name, "reset-focus")
self.assertEqual(accels[0].func, self.window.set_focus)
self.assertListEqual(accels[0].accels, ["Escape"])