diff --git a/emmental/sidebar/icon.py b/emmental/sidebar/icon.py index fd3c2c2..e8f33fa 100644 --- a/emmental/sidebar/icon.py +++ b/emmental/sidebar/icon.py @@ -1,10 +1,20 @@ # Copyright 2022 (c) Anna Schumaker. """Custom icon widgets for playlist rows.""" +import pathlib from gi.repository import GObject +from gi.repository import GLib +from gi.repository import Gio from gi.repository import Gdk +from gi.repository import Gtk from gi.repository import Adw +IMAGE_FILTERS = Gio.ListStore() +IMAGE_FILTERS.append(Gtk.FileFilter(name="Image Files", + mime_types=["inode/directory"])) +IMAGE_FILTERS[0].add_pixbuf_formats() + + class Icon(Adw.Bin): """A custom Adw.Avatar that can load images from a file path.""" @@ -32,3 +42,35 @@ class Icon(Adw.Bin): else: texture = Gdk.Texture.new_from_filename(str(self.filepath)) self._icon.set_custom_image(texture) + + +class Settable(Icon): + """A custom Avatar with an attached popover for selecting an image.""" + + settable = GObject.Property(type=bool, default=False) + + def __init__(self): + """Initialize our settable icon.""" + super().__init__() + self._dialog = Gtk.FileDialog(title="Pick an Image", + filters=IMAGE_FILTERS) + self._long_press = Gtk.GestureLongPress() + self._long_press.connect("pressed", self.__long_press) + self.add_controller(self._long_press) + + def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None: + try: + file = dialog.open_finish(task) + self.filepath = pathlib.Path(file.get_path()) + except GLib.Error: + self.filepath = None + + def __long_press(self, gesture: Gtk.GestureLongPress, + x: float, y: float) -> None: + """Handle a click event.""" + if self.settable: + if self.filepath is not None: + file = Gio.File.new_for_path(str(self.filepath)) + self._dialog.set_initial_file(file) + self._dialog.open(self.get_ancestor(Gtk.Window), None, + self.__async_ready) diff --git a/tests/sidebar/test_icon.py b/tests/sidebar/test_icon.py index f219e4c..409df1c 100644 --- a/tests/sidebar/test_icon.py +++ b/tests/sidebar/test_icon.py @@ -3,10 +3,31 @@ import unittest import emmental.sidebar.icon import tests.util +from gi.repository import GLib +from gi.repository import Gio from gi.repository import Gdk +from gi.repository import Gtk from gi.repository import Adw +class TestImageFilters(unittest.TestCase): + """Test that the global File Filters have been set up properly.""" + + def test_filters(self): + """Test the global Filter list.""" + self.assertIsInstance(emmental.sidebar.icon.IMAGE_FILTERS, + Gio.ListStore) + + filter = emmental.sidebar.icon.IMAGE_FILTERS[0] + self.assertIsInstance(filter, Gtk.FileFilter) + self.assertEqual(filter.get_name(), "Image Files") + + (name, mime_types) = filter.to_gvariant() + self.assertEqual(name, "Image Files") + self.assertTupleEqual(mime_types[0], (1, "inode/directory")) + self.assertGreater(len(mime_types), 1) + + class TestIcon(unittest.TestCase): """Test our icon that can also be set from filepath.""" @@ -60,3 +81,87 @@ class TestIcon(unittest.TestCase): self.icon.filepath = None self.assertIsNone(self.icon._icon.get_custom_image()) mock_new.assert_not_called() + + +class TestSettable(unittest.TestCase): + """Test our icon that can be set by the user.""" + + def setUp(self): + """Set up common variables.""" + self.icon = emmental.sidebar.icon.Settable() + + def test_init(self): + """Test that the icon is set up properly.""" + self.assertIsInstance(self.icon, emmental.sidebar.icon.Icon) + + def test_dialog(self): + """Test that the dialog is set up properly.""" + self.assertIsInstance(self.icon._dialog, Gtk.FileDialog) + self.assertEqual(self.icon._dialog.get_title(), "Pick an Image") + self.assertEqual(self.icon._dialog.get_filters(), + emmental.sidebar.icon.IMAGE_FILTERS) + + def test_setting(self): + """Test setting the icon through the FileDialog.""" + self.assertIsInstance(self.icon._long_press, Gtk.GestureLongPress) + self.assertIn(self.icon._long_press, self.icon.observe_controllers()) + self.assertFalse(self.icon.settable) + + mock_set_initial_file = unittest.mock.Mock() + self.icon._dialog.set_initial_file = mock_set_initial_file + self.icon.settable = True + + with unittest.mock.patch.object(self.icon._dialog, + "open") as mock_open: + self.icon._long_press.emit("pressed", 0, 0) + mock_set_initial_file.assert_not_called() + mock_open.assert_called_with(None, None, + self.icon._Settable__async_ready) + + with unittest.mock.patch.object(self.icon._dialog, + "open_finish") as mock_finish: + task = Gio.Task() + cover_path = str(tests.util.COVER_JPG) + mock_finish.return_value = Gio.File.new_for_path(cover_path) + + self.icon._Settable__async_ready(self.icon._dialog, task) + mock_finish.assert_called_with(task) + self.assertEqual(self.icon.filepath, tests.util.COVER_JPG) + + def test_clearing(self): + """Test clearing the icon by canceling the FileDialog.""" + mock_set_initial_file = unittest.mock.Mock() + self.icon._dialog.set_initial_file = mock_set_initial_file + self.icon.filepath = tests.util.COVER_JPG + self.icon.settable = True + + with unittest.mock.patch.object(self.icon._dialog, + "open") as mock_open: + self.icon._long_press.emit("pressed", 0, 0) + mock_set_initial_file.assert_called() + mock_open.assert_called() + + cover_path = str(tests.util.COVER_JPG) + call_args = mock_set_initial_file.call_args.args + self.assertIsInstance(call_args[0], Gio.File) + self.assertEqual(call_args[0].get_path(), cover_path) + + with unittest.mock.patch.object(self.icon._dialog, "open_finish", + side_effect=GLib.Error) as mock_finish: + task = Gio.Task() + mock_finish.return_value = None + + self.icon._Settable__async_ready(self.icon._dialog, task) + mock_finish.assert_called_with(task) + self.assertIsNone(self.icon.filepath) + + def test_not_settable(self): + """Test when the icon isn't settable.""" + mock_set_initial_file = unittest.mock.Mock() + self.icon._dialog.set_initial_file = mock_set_initial_file + + with unittest.mock.patch.object(self.icon._dialog, + "open") as mock_open: + self.icon._long_press.emit("pressed", 0, 0) + mock_set_initial_file.assert_not_called() + mock_open.assert_not_called()