diff --git a/emmental/header/volume.py b/emmental/header/volume.py new file mode 100644 index 0000000..85a4f05 --- /dev/null +++ b/emmental/header/volume.py @@ -0,0 +1,52 @@ +# Copyright 2022 (c) Anna Schumaker. +"""A custom Gtk.Box with controls for adjusting the volume.""" +from gi.repository import GObject +from gi.repository import Gtk + +STEP_SIZE = 0.05 + + +def format_value_func(scale, value: float) -> str: + """Format the volume value to a percentage.""" + return f"{round(value*100)} %" + + +class Controls(Gtk.Box): + """A Gtk.Box containing widgets for adjusting the volume.""" + + volume = GObject.Property(type=float, default=1.0) + + def __init__(self): + """Initialize our volume controls.""" + super().__init__() + self._decrement = Gtk.Button(icon_name="list-remove-symbolic", + valign=Gtk.Align.END, has_frame=False, + margin_bottom=6) + self._adjustment = Gtk.Adjustment.new(1.0, 0.0, 1.0, STEP_SIZE, 0, 0) + self._scale = Gtk.Scale(adjustment=self._adjustment, draw_value=True, + valign=Gtk.Align.END, hexpand=True) + self._increment = Gtk.Button(icon_name="list-add-symbolic", + valign=Gtk.Align.END, has_frame=False, + margin_bottom=6) + + self._scale.set_format_value_func(format_value_func) + + self.append(self._decrement) + self.append(self._scale) + self.append(self._increment) + + self._decrement.connect("clicked", self.__decrement) + self._scale.connect("value-changed", self.__value_changed) + self._increment.connect("clicked", self.__increment) + + self.bind_property("volume", self._adjustment, "value", + GObject.BindingFlags.BIDIRECTIONAL) + + def __decrement(self, button: Gtk.Button) -> None: + self._scale.set_value(self._scale.get_value() - STEP_SIZE) + + def __increment(self, button: Gtk.Button) -> None: + self._scale.set_value(self._scale.get_value() + STEP_SIZE) + + def __value_changed(self, range: Gtk.Range) -> None: + self.volume = range.get_value() diff --git a/tests/header/test_volume.py b/tests/header/test_volume.py new file mode 100644 index 0000000..49574af --- /dev/null +++ b/tests/header/test_volume.py @@ -0,0 +1,100 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Tests our volume controls.""" +import unittest +import emmental.header.volume +import tests.util +from gi.repository import Gtk + + +class TestControls(unittest.TestCase): + """Test case for our custom volume controls.""" + + def setUp(self): + """Set up common variables.""" + self.vol = emmental.header.volume.Controls() + self.value = tests.util.FloatObject(value=1.0) + self.vol.bind_property("volume", self.value, "value") + + def test_step_size(self): + """Test the step size constant.""" + self.assertEqual(emmental.header.volume.STEP_SIZE, 0.05) + + def test_volume(self): + """Check that the volume Controls are set up properly.""" + self.assertIsInstance(self.vol, Gtk.Box) + self.assertEqual(self.vol.get_spacing(), 0) + self.assertEqual(self.vol.get_orientation(), + Gtk.Orientation.HORIZONTAL) + self.assertEqual(self.vol.volume, 1.0) + + self.vol.volume = 0.85 + self.assertAlmostEqual(self.vol._scale.get_value(), 0.85) + self.assertAlmostEqual(self.value.value, 0.85) + + def test_decrement_button(self): + """Test the decrement button.""" + self.assertIsInstance(self.vol._decrement, Gtk.Button) + self.assertEqual(self.vol.get_first_child(), self.vol._decrement) + + self.assertEqual(self.vol._decrement.get_icon_name(), + "list-remove-symbolic") + self.assertEqual(self.vol._decrement.get_valign(), Gtk.Align.END) + self.assertEqual(self.vol._decrement.get_margin_bottom(), 6) + self.assertFalse(self.vol._decrement.get_has_frame()) + + self.vol._decrement.emit("clicked") + self.assertAlmostEqual(self.vol.volume, 0.95) + self.assertAlmostEqual(self.vol._scale.get_value(), 0.95) + self.assertAlmostEqual(self.value.value, 0.95) + + def test_scale(self): + """Check that the volume slider has been set up properly.""" + self.assertIsInstance(self.vol._adjustment, Gtk.Adjustment) + self.assertIsInstance(self.vol._scale, Gtk.Scale) + self.assertEqual(self.vol._decrement.get_next_sibling(), + self.vol._scale) + self.assertEqual(self.vol._scale.get_adjustment(), + self.vol._adjustment) + + self.assertEqual(self.vol._adjustment.get_lower(), 0.0) + self.assertEqual(self.vol._adjustment.get_upper(), 1.0) + self.assertEqual(self.vol._adjustment.get_step_increment(), 0.05) + self.assertEqual(self.vol._adjustment.get_value(), 1.0) + + self.assertEqual(self.vol._scale.get_orientation(), + Gtk.Orientation.HORIZONTAL) + self.assertEqual(self.vol._scale.get_value(), 1.0) + self.assertEqual(self.vol._scale.get_valign(), Gtk.Align.END) + self.assertTrue(self.vol._scale.get_draw_value()) + self.assertTrue(self.vol._scale.get_hexpand()) + + self.vol._scale.set_value(0.85) + self.assertAlmostEqual(self.vol.volume, 0.85) + self.assertAlmostEqual(self.vol._scale.get_value(), 0.85) + self.assertAlmostEqual(self.value.value, 0.85) + + def test_increment_button(self): + """Test the decrement button.""" + self.assertIsInstance(self.vol._increment, Gtk.Button) + self.assertEqual(self.vol._scale.get_next_sibling(), + self.vol._increment) + + self.assertEqual(self.vol._increment.get_icon_name(), + "list-add-symbolic") + self.assertEqual(self.vol._increment.get_valign(), Gtk.Align.END) + self.assertEqual(self.vol._increment.get_margin_bottom(), 6) + self.assertFalse(self.vol._increment.get_has_frame()) + + self.vol.volume = 0.9 + self.vol._increment.emit("clicked") + self.assertAlmostEqual(self.vol.volume, 0.95) + self.assertAlmostEqual(self.vol._scale.get_value(), 0.95) + self.assertAlmostEqual(self.value.value, 0.95) + + def test_format_value(self): + """Check that the scale value is formatted correctly.""" + format_value = emmental.header.volume.format_value_func + for value in range(101): + with self.subTest(value=value): + self.assertEqual(format_value(self.vol._scale, value/100), + f"{value} %") diff --git a/tests/util/__init__.py b/tests/util/__init__.py index c4654a5..a2ba0d6 100644 --- a/tests/util/__init__.py +++ b/tests/util/__init__.py @@ -2,6 +2,7 @@ """Helper utilities for testing.""" import unittest import emmental.db +from gi.repository import GObject class TestCase(unittest.TestCase): @@ -14,3 +15,9 @@ class TestCase(unittest.TestCase): def tearDown(self): """Clean up the database connection.""" self.sql.close() + + +class FloatObject(GObject.GObject): + """A GObject holding a Float value.""" + + value = GObject.Property(type=float)