diff --git a/emmental/sidebar/title.py b/emmental/sidebar/title.py index a99607b..e7102a7 100644 --- a/emmental/sidebar/title.py +++ b/emmental/sidebar/title.py @@ -46,3 +46,69 @@ class PlaylistTitle(Title): def __update_subtitle(self, title: Title, param) -> None: s_count = "s" if self.count != 1 else "" self.subtitle = f"{self.count} track{s_count}" + + +class EditableTitle(Gtk.Stack): + """A Title widget and an Entry for handling renames.""" + + title = GObject.Property(type=str) + count = GObject.Property(type=int) + editable = GObject.Property(type=bool, default=False) + + def __init__(self, **kwargs): + """Initialize a row EditableTitle widget.""" + super().__init__(**kwargs) + self._title = PlaylistTitle(title=self.title, count=self.count) + self._entry = Gtk.Text(text=self.title) + self._long_press = Gtk.GestureLongPress() + self._trigger = Gtk.ShortcutTrigger.parse_string("Escape") + self._action = Gtk.CallbackAction.new(self.__cancel_editing) + self._cancel = Gtk.Shortcut.new(self._trigger, self._action) + self._shortcut = Gtk.ShortcutController() + + self._shortcut.add_shortcut(self._cancel) + + self.bind_property("title", self._title, "title") + self.bind_property("title", self._entry, "text") + self.bind_property("count", self._title, "count") + + self._long_press.connect("pressed", self.__long_press) + self._entry.connect("activate", self.__entry_activated) + self._entry.connect("notify::has-focus", self.__focus_change) + + self.add_child(self._title) + self.add_child(self._entry) + self.add_controller(self._long_press) + self.add_controller(self._shortcut) + self.add_css_class("linked") + + def __long_press(self, gesture: Gtk.GestureLongPress, + x: float, y: float) -> None: + self.editing = True + + def __entry_activated(self, entry: Gtk.Text) -> None: + self.emit("request-rename", entry.get_text()) + + def __focus_change(self, text: Gtk.Text, param) -> None: + self.editing = text.has_focus() + + def __cancel_editing(self, title, *args) -> None: + self.editing = False + + @GObject.Property + def editing(self) -> bool: + """Get if we are currently editing the label.""" + return self.get_visible_child() == self._entry + + @editing.setter + def editing(self, newval: bool) -> None: + if self.editable and newval: + self.set_visible_child(self._entry) + self._entry.grab_focus() + else: + self.set_visible_child(self._title) + + @GObject.Signal(arg_types=(str,)) + def request_rename(self, new_name: str): + """Signal that the user has entered new text.""" + self.editing = False diff --git a/tests/sidebar/test_title.py b/tests/sidebar/test_title.py index cfde0c0..2202a19 100644 --- a/tests/sidebar/test_title.py +++ b/tests/sidebar/test_title.py @@ -87,3 +87,92 @@ class TestPlaylistTitle(unittest.TestCase): self.title.count = 2 self.assertEqual(self.title.subtitle, "2 tracks") + + +class TestEditableTitle(unittest.TestCase): + """Tests the editable title widget.""" + + def setUp(self): + """Set up common variables.""" + self.title = emmental.sidebar.title.EditableTitle() + + def test_init(self): + """Test that the editable title widget is set up properly.""" + self.assertIsInstance(self.title, Gtk.Stack) + self.assertIsNotNone(self.title.get_page(self.title._title)) + self.assertIsNotNone(self.title.get_page(self.title._entry)) + + def test_playlist_title(self): + """Test the playlist title child.""" + self.assertIsInstance(self.title._title, + emmental.sidebar.title.PlaylistTitle) + self.assertEqual(self.title.title, "") + self.assertEqual(self.title.count, 0) + + self.title.title = "Test Title" + self.assertEqual(self.title._title.title, "Test Title") + self.assertEqual(self.title._entry.get_text(), "Test Title") + + self.title.count = 42 + self.assertEqual(self.title._title.count, 42) + + title2 = emmental.sidebar.title.EditableTitle(title="Test Title") + self.assertEqual(title2._title.title, "Test Title") + self.assertEqual(title2._entry.get_text(), "Test Title") + + def test_visible_child(self): + """Test switching to the Entry for editing.""" + self.assertEqual(self.title.get_visible_child(), self.title._title) + self.assertFalse(self.title.editing) + + self.title.editing = True + self.assertFalse(self.title.editing) + + self.title.editable = True + self.title.editing = True + self.assertTrue(self.title.editing) + self.assertEqual(self.title.get_visible_child(), self.title._entry) + + self.title.editing = False + self.assertEqual(self.title.get_visible_child(), self.title._title) + + def test_cancel(self): + """Test the cancel Shortcut for editing.""" + self.assertIsInstance(self.title._shortcut, Gtk.ShortcutController) + self.assertIsInstance(self.title._cancel, Gtk.Shortcut) + self.assertIsInstance(self.title._trigger, Gtk.KeyvalTrigger) + self.assertIsInstance(self.title._action, Gtk.CallbackAction) + + self.assertEqual(self.title._cancel.get_trigger(), self.title._trigger) + self.assertEqual(self.title._cancel.get_action(), self.title._action) + + self.assertEqual(self.title._trigger.get_keyval(), + Gtk.accelerator_parse("Escape").accelerator_key) + self.assertEqual(self.title._trigger.get_modifiers(), 0) + + self.assertIn(self.title._cancel, self.title._shortcut) + self.assertIn(self.title._shortcut, self.title.observe_controllers()) + + self.title.editing = True + self.title._action.activate(0, self.title, None) + self.assertFalse(self.title.editing) + + def test_editing(self): + """Test editing the title label.""" + self.assertIsInstance(self.title._entry, Gtk.Text) + self.assertIsInstance(self.title._long_press, Gtk.GestureLongPress) + self.assertIn(self.title._long_press, self.title.observe_controllers()) + + self.title._long_press.emit("pressed", 0, 0) + self.assertFalse(self.title.editing) + + self.title.editable = True + self.title._long_press.emit("pressed", 0, 0) + self.assertTrue(self.title.editing) + + callback = unittest.mock.Mock() + self.title.connect("request-rename", callback) + self.title._entry.set_text("New Name") + self.title._entry.emit("activate") + self.assertFalse(self.title.editing) + callback.assert_called_with(self.title, "New Name")