Compare commits

...

12 Commits

Author SHA1 Message Date
Anna Schumaker ef99951f74 Emmental 3.0.5
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:26:14 -04:00
Anna Schumaker 0fd391a9fd sidebar: Add keyboard shortcuts
The following shortcuts are implemented:

- <Control>? (<Shift><Control>/) to focus the "filter playlists" entry
- <Control><Alt>g to go to the current playlist
- <Shift><Control>p to open the Playlists section
- <Shift><Control>a to open the Artists section
- <Shift><Control>g to open the Genres section
- <Shift><Control>d to open the Decades section
- <Shift><Control>l to open the Libraries section

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:24:10 -04:00
Anna Schumaker bc92e72265 sidebar: Give the Header an activate() function
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:24:10 -04:00
Anna Schumaker 8dae0ed7bd sidebar: Add a button to jump to the current playlist
Implements: #59 ("Jump to Active Playlist button")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:23:31 -04:00
Anna Schumaker 1707f87e45 sidebar: Move the Filter into a Gtk.CenterBox
I'm going to add a button to jump to the current playlist, and the first
step is to add an area to put it in the sidebar.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 7e99fd1ba0 sidebar: Convert the section Group into a View
I change it to inherit from Gtk.Box, and append Sections as they are
added. I also add some stand-alone styling to set it apart as its own
widget.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker a8e7078308 db: Disable incremental filtering
This applies to both the Table and child Playlists models. I'm doing my
own idle handling already for searching, so we can rely on that instead
of needing Gtk to do it. The benefit to this is that we can select
playlists programmatically during startup, since we don't need to worry
about the Table not being fully loaded yet.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 1d0813f217 db: Give playlist tables a refilter() function
This is used to notify the model that the rows have changed when it's
not automatically being detected. I first noticed this when attempting
to disable incremental filtering, due to the Artist list not getting
refiltered when new child Albums were added to the Artist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 725619faf5 db: Improve filtering Tables
I scheduled the filter query with  first=True so it can run without a
long delay during scanning. Additionally, I cancel any pending filter
calls to discard stale arguments that otherwise would be processed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 6607e5b0ad db: Give the Idle Queue a way to handle high priority tasks
If we have a high priority task, then we want to push it to the front of
the idle queue so it runs as soon as possible.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 73019d8eb4 db: Give the Idle Queue a cancel_task() function
It's sometimes desireable to cancel a pending task and re-add it with
new parameters to cut out some unnecessary work.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 6032e549a5 emmental: Use the gsetup.DEBUG_STR for appending "-debug"
This lets us calculate if we're in debug-mode or not once, and reuse the
result when needed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:06:38 -04:00
16 changed files with 272 additions and 74 deletions

View File

@ -21,7 +21,7 @@ from gi.repository import Adw
MAJOR_VERSION = 3 MAJOR_VERSION = 3
MINOR_VERSION = 0 MINOR_VERSION = 0
MICRO_VERSION = 4 MICRO_VERSION = 5
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}" VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}" VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
@ -182,6 +182,7 @@ class Application(Adw.Application):
side_bar = sidebar.Card(sql=self.db) side_bar = sidebar.Card(sql=self.db)
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar, self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
"show-all-artists") "show-all-artists")
self.__add_accelerators(side_bar.accelerators)
return side_bar return side_bar
def build_tracklist(self) -> tracklist.Card: def build_tracklist(self) -> tracklist.Card:

View File

@ -52,6 +52,11 @@ class Queue(GObject.GObject):
self.running = False self.running = False
self._idle_id = None self._idle_id = None
def cancel_task(self, func: typing.Callable) -> None:
"""Remove all instances of a specific task from the Idle Queue."""
self._tasks = [t for t in self._tasks if t[0] != func]
self.__update_counters()
def complete(self) -> None: def complete(self) -> None:
"""Complete all pending tasks.""" """Complete all pending tasks."""
if self.running: if self.running:
@ -60,12 +65,13 @@ class Queue(GObject.GObject):
self.cancel() self.cancel()
def push(self, func: typing.Callable, *args, def push(self, func: typing.Callable, *args,
now: bool = False) -> bool | None: now: bool = False, first: bool = False) -> bool | None:
"""Add a task to the Idle Queue.""" """Add a task to the Idle Queue."""
if not self.enabled or now: if not self.enabled or now:
return func(*args) return func(*args)
self._tasks.append((func, *args)) pos = 0 if first else len(self._tasks)
self._tasks.insert(pos, (func, *args))
self.total += 1 self.total += 1
self.__start() self.__start()

View File

@ -55,7 +55,6 @@ class Playlist(table.Row):
self.child_set = table.TableSubset(child_table, keys=child_keys) self.child_set = table.TableSubset(child_table, keys=child_keys)
self.children = Gtk.FilterListModel.new(self.child_set, self.children = Gtk.FilterListModel.new(self.child_set,
child_table.get_filter()) child_table.get_filter())
self.children.set_incremental(True)
def do_update(self, column: str) -> bool: def do_update(self, column: str) -> bool:
"""Update a Playlist object.""" """Update a Playlist object."""
@ -69,6 +68,8 @@ class Playlist(table.Row):
def add_child(self, child: typing.Self) -> None: def add_child(self, child: typing.Self) -> None:
"""Add a child Playlist to this Playlist.""" """Add a child Playlist to this Playlist."""
self.child_set.add_row(child) self.child_set.add_row(child)
if self.child_set.keyset.n_keys == 1:
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
def add_track(self, track: Track, *, idle: bool = False) -> None: def add_track(self, track: Track, *, idle: bool = False) -> None:
"""Add a Track to this Playlist.""" """Add a Track to this Playlist."""
@ -110,6 +111,8 @@ class Playlist(table.Row):
def remove_child(self, child: typing.Self) -> None: def remove_child(self, child: typing.Self) -> None:
"""Remove a child Playlist from this Playlist.""" """Remove a child Playlist from this Playlist."""
self.child_set.remove_row(child) self.child_set.remove_row(child)
if self.child_set.keyset.n_keys == 0:
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
def remove_track(self, track: table.Row, *, idle: bool = False) -> None: def remove_track(self, track: table.Row, *, idle: bool = False) -> None:
"""Remove a Track from this Playlist.""" """Remove a Track from this Playlist."""
@ -154,6 +157,10 @@ class Table(table.Table):
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None: def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
return plist.children return plist.children
def __refilter(self, change_how: Gtk.FilterChange) -> bool:
self.get_filter().changed(change_how)
return True
def do_add_track(self, playlist: Playlist, track: Track) -> bool: def do_add_track(self, playlist: Playlist, track: Track) -> bool:
"""Add a Track to the Playlist.""" """Add a Track to the Playlist."""
raise NotImplementedError raise NotImplementedError
@ -255,6 +262,11 @@ class Table(table.Table):
playlist.sort_order = "user" playlist.sort_order = "user"
return res return res
def refilter(self, change_how: Gtk.FilterChange) -> None:
"""Schedule refiltering the Table."""
self.queue.cancel_task(self.__refilter)
self.queue.push(self.__refilter, change_how, first=True)
def remove_system_track(self, playlist: Playlist, track: Track) -> bool: def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
"""Remove a Track from a system Playlist.""" """Remove a Track from a system Playlist."""
return self.sql("""DELETE FROM system_tracks return self.sql("""DELETE FROM system_tracks

View File

@ -149,7 +149,7 @@ class Table(Gtk.FilterListModel):
filter: KeySet | None = None, filter: KeySet | None = None,
queue: Queue | None = None, **kwargs): queue: Queue | None = None, **kwargs):
"""Set up our Table object.""" """Set up our Table object."""
super().__init__(sql=sql, rows=dict(), incremental=True, super().__init__(sql=sql, rows=dict(),
store=store.SortedList(self.get_sort_key), store=store.SortedList(self.get_sort_key),
filter=(filter if filter else KeySet()), filter=(filter if filter else KeySet()),
queue=(queue if queue else Queue()), **kwargs) queue=(queue if queue else Queue()), **kwargs)
@ -227,7 +227,8 @@ class Table(Gtk.FilterListModel):
def filter(self, glob: str | None, *, now: bool = False) -> None: def filter(self, glob: str | None, *, now: bool = False) -> None:
"""Filter the displayed Rows.""" """Filter the displayed Rows."""
if glob is not None: if glob is not None:
self.queue.push(self._filter_idle, glob, now=now) self.queue.cancel_task(self._filter_idle)
self.queue.push(self._filter_idle, glob, now=now, first=True)
else: else:
self.get_filter().keys = None self.get_filter().keys = None

View File

@ -17,7 +17,7 @@ gi.importlib.import_module("gi.repository.Gtk")
gi.importlib.import_module("gi.repository.Gst").init(sys.argv) gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
DEBUG_STR = "-debug" if __debug__ else "" DEBUG_STR = "-debug" if __debug__ else ""
APPLICATION_ID = f"com.nowheycreamery.emmental{'-debug' if __debug__ else ''}" APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css" CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION

View File

@ -2,10 +2,11 @@
"""Implement the MPRIS2 Specification.""" """Implement the MPRIS2 Specification."""
from gi.repository import GObject from gi.repository import GObject
from gi.repository import Gio from gi.repository import Gio
from .. import gsetup
from . import application from . import application
from . import player from . import player
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}" MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{gsetup.DEBUG_STR}"
class Connection(GObject.GObject): class Connection(GObject.GObject):

View File

@ -8,6 +8,7 @@ from . import genre
from . import library from . import library
from . import playlist from . import playlist
from . import section from . import section
from ..action import ActionEntry
from .. import db from .. import db
from .. import entry from .. import entry
@ -23,33 +24,41 @@ class Card(Gtk.Box):
"""Set up the Sidebar widget.""" """Set up the Sidebar widget."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL, super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
sensitive=False, **kwargs) sensitive=False, **kwargs)
self._filter = entry.Filter("playlists") self._header = Gtk.CenterBox()
self._filter = entry.Filter("playlists", hexpand=True)
self._jump = Gtk.Button(icon_name="go-jump-symbolic",
tooltip_text="scroll to current playlist")
self._playlists = playlist.Section(self.sql.playlists) self._playlists = playlist.Section(self.sql.playlists)
self._artists = artist.Section(self.sql.artists, self.sql.albums) self._artists = artist.Section(self.sql.artists, self.sql.albums)
self._genres = genre.Section(self.sql.genres) self._genres = genre.Section(self.sql.genres)
self._decades = decade.Section(self.sql.decades, self.sql.years) self._decades = decade.Section(self.sql.decades, self.sql.years)
self._libraries = library.Section(self.sql.libraries) self._libraries = library.Section(self.sql.libraries)
self._group = section.Group(sql) self._view = section.View(sql)
self.append(self._filter) self._header.set_center_widget(self._filter)
self._header.set_end_widget(self._jump)
self.append(self._header)
for sect in [self._playlists, self._artists, self._genres, for sect in [self._playlists, self._artists, self._genres,
self._decades, self._libraries]: self._decades, self._libraries]:
self.append(sect) self._view.add(sect)
self._group.add(sect) self.append(self._view)
self._group.bind_property("selected-playlist", self._view.bind_property("selected-playlist",
self, "selected-playlist") self, "selected-playlist")
self.bind_property("show-all-artists", self._artists, "show-all", self.bind_property("show-all-artists", self._artists, "show-all",
GObject.BindingFlags.BIDIRECTIONAL) GObject.BindingFlags.BIDIRECTIONAL)
self._filter.connect("search-changed", self.__search_changed) self._filter.connect("search-changed", self.__search_changed)
self._jump.connect("clicked", self.__jump_to_playlist)
self.sql.connect("table-loaded", self.__table_loaded) self.sql.connect("table-loaded", self.__table_loaded)
self.add_css_class("background") self._header.add_css_class("toolbar")
self.add_css_class("linked")
self.add_css_class("card") self.add_css_class("card")
def __jump_to_playlist(self, jump: Gtk.Button) -> None:
self.select_playlist(self.sql.active_playlist)
def __search_changed(self, entry: entry.Filter) -> None: def __search_changed(self, entry: entry.Filter) -> None:
self.sql.filter(entry.get_query()) self.sql.filter(entry.get_query())
@ -78,3 +87,21 @@ class Card(Gtk.Box):
section.active = True section.active = True
section.select_playlist(playlist) section.select_playlist(playlist)
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Sidebar."""
return [ActionEntry("focus-search-playlist", self._filter.grab_focus,
"<Control>question", enabled=(self, "sensitive")),
ActionEntry("goto-active-playlist", self._jump.activate,
"<Control><Alt>g", enabled=(self, "sensitive")),
ActionEntry("goto-playlists", self._playlists.activate,
"<Shift><Control>p", enabled=(self, "sensitive")),
ActionEntry("goto-artists", self._artists.activate,
"<Shift><Control>a", enabled=(self, "sensitive")),
ActionEntry("goto-genres", self._genres.activate,
"<Shift><Control>g", enabled=(self, "sensitive")),
ActionEntry("goto-decades", self._decades.activate,
"<Shift><Control>d", enabled=(self, "sensitive")),
ActionEntry("goto-libraries", self._libraries.activate,
"<Shift><Control>l", enabled=(self, "sensitive"))]

View File

@ -55,7 +55,7 @@ class Header(Gtk.Box):
self.bind_property("reveal-widget", self._revealer, "child") self.bind_property("reveal-widget", self._revealer, "child")
self.bind_property("animation", self._revealer, "transition-type") self.bind_property("animation", self._revealer, "transition-type")
self._clicked.connect("released", self.__clicked) self._clicked.connect("released", self.activate)
self.connect("notify::active", self.__notify_active) self.connect("notify::active", self.__notify_active)
self._box.append(self._icon) self._box.append(self._icon)
@ -70,12 +70,12 @@ class Header(Gtk.Box):
self.append(self._overlay) self.append(self._overlay)
self.append(self._revealer) self.append(self._revealer)
def __clicked(self, gesture: Gtk.GestureClick, n_press: int,
x: int, y: int) -> None:
self.active = True
def __notify_active(self, header, param) -> None: def __notify_active(self, header, param) -> None:
if self.active: if self.active:
self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False) self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False)
else: else:
self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED) self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED)
def activate(self, *args) -> None:
"""Activate the Header."""
self.active = True

View File

@ -99,8 +99,8 @@ class Section(header.Header):
"""Signal that the selected playlist has changed.""" """Signal that the selected playlist has changed."""
class Group(GObject.GObject): class View(Gtk.Box):
"""A group of sections.""" """A widget for displaying a group of sections."""
sql = GObject.Property(type=db.Connection) sql = GObject.Property(type=db.Connection)
current = GObject.Property(type=Section) current = GObject.Property(type=Section)
@ -108,8 +108,8 @@ class Group(GObject.GObject):
selected_playlist = GObject.Property(type=db.playlist.Playlist) selected_playlist = GObject.Property(type=db.playlist.Playlist)
def __init__(self, sql: db.Connection): def __init__(self, sql: db.Connection):
"""Initialize a Section Group.""" """Initialize a Section View."""
super().__init__(sql=sql) super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL)
self._sections = [] self._sections = []
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None: def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
@ -145,6 +145,7 @@ class Group(GObject.GObject):
def add(self, section: Section) -> None: def add(self, section: Section) -> None:
"""Add a section to the group.""" """Add a section to the group."""
self._sections.append(section) self._sections.append(section)
self.append(section)
section.connect("notify::active", self.__on_active) section.connect("notify::active", self.__on_active)
section.connect("playlist-activated", self.__playlist_activated) section.connect("playlist-activated", self.__playlist_activated)
section.connect("playlist-selected", self.__playlist_selected) section.connect("playlist-selected", self.__playlist_selected)

View File

@ -51,6 +51,26 @@ class TestIdleQueue(unittest.TestCase):
self.assertEqual(self.queue.total, 0) self.assertEqual(self.queue.total, 0)
self.assertEqual(self.queue.progress, 0.0) self.assertEqual(self.queue.progress, 0.0)
def test_cancel_task(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test canceling a specific task."""
self.queue.push(1)
self.queue.push(2)
self.queue.push(1)
self.queue.cancel_task(1)
self.assertListEqual(self.queue._tasks, [(2,)])
self.assertEqual(self.queue.total, 3)
self.assertAlmostEqual(self.queue.progress, 2 / 3)
mock_source_removed.assert_not_called()
self.queue.cancel_task(2)
self.assertListEqual(self.queue._tasks, [])
self.assertIsNone(self.queue._idle_id)
self.assertEqual(self.queue.total, 0)
self.assertEqual(self.queue.progress, 0.0)
mock_source_removed.assert_called_with(42)
def test_complete(self, mock_idle_add: unittest.mock.Mock, def test_complete(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock): mock_source_removed: unittest.mock.Mock):
"""Test completing queued tasks.""" """Test completing queued tasks."""
@ -119,6 +139,17 @@ class TestIdleQueue(unittest.TestCase):
mock_idle_add.assert_not_called() mock_idle_add.assert_not_called()
func.assert_called_with(1) func.assert_called_with(1)
def test_push_first(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test pushing an idle task with first=True."""
self.queue.push(1)
self.queue.push(0, first=True)
self.assertListEqual(self.queue._tasks, [(0,), (1,)])
self.queue.push(2, first=False)
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,)])
self.queue.push(3)
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,), (3,)])
def test_push_many_enabled(self, mock_idle_add: unittest.mock.Mock, def test_push_many_enabled(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock): mock_source_removed: unittest.mock.Mock):
"""Test adding several calls to one function at one time.""" """Test adding several calls to one function at one time."""

View File

@ -21,6 +21,7 @@ class TestPlaylistRow(unittest.TestCase):
self.table.move_track_up = unittest.mock.Mock(return_value=True) self.table.move_track_up = unittest.mock.Mock(return_value=True)
self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3}) self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3})
self.table.get_track_order = unittest.mock.Mock() self.table.get_track_order = unittest.mock.Mock()
self.table.refilter = unittest.mock.Mock()
self.table.queue = emmental.db.idle.Queue() self.table.queue = emmental.db.idle.Queue()
self.table.update = unittest.mock.Mock(return_value=True) self.table.update = unittest.mock.Mock(return_value=True)
@ -81,7 +82,7 @@ class TestPlaylistRow(unittest.TestCase):
table.get_filter()) table.get_filter())
self.assertEqual(self.playlist.children.get_model(), self.assertEqual(self.playlist.children.get_model(),
self.playlist.child_set) self.playlist.child_set)
self.assertTrue(self.playlist.children.get_incremental()) self.assertFalse(self.playlist.children.get_incremental())
playlist2 = emmental.db.playlist.Playlist(table=self.table, playlist2 = emmental.db.playlist.Playlist(table=self.table,
propertyid=2, name="Plist2") propertyid=2, name="Plist2")
@ -114,11 +115,16 @@ class TestPlaylistRow(unittest.TestCase):
def test_add_child(self): def test_add_child(self):
"""Test adding a child playlist to the playlist.""" """Test adding a child playlist to the playlist."""
table = emmental.db.table.Table(None) table = emmental.db.table.Table(None)
child = tests.util.table.MockRow(table=table, number=1) child1 = tests.util.table.MockRow(table=table, number=1)
child2 = tests.util.table.MockRow(table=table, number=2)
self.playlist.add_children(table, set()) self.playlist.add_children(table, set())
self.playlist.add_child(child) self.playlist.add_child(child1)
self.assertIn(child, self.playlist.child_set) self.assertIn(child1, self.playlist.child_set)
self.table.refilter.assert_called_with(Gtk.FilterChange.LESS_STRICT)
self.playlist.add_child(child2)
self.table.refilter.assert_called_once()
def test_add_track(self): def test_add_track(self):
"""Test adding a track to the playlist.""" """Test adding a track to the playlist."""
@ -179,12 +185,19 @@ class TestPlaylistRow(unittest.TestCase):
def test_remove_child(self): def test_remove_child(self):
"""Test removing a child playlist from the playlist.""" """Test removing a child playlist from the playlist."""
table = emmental.db.table.Table(None) table = emmental.db.table.Table(None)
child = tests.util.table.MockRow(table=table, number=1) child1 = tests.util.table.MockRow(table=table, number=1)
child2 = tests.util.table.MockRow(table=table, number=2)
self.playlist.add_children(table, set()) self.playlist.add_children(table, set())
self.playlist.add_child(child1)
self.playlist.add_child(child2)
self.table.refilter.reset_mock()
self.playlist.add_child(child) self.playlist.remove_child(child1)
self.playlist.remove_child(child) self.assertFalse(child1 in self.playlist.child_set)
self.assertFalse(child in self.playlist.child_set) self.table.refilter.assert_not_called()
self.playlist.remove_child(child2)
self.table.refilter.assert_called_with(Gtk.FilterChange.MORE_STRICT)
def test_remove_track(self): def test_remove_track(self):
"""Test removing a track from the playlist.""" """Test removing a track from the playlist."""
@ -403,6 +416,27 @@ class TestPlaylistTable(tests.util.TestCase):
self.table.move_track_up(plist, self.track) self.table.move_track_up(plist, self.track)
self.assertEqual(plist.sort_order, "user") self.assertEqual(plist.sort_order, "user")
def test_refilter(self):
"""Test refiltering the playlist table."""
self.table.queue.push(unittest.mock.Mock())
with unittest.mock.patch.object(self.table.get_filter(),
"changed") as mock_changed:
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
self.assertEqual(self.table.queue[0],
(self.table._Table__refilter,
Gtk.FilterChange.MORE_STRICT))
mock_changed.assert_not_called()
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
self.assertEqual(self.table.queue[0],
(self.table._Table__refilter,
Gtk.FilterChange.LESS_STRICT))
mock_changed.assert_not_called()
self.table.queue.complete()
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
def test_remove_track(self): def test_remove_track(self):
"""Test adding a track to a playlist.""" """Test adding a track to a playlist."""
self.assertTrue(self.table.system_tracks) self.assertTrue(self.table.system_tracks)

View File

@ -233,7 +233,7 @@ class TestTable(tests.util.TestCase):
self.assertEqual(self.table.get_model(), self.table.store) self.assertEqual(self.table.get_model(), self.table.store)
self.assertEqual(self.table.store.key_func, self.table.get_sort_key) self.assertEqual(self.table.store.key_func, self.table.get_sort_key)
self.assertDictEqual(self.table.rows, {}) self.assertDictEqual(self.table.rows, {})
self.assertTrue(self.table.get_incremental()) self.assertFalse(self.table.get_incremental())
filter2 = emmental.db.table.KeySet() filter2 = emmental.db.table.KeySet()
queue2 = emmental.db.idle.Queue() queue2 = emmental.db.idle.Queue()
@ -368,9 +368,13 @@ class TestTableFunctions(tests.util.TestCase):
"""Test filtering Rows in the table.""" """Test filtering Rows in the table."""
for n in [1, 121, 212, 333]: for n in [1, 121, 212, 333]:
self.table.create(number=n) self.table.create(number=n)
self.table.queue.push(unittest.mock.Mock())
self.table.filter("*3*")
self.assertIsNone(self.table.get_filter().keys)
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*3*"))
self.table.filter("*2*") self.table.filter("*2*")
self.assertIsNone(self.table.get_filter().keys)
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*")) self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*"))
self.table.queue.complete() self.table.queue.complete()

View File

@ -165,3 +165,9 @@ class TestHeader(unittest.TestCase):
self.assertTrue(self.header.active) self.assertTrue(self.header.active)
flags = self.header._arrow.get_state_flags() flags = self.header._arrow.get_state_flags()
self.assertTrue(flags & Gtk.StateFlags.CHECKED) self.assertTrue(flags & Gtk.StateFlags.CHECKED)
def test_activate(self):
"""Test the activate() function."""
self.assertFalse(self.header.active)
self.header.activate()
self.assertTrue(self.header.active)

View File

@ -4,7 +4,6 @@ import emmental.db
import emmental.sidebar.section import emmental.sidebar.section
import tests.util import tests.util
import unittest.mock import unittest.mock
from gi.repository import GObject
from gi.repository import GLib from gi.repository import GLib
from gi.repository import Gtk from gi.repository import Gtk
@ -150,7 +149,7 @@ class TestGroup(tests.util.TestCase):
def setUp(self): def setUp(self):
"""Set up common variables.""" """Set up common variables."""
super().setUp() super().setUp()
self.group = emmental.sidebar.section.Group(self.sql) self.view = emmental.sidebar.section.View(self.sql)
self.row_type = emmental.sidebar.row.TreeRow self.row_type = emmental.sidebar.row.TreeRow
self.section1 = emmental.sidebar.section.Section(self.sql.playlists, self.section1 = emmental.sidebar.section.Section(self.sql.playlists,
self.row_type) self.row_type)
@ -161,35 +160,40 @@ class TestGroup(tests.util.TestCase):
def test_init(self): def test_init(self):
"""Test that the Group is set up properly.""" """Test that the Group is set up properly."""
self.assertIsInstance(self.group, GObject.GObject) self.assertIsInstance(self.view, Gtk.Box)
self.assertListEqual(self.group._sections, []) self.assertListEqual(self.view._sections, [])
self.assertEqual(self.group.sql, self.sql) self.assertEqual(self.view.sql, self.sql)
self.assertEqual(self.view.get_orientation(),
Gtk.Orientation.VERTICAL)
def test_add(self): def test_add(self):
"""Test adding sections to the Group.""" """Test adding sections to the Group."""
self.group.add(self.section1) self.view.add(self.section1)
self.assertListEqual(self.group._sections, [self.section1]) self.assertListEqual(self.view._sections, [self.section1])
self.group.add(self.section2) self.assertEqual(self.view.get_first_child(), self.section1)
self.assertListEqual(self.group._sections,
self.view.add(self.section2)
self.assertListEqual(self.view._sections,
[self.section1, self.section2]) [self.section1, self.section2])
self.assertEqual(self.section1.get_next_sibling(), self.section2)
def test_current(self): def test_current(self):
"""Test the current section property.""" """Test the current section property."""
self.group.add(self.section1) self.view.add(self.section1)
self.group.add(self.section2) self.view.add(self.section2)
self.assertIsNone(self.group.current) self.assertIsNone(self.view.current)
self.section1.active = True self.section1.active = True
self.assertEqual(self.group.current, self.section1) self.assertEqual(self.view.current, self.section1)
self.section2.active = True self.section2.active = True
self.assertEqual(self.group.current, self.section2) self.assertEqual(self.view.current, self.section2)
self.assertFalse(self.section1.active) self.assertFalse(self.section1.active)
def test_animation(self): def test_animation(self):
"""Test setting the section animation style.""" """Test setting the section animation style."""
self.group.add(self.section1) self.view.add(self.section1)
self.group.add(self.section2) self.view.add(self.section2)
self.section1.active = True self.section1.active = True
self.assertEqual(self.section1.animation, self.assertEqual(self.section1.animation,
@ -201,8 +205,8 @@ class TestGroup(tests.util.TestCase):
def test_playlist_activated(self): def test_playlist_activated(self):
"""Test responding to the section playlist-activated signal.""" """Test responding to the section playlist-activated signal."""
self.group.add(self.section1) self.view.add(self.section1)
self.group.add(self.section2) self.view.add(self.section2)
self.assertIsNone(self.sql.active_playlist) self.assertIsNone(self.sql.active_playlist)
playlist = self.sql.playlists.create("Test Playlist") playlist = self.sql.playlists.create("Test Playlist")
@ -215,16 +219,16 @@ class TestGroup(tests.util.TestCase):
def test_selections(self): def test_selections(self):
"""Test the selected section & playlist properties.""" """Test the selected section & playlist properties."""
self.group.add(self.section1) self.view.add(self.section1)
self.group.add(self.section2) self.view.add(self.section2)
self.assertIsNone(self.group.selected_section) self.assertIsNone(self.view.selected_section)
self.assertIsNone(self.group.selected_playlist) self.assertIsNone(self.view.selected_playlist)
genre = self.sql.genres.create("Test Genre") genre = self.sql.genres.create("Test Genre")
self.section2.emit("playlist-selected", genre) self.section2.emit("playlist-selected", genre)
self.assertEqual(self.group.selected_section, self.section2) self.assertEqual(self.view.selected_section, self.section2)
self.assertEqual(self.group.selected_playlist, genre) self.assertEqual(self.view.selected_playlist, genre)
self.section2.active = True self.section2.active = True
treerow = self.section2._selection.get_selected_item() treerow = self.section2._selection.get_selected_item()

View File

@ -23,23 +23,46 @@ class TestSidebar(tests.util.TestCase):
Gtk.Orientation.VERTICAL) Gtk.Orientation.VERTICAL)
self.assertFalse(self.sidebar.get_sensitive()) self.assertFalse(self.sidebar.get_sensitive())
self.assertTrue(self.sidebar.has_css_class("background"))
self.assertTrue(self.sidebar.has_css_class("linked"))
self.assertTrue(self.sidebar.has_css_class("card")) self.assertTrue(self.sidebar.has_css_class("card"))
def test_header(self):
"""Test the Sidebar header."""
self.assertIsInstance(self.sidebar._header, Gtk.CenterBox)
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._header)
self.assertTrue(self.sidebar._header.has_css_class("toolbar"))
def test_filter(self): def test_filter(self):
"""Test the Sidebar filter entry.""" """Test the Sidebar filter entry."""
self.assertIsInstance(self.sidebar._filter, emmental.entry.Filter) self.assertIsInstance(self.sidebar._filter, emmental.entry.Filter)
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._filter) self.assertEqual(self.sidebar._header.get_center_widget(),
self.sidebar._filter)
self.assertEqual(self.sidebar._filter.get_placeholder_text(), self.assertEqual(self.sidebar._filter.get_placeholder_text(),
"type to filter playlists") "type to filter playlists")
self.assertTrue(self.sidebar._filter.get_hexpand())
with unittest.mock.patch.object(self.sql, "filter") as mock_filter: with unittest.mock.patch.object(self.sql, "filter") as mock_filter:
self.sidebar._filter.set_text("test text") self.sidebar._filter.set_text("test text")
self.sidebar._filter.emit("search-changed") self.sidebar._filter.emit("search-changed")
mock_filter.assert_called_with("*test text*") mock_filter.assert_called_with("*test text*")
def test_jump(self):
"""Test the jump button."""
self.assertIsInstance(self.sidebar._jump, Gtk.Button)
self.assertEqual(self.sidebar._header.get_end_widget(),
self.sidebar._jump)
self.assertEqual(self.sidebar._jump.get_icon_name(),
"go-jump-symbolic")
self.assertEqual(self.sidebar._jump.get_tooltip_text(),
"scroll to current playlist")
self.sql.playlists.load(now=True)
with unittest.mock.patch.object(self.sidebar,
"select_playlist") as mock_select:
self.sidebar._jump.emit("clicked")
mock_select.assert_called_with(self.sql.active_playlist)
def test_sensitivity_and_startup(self): def test_sensitivity_and_startup(self):
"""Test setting the sidebar sensitivity when all tables have loaded.""" """Test setting the sidebar sensitivity when all tables have loaded."""
tables = [t for t in self.sql.playlist_tables()] tables = [t for t in self.sql.playlist_tables()]
@ -73,15 +96,17 @@ class TestSidebar(tests.util.TestCase):
self.assertIsNone(self.sidebar.selected_playlist) self.assertIsNone(self.sidebar.selected_playlist)
playlist1 = self.sql.playlists.create("Playlist 1") playlist1 = self.sql.playlists.create("Playlist 1")
self.sidebar._group.selected_playlist = playlist1 self.sidebar._view.selected_playlist = playlist1
self.assertEqual(self.sidebar.selected_playlist, playlist1) self.assertEqual(self.sidebar.selected_playlist, playlist1)
def test_group(self): def test_view(self):
"""Test that sidebar sections are part of the same Group.""" """Test that sidebar sections are in the View."""
self.assertIsInstance(self.sidebar._group, self.assertIsInstance(self.sidebar._view,
emmental.sidebar.section.Group) emmental.sidebar.section.View)
self.assertEqual(self.sidebar._header.get_next_sibling(),
self.sidebar._view)
self.assertListEqual(self.sidebar._group._sections, self.assertListEqual(self.sidebar._view._sections,
[self.sidebar._playlists, [self.sidebar._playlists,
self.sidebar._artists, self.sidebar._artists,
self.sidebar._genres, self.sidebar._genres,
@ -101,7 +126,7 @@ class TestSidebar(tests.util.TestCase):
self.assertIsInstance(self.sidebar._libraries, self.assertIsInstance(self.sidebar._libraries,
emmental.sidebar.library.Section) emmental.sidebar.library.Section)
self.assertEqual(self.sidebar._filter.get_next_sibling(), self.assertEqual(self.sidebar._view.get_first_child(),
self.sidebar._playlists) self.sidebar._playlists)
self.assertEqual(self.sidebar._playlists.get_next_sibling(), self.assertEqual(self.sidebar._playlists.get_next_sibling(),
self.sidebar._artists) self.sidebar._artists)
@ -161,3 +186,37 @@ class TestSidebar(tests.util.TestCase):
self.sidebar.select_playlist(library) self.sidebar.select_playlist(library)
self.assertTrue(self.sidebar._libraries.active) self.assertTrue(self.sidebar._libraries.active)
self.assertEqual(self.sidebar.selected_playlist, library) self.assertEqual(self.sidebar.selected_playlist, library)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("focus-search-playlist", self.sidebar._filter.grab_focus,
["<Control>question"]),
("goto-active-playlist", self.sidebar._jump.activate,
["<Control><Alt>g"]),
("goto-playlists", self.sidebar._playlists.activate,
["<Shift><Control>p"]),
("goto-artists", self.sidebar._artists.activate,
["<Shift><Control>a"]),
("goto-genres", self.sidebar._genres.activate,
["<Shift><Control>g"]),
("goto-decades", self.sidebar._decades.activate,
["<Shift><Control>d"]),
("goto-libraries", self.sidebar._libraries.activate,
["<Shift><Control>l"])]
accels = self.sidebar.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel) 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 = self.sidebar.get_sensitive()
self.assertEqual(accels[i].enabled, enabled)
self.sidebar.set_sensitive(not enabled)
self.assertEqual(accels[i].enabled, not enabled)
self.assertEqual(len(accels), i + 1)

View File

@ -22,9 +22,9 @@ class TestEmmental(unittest.TestCase):
"""Check that version constants have been set properly.""" """Check that version constants have been set properly."""
self.assertEqual(emmental.MAJOR_VERSION, 3) self.assertEqual(emmental.MAJOR_VERSION, 3)
self.assertEqual(emmental.MINOR_VERSION, 0) self.assertEqual(emmental.MINOR_VERSION, 0)
self.assertEqual(emmental.MICRO_VERSION, 4) self.assertEqual(emmental.MICRO_VERSION, 5)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.4") self.assertEqual(emmental.VERSION_NUMBER, "3.0.5")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.4-debug") self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.5-debug")
def test_application(self): def test_application(self):
"""Check that the application instance is initialized properly.""" """Check that the application instance is initialized properly."""
@ -63,7 +63,7 @@ class TestEmmental(unittest.TestCase):
mock_startup.assert_called() mock_startup.assert_called()
mock_load.assert_called() mock_load.assert_called()
mock_add_window.assert_called_with(self.application.win) mock_add_window.assert_called_with(self.application.win)
mock_set_useragent.assert_called_with("emmental-debug", "3.0.4") mock_set_useragent.assert_called_with("emmental-debug", "3.0.5")
@unittest.mock.patch("sys.stdout") @unittest.mock.patch("sys.stdout")
@unittest.mock.patch("gi.repository.Adw.Application.add_window") @unittest.mock.patch("gi.repository.Adw.Application.add_window")
@ -205,6 +205,17 @@ class TestEmmental(unittest.TestCase):
self.application.player = emmental.audio.Player() self.application.player = emmental.audio.Player()
win = self.application.build_window() win = self.application.build_window()
for action, accel in [("app.focus-search-playlist",
"<Control>question"),
("app.goto-active-playlist", "<Control><Alt>g"),
("app.goto-playlists", "<Shift><Control>p"),
("app.goto-artists", "<Shift><Control>a"),
("app.goto-genres", "<Shift><Control>g"),
("app.goto-decades", "<Shift><Control>d"),
("app.goto-libraries", "<Shift><Control>l")]:
self.assertEqual(self.application.get_accels_for_action(action),
[accel])
self.assertEqual(win.sidebar.sql, self.application.db) self.assertEqual(win.sidebar.sql, self.application.db)
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO) @unittest.mock.patch("sys.stdout", new_callable=io.StringIO)