diff --git a/README.md b/README.md index 8cbe6c7..6ec9fed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# nanogui-experiments +# NanoGUI Experiments +![NanoGUI Knobs example app](nanogui_knobs.png) + +## Quickstart + +```con +python3 -m venv venv +source venv/bin/activate +(venv) pip install -r requirenments.txt +(venv) python3 nanogui_helloworld.py +``` + + +## Knobs Example + +```con +(venv) python3 nanogui_knob.py +``` + +### Key and Mouse Bindings + +| | | | +| ----------------------------------------- | -------------------- |------------------------ | +| Mouse click-and-drag knob | coarse in-/decrement | +/- one 100th / pixel | +| Shift + mouse click-and-drag knob | fine in-/decrement | +/- 0.1 / pixel | +| Mouse wheel scroll over knob | coarse in-/decrement | one 100th / click * 2.0 | +| Shift + mouse wheel scroll over knob | fine in-/decrement | +/- 0.1 / click | +| Mouse wheel scroll over value entry | fine in-/decrement | +/- 0.1 | +| Ctrl + left click on knob | set to default value | | +| Up/down key while mouse over knob | coarse in-/decrement | +/- one 100th | +| Shift + up/down key while mouse over knob | fine in-/decrement | +/- 0.1 | +| Page up/down key while mouse over knob | fine in-/decrement | +/- one 10th | +| Mouse click on value entry up/down arrow | fine in-/decrement | +/- 0.1 | +| Return/Enter while editing value entry | accept & set value | | +| Escape key | quit | | + + +### Notes + +* No support for scaling or re-layouting on window resize yet. +* Only the knob value gauge color can currently be specified on instantation, + the knob gradient and outline colors are currently hard-coded. diff --git a/knob.py b/knob.py new file mode 100644 index 0000000..3561854 --- /dev/null +++ b/knob.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 + +import math + +from nanogui import Color, Cursor, FloatBox, Vector2f, Vector2i, Widget, glfw +from nanogui.nanovg import BUTT, RGB, RGBA, RGBAf, LerpRGBA, NVGwinding, RGBAf + + +class Knob(Widget): + def __init__( + self, + parent, + label, + default=0.0, + min_val=0.0, + max_val=127.0, + unit="%", + increment=0.1, + size=80, + color=RGBA(192, 192, 192, 255), + ): + super().__init__(parent) + self.label = label + + # sizes + self._size = size + # value gauge width as ratio of knob radius + self.gauge_width = 0.125 + # value indicator line length as ratio of knob radius + self.indicator_size = 0.35 + + # value and range + self.value = default + self.min_val = min_val + self.max_val = max_val + self.default = default + self.unit = unit + self.increment = increment + + # behaviour + self.fine_mode = False + self.scroll_speed = 2.0 + self.scroll_increment = (self.max_val - self.min_val) / 100.0 * self.scroll_speed + self.drag_increment = (self.max_val - self.min_val) / 100.0 + self.callback = None + self.set_cursor(Cursor.Hand) + + # colors + self.color = color + self.knob_color_1 = RGB(86, 92, 95) + self.knob_color_2 = RGB(39, 42, 43) + self.outline_color_1 = RGBA(190, 190, 190, 0.0) + self.outline_color_2 = RGB(23, 23, 23) + + @property + def color(self): + return self._color + + @color.setter + def color(self, col): + if isinstance(col, Color): + col = RGBAf(col.r, col.g, col.b, col.w) + + self._color = col + self.gauge_color = LerpRGBA(RGB(40, 40, 40), col, 0.3) + + def preferred_size(self, ctx): + return Vector2i(self._size, self._size) + + def _adjust_value(self, val, inc): + if self.fine_mode: + inc = self.increment + + new_val = self.value + val * inc + new_val = max(self.min_val, min(self.max_val, new_val)) + + if new_val != self.value: + self.value = new_val + + if self.callback: + self.callback(new_val) + + return True + + return False + + def mouse_enter_event(self, pos: Vector2i, enter: bool): + if enter: + self.request_focus() + + self.set_focused(enter) + return True + + def mouse_button_event(self, pos: Vector2i, button: int, down: bool, modifiers: int): + if button == glfw.MOUSE_BUTTON_1 and modifiers == glfw.MOD_CONTROL and down: + if self.value != self.default: + self.value = self.default + + if self.callback: + self.callback(self.value) + + return True + + def mouse_drag_event(self, p: Vector2i, rel: Vector2i, button: int, modifiers: int): + self._adjust_value(-rel[1], self.drag_increment) + return True + + def scroll_event(self, p: Vector2i, rel: Vector2f): + self._adjust_value(rel[1], self.scroll_increment) + return True + + def keyboard_event(self, key, scancode, action, modifiers): + if key in (glfw.KEY_LEFT_SHIFT, glfw.KEY_RIGHT_SHIFT): + if action == glfw.PRESS: + self.fine_mode = True + elif action == glfw.RELEASE: + self.fine_mode = False + return True + elif action in (glfw.PRESS, glfw.REPEAT): + if key == glfw.KEY_UP: + self._adjust_value(1, self.drag_increment) + return True + elif key == glfw.KEY_DOWN: + self._adjust_value(-1, self.drag_increment) + return True + elif key == glfw.KEY_PAGE_UP: + self._adjust_value(10, self.drag_increment) + return True + elif key == glfw.KEY_PAGE_DOWN: + self._adjust_value(-10, self.drag_increment) + return True + + return False + + def set_callback(self, callback): + if callable(callback): + self.callback = callback + else: + raise TypeError("Not a callable.") + + def set_value(self, value, trigger_callback=False): + new_val = max(self.min_val, min(self.max_val, value)) + if new_val != self.value: + self.value = new_val + + if trigger_callback and self.callback: + self.callback(value) + + def create_value_entry(self, parent, editable=True, font_size=20, widget=FloatBox, **kwargs): + entry = widget(parent, **kwargs) + entry.set_size((int(self._size * 1.1), int(font_size * 1.2))) + entry.set_font_size(font_size) + entry.set_editable(editable) + entry.set_spinnable(True) + entry.set_value(self.value) + # entry.number_format("%02.2f") # method missing in Python bindings + entry.set_min_value(self.min_val) + entry.set_max_value(self.max_val) + entry.set_value_increment(self.increment) + + if self.unit: + entry.set_units(self.unit) + + self.set_callback(entry.set_value) + entry.set_callback(self.set_value) + + return entry + + def draw(self, ctx): + pos = self.position() + size = self.size() + height = float(size[1]) + radius = height / 2.0 + + gauge_width = radius * self.gauge_width + margin = gauge_width / 2.0 + percent_filled = (self.value - self.min_val) / (self.max_val - self.min_val) + knob_diameter = (radius - gauge_width) * 2.0 - margin + indicator_length = radius * self.indicator_size + + ctx.Save() + ctx.Translate(pos[0], pos[1]) + + # Gauge (background) + ctx.BeginPath() + + ctx.StrokeWidth(gauge_width) + ctx.StrokeColor(self.gauge_color) + ctx.LineCap(BUTT.ROUND) + ctx.Arc(radius, radius, radius - margin, 0.75 * math.pi, 0.25 * math.pi, NVGwinding.CW) + ctx.Stroke() + + # Gauge (fill) + ctx.BeginPath() + + ctx.StrokeWidth(gauge_width) + ctx.StrokeColor(self.color) + ctx.LineCap(BUTT.ROUND) + ctx.Arc( + radius, + radius, + radius - margin, + 0.75 * math.pi, + (0.75 + 1.5 * percent_filled) * math.pi, + NVGwinding.CW, + ) + ctx.Stroke() + + # Knob + ctx.BeginPath() + + ctx.StrokeWidth(2.0) + outline_paint = ctx.LinearGradient( + 0, + 0, + 0, + height - 10, + self.outline_color_1, + self.outline_color_2, + ) + ctx.StrokePaint(outline_paint) + + knob_paint = ctx.LinearGradient( + radius, gauge_width, radius, knob_diameter, self.knob_color_1, self.knob_color_2 + ) + ctx.FillPaint(knob_paint) + + ctx.Circle(radius, radius, knob_diameter / 2.0) + ctx.Fill() + ctx.Stroke() + + # Indicator + ctx.BeginPath() + + ctx.Translate(radius, radius) + ctx.Rotate((2.0 + ((percent_filled - 0.5) * 1.5)) * math.pi) + + ctx.StrokeColor(self.color) + ctx.StrokeWidth(gauge_width) + ctx.LineCap(BUTT.ROUND) + indicator_start = radius - margin - indicator_length + ctx.MoveTo(0, -indicator_start) + ctx.LineTo(0, -(indicator_start + indicator_length)) + ctx.Stroke() + + ctx.Restore() + ctx.ClosePath() diff --git a/nanogui_customwidget.py b/nanogui_customwidget.py new file mode 100644 index 0000000..8342ef9 --- /dev/null +++ b/nanogui_customwidget.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import nanogui +from nanogui import glfw +from nanogui import BoxLayout, Color, Label, Orientation, Screen, Widget, Window, Vector2i +from nanogui.nanovg import RGBAf, RGB + + +class CustomWidget(Widget): + def __init__(self, parent, size=(100, 100), color=Color(192, 0, 0, 255)): + super().__init__(parent) + self._size = size + self._color = RGBAf(color.r, color.g, color.b, color.w) + + def preferred_size(self, ctx): + return Vector2i(self._size) + + def draw(self, ctx): + pos = self.position() + size = self.size() + + ctx.BeginPath() + ctx.Rect(pos[0], pos[1], size[0], size[1]) + ctx.FillColor(RGB(128, 128, 0)) + ctx.Fill() + ctx.BeginPath() + ctx.StrokeWidth(5.0) + ctx.StrokeColor(self._color) + ctx.MoveTo(pos[0], pos[1]) + ctx.LineTo(pos[0] + size[0], pos[1] + size[1] / 3.0) + ctx.Stroke() + ctx.ClosePath() + + +class CustomWidgetApp(Screen): + def __init__(self, size=(400, 300)): + super().__init__(size, "NanoGUI CustomWidget") + self.win = Window(self, "Demo Window") + + self.win.set_layout(BoxLayout(Orientation.Vertical, margin=20, spacing=20)) + self.resize_event(size) + + Label(self.win, "NanoGUI CustomWidget Demo", "sans-bold") + + cw = CustomWidget(self.win, (200, 100)) + + self.draw_all() + self.set_visible(True) + self.perform_layout() + + def resize_event(self, size): + self.win.set_fixed_size(size) + self.win.set_size(size) + self.win.center() + self.win.perform_layout(self.nvg_context()) + super().resize_event(size) + return True + + def keyboard_event(self, key, scancode, action, modifiers): + if super().keyboard_event(key, scancode, action, modifiers): + return True + + if key == glfw.KEY_ESCAPE and action == glfw.PRESS: + self.set_visible(False) + return True + + return False + + +if __name__ == "__main__": + import gc + + nanogui.init() + app = CustomWidgetApp() + nanogui.mainloop(refresh=1 / 60.0 * 1000) + del app + gc.collect() + nanogui.shutdown() diff --git a/nanogui_helloworld.py b/nanogui_helloworld.py new file mode 100644 index 0000000..ced3920 --- /dev/null +++ b/nanogui_helloworld.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +import nanogui +from nanogui import glfw +from nanogui import BoxLayout, Button, Label, Orientation, Screen, Window + + +class HelloWorldApp(Screen): + def __init__(self, size=(400, 300)): + super().__init__(size, "NanoGUI Hello World") + self.win = Window(self, "Demo Window") + + self.win.set_layout(BoxLayout(Orientation.Vertical, margin=20, spacing=20)) + self.resize_event(size) + + Label(self.win, "NanoGUI Demo", "sans-bold") + b = Button(self.win, "Say hello!") + + def cb(): + print("Well, 'ello there!") + + b.set_callback(cb) + + self.set_visible(True) + self.perform_layout() + + def resize_event(self, size): + self.win.set_fixed_size(size) + self.win.set_size(size) + self.win.center() + self.win.perform_layout(self.nvg_context()) + super().resize_event(size) + return True + + def keyboard_event(self, key, scancode, action, modifiers): + if super().keyboard_event(key, scancode, action, modifiers): + return True + + if key == glfw.KEY_ESCAPE and action == glfw.PRESS: + self.set_visible(False) + return True + + return False + + +if __name__ == "__main__": + import gc + + nanogui.init() + app = HelloWorldApp() + nanogui.mainloop(refresh=1 / 60.0 * 1000) + del app + gc.collect() + nanogui.shutdown() diff --git a/nanogui_knobs.png b/nanogui_knobs.png new file mode 100644 index 0000000..29766f6 Binary files /dev/null and b/nanogui_knobs.png differ diff --git a/nanogui_knobs.py b/nanogui_knobs.py new file mode 100644 index 0000000..8b34c50 --- /dev/null +++ b/nanogui_knobs.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +from random import randint, shuffle, uniform + +import nanogui +from nanogui import BoxLayout, Color, Label, Orientation, Screen, Widget, Window, glfw +from nanogui.nanovg import RGB + +from knob import Knob + + +class KnobsApp(Screen): + def __init__(self, size=(450, 250)): + super().__init__(size, "NanoGUI Knobs") + self.set_background(Color(96, 96, 96, 255)) + self.win = Window(self, "Envelope") + self.win.set_layout(BoxLayout(Orientation.Horizontal, margin=20, spacing=20)) + self.resize_event(size) + + knobs = ( + ("Attack", 0.0, 0.0, 5.0, "s", 0.01), + ("Decay", 0.0, 0.0, 5.0, "s", 0.01), + ("Sustain", 0.0, 0.0, 100.0, "%", 0.01), + ("Release", 0.0, 0.0, 5.0, "s", 0.01), + ) + + self.knobs = [] + self.entries = [] + self.labels = [] + + for i, knobspec in enumerate(knobs): + box = Widget(self.win) + box.set_layout(BoxLayout(Orientation.Vertical, spacing=10)) + + rgb = [128 + randint(0, 127), randint(0, 127), 64] + shuffle(rgb) + knob = Knob(box, *knobspec) + knob.color = RGB(*rgb) + knob.value = round(uniform(knob.min_val, knob.max_val), 2) + self.knobs.append(knob) + self.entries.append(knob.create_value_entry(box)) + self.labels.append(Label(box, knob.label, font="sans", font_size=20)) + + self.draw_all() + self.set_visible(True) + self.perform_layout() + + def resize_event(self, size): + self.win.set_fixed_size(size) + self.win.set_size(size) + self.win.center() + self.win.perform_layout(self.nvg_context()) + super().resize_event(size) + return True + + def keyboard_event(self, key, scancode, action, modifiers): + if super().keyboard_event(key, scancode, action, modifiers): + return True + + if key == glfw.KEY_ESCAPE and action == glfw.PRESS: + self.set_visible(False) + return True + + return True + + def __del__(self): + # callbacks referencing other widgets cause memory leakage at cleanup + for entry in self.entries: + entry.set_callback(lambda x: True) + for entry in self.knobs: + entry.set_callback(lambda x: True) + + +if __name__ == "__main__": + import gc + + nanogui.init() + app = KnobsApp() + nanogui.mainloop(refresh=1 / 60.0 * 1000) + del app + gc.collect() + nanogui.shutdown() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c3c393 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +nanogui==0.2.0