From 90f88953379232523f327ae4706e419e38101adc Mon Sep 17 00:00:00 2001 From: Stephen Kaplan Date: Thu, 8 Dec 2022 12:10:44 -0500 Subject: [PATCH 1/3] Add segmented control menu item --- examples/example_segmented_control.py | 13 +++ rumps/__init__.py | 2 +- rumps/rumps.py | 156 ++++++++++++++++++++++++-- 3 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 examples/example_segmented_control.py diff --git a/examples/example_segmented_control.py b/examples/example_segmented_control.py new file mode 100644 index 0000000..a4b486e --- /dev/null +++ b/examples/example_segmented_control.py @@ -0,0 +1,13 @@ +import rumps + +@rumps.segmented(segments=["10"]) +def button_press(sender): + print("Button pressed!") + +app = rumps.App('Buzz Application', quit_button=rumps.MenuItem('Quit Buzz', key='q')) +app.menu = [ + rumps.SegmentedMenuItem(["1", "2", "3"], multiselect=True, style='bordered', callback=lambda item: print("Current selection:", item.selection)), + rumps.SegmentedMenuItem(["4", "5", "6"], style='rectangular', callback=lambda item: print("Selected", item.selection[0])), + rumps.SegmentedMenuItem(["7", "8", "9"], momentary=True, style='separated', callback=lambda item: print("Clicked", item.selection[0])), +] +app.run() \ No newline at end of file diff --git a/rumps/__init__.py b/rumps/__init__.py index 5c232f5..8e2dccc 100644 --- a/rumps/__init__.py +++ b/rumps/__init__.py @@ -24,7 +24,7 @@ from . import notifications as _notifications from .rumps import (separator, debug_mode, alert, application_support, timers, quit_application, timer, - clicked, MenuItem, SliderMenuItem, Timer, Window, App, slider) + clicked, MenuItem, SliderMenuItem, SegmentedMenuItem, Timer, Window, App, slider, segmented) notifications = _notifications.on_notification notification = _notifications.notify diff --git a/rumps/rumps.py b/rumps/rumps.py index 4ded62f..d0f59a8 100644 --- a/rumps/rumps.py +++ b/rumps/rumps.py @@ -12,7 +12,7 @@ from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode, NSSearchPathForDirectoriesInDomains, NSMakeRect, NSLog, NSObject, NSMutableDictionary, NSString, NSUserDefaults) -from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize, NSWorkspace, NSWorkspaceWillSleepNotification, NSWorkspaceDidWakeNotification, NSView +from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize, NSWorkspace, NSWorkspaceWillSleepNotification, NSWorkspaceDidWakeNotification, NSView, NSSegmentedControl, NSSegmentSwitchTrackingSelectOne, NSSegmentSwitchTrackingSelectAny, NSSegmentSwitchTrackingMomentary, NSSegmentStyleRoundRect, NSSegmentStyleSmallSquare, NSSegmentStyleSeparated from PyObjCTools import AppHelper import os @@ -236,6 +236,47 @@ def register_click(self): return f return decorator +def segmented(*args, **options): + """Decorator for registering a function as a callback for a button click action on a :class:`rumps.SegmentedMenuItem` within + the application. All elements of the provided path will be created as :class:`rumps.MenuItem` objects. The + :class:`rumps.SegmentedMenuItem` will be created as a child of the last menu item. + + Accepts the same keyword arguments as :class:`rumps.SegmentedMenuItem`. + + .. versionadded:: 0.5.0 + + :param args: a series of strings representing the path to a :class:`rumps.SegmentedMenuItem` in the main menu of the + application. + """ + def decorator(f): + + def register_click(self): + + # self not defined yet but will be later in 'run' method + menuitem = self._menu + if menuitem is None: + raise ValueError('no menu created') + + # create here in case of error so we don't create the path + segmented_menu_item = SegmentedMenuItem(**options) + segmented_menu_item.set_callback(f) + + for arg in args: + try: + menuitem = menuitem[arg] + except KeyError: + menuitem.add(arg) + menuitem = menuitem[arg] + + menuitem.add(segmented_menu_item) + + # delay registering the button until we have a current instance to be able to traverse the menu + buttons = clicked.__dict__.setdefault('*buttons', []) + buttons.append(register_click) + + return f + return decorator + #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -260,7 +301,8 @@ def __setitem__(self, key, value): if key not in self: key, value = self._process_new_menuitem(key, value) self._menu.addItem_(value._menuitem) - if isinstance(value, SliderMenuItem): + + if isinstance(value, SliderMenuItem) or isinstance(value, SegmentedMenuItem): self._set_subview_dimensions(self, value) super(Menu, self).__setitem__(key, value) @@ -288,15 +330,15 @@ def fromkeys(cls, *args, **kwargs): raise NotImplementedError def _set_subview_dimensions(self, menu, ele): - # Ensure the item view spans the full width of the menu - menu_width = max(menu._menu.size().width, 200) - view = ele._menuitem.view() - view_height = view.frame().size.height - view.setFrameSize_((menu_width, view_height)) + # Ensure the item view spans the full width of the menu + menu_width = max(menu._menu.size().width, 200) + view = ele._menuitem.view() + view_height = view.frame().size.height + view.setFrameSize_((menu_width, view_height)) - # Give the subview (e.g. slider) 5% padding on each side - subview = view.subviews()[0] - subview.setFrame_(AppKit.NSMakeRect((menu_width - menu_width * 0.9) / 2, (view_height - view_height * 0.9) / 2, menu_width * 0.9, view_height * 0.9)) + # Give the subview (e.g. slider) 5% padding on each side + subview = view.subviews()[0] + subview.setFrame_(AppKit.NSMakeRect((menu_width - menu_width * 0.9) / 2, (view_height - view_height * 0.9) / 2, menu_width * 0.9, view_height * 0.9)) def update(self, iterable, **kwargs): """Update with objects from `iterable` after each is converted to a :class:`rumps.MenuItem`, ignoring @@ -317,6 +359,7 @@ def update(self, iterable, **kwargs): - if the element is a mapping, each key-value pair will act as an iterable having a length of 2 """ + def parse_menu(iterable, menu, depth): if isinstance(iterable, MenuItem): menu.add(iterable) @@ -343,7 +386,7 @@ def parse_menu(iterable, menu, depth): # menu item / could be visual separator where ele is None or separator else: menu.add(ele) - if isinstance(ele, SliderMenuItem): + if isinstance(ele, SliderMenuItem) or isinstance(ele, SegmentedMenuItem): self._set_subview_dimensions(menu, ele) parse_menu(iterable, self, 0) parse_menu(kwargs, self, 0) @@ -377,7 +420,8 @@ def _insert_helper(self, existing_key, key, menuitem, pos): existing_menuitem = self[existing_key] index = self._menu.indexOfItem_(existing_menuitem._menuitem) self._menu.insertItem_atIndex_(menuitem._menuitem, index + pos) - if isinstance(menuitem, SliderMenuItem): + + if isinstance(menuitem, SliderMenuItem) or isinstance(menuitem, SegmentedMenuItem): self._set_subview_dimensions(self, menuitem) # Processing MenuItems @@ -670,6 +714,94 @@ def value(self, new_value): self._slider.setDoubleValue_(new_value) +class SegmentedMenuItem(object): + """Represents a slider menu item within the application's menu. + + .. versionadded:: 0.5.0 + + :param segments: list of strings to use as segment labels + :param multiselect: boolean value indicating whether multiple segments can be selected at once + :param momentary: boolean value indicating whether segments should stay active only while actively pressed (always True if only one segment is specified) + :param style: string controlling the appearance of the segmented control, either 'default', 'bordered', 'rectangular', or 'separated' + """ + + def __init__(self, segments, multiselect=False, momentary=False, style='default', callback=None): + self.__segments = segments + self.__state = [False for _ in segments] + self.__multiselect = multiselect + + # Controls how button activation/deactivation is handled + if momentary or len(segments) == 1: + tracking_mode = NSSegmentSwitchTrackingMomentary + elif multiselect: + tracking_mode = NSSegmentSwitchTrackingSelectAny + else: + tracking_mode = NSSegmentSwitchTrackingSelectOne + + # Create the segmented controller view + self._view = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, 200, 30)) + self._control = NSSegmentedControl.segmentedControlWithLabels_trackingMode_target_action_(segments, tracking_mode, NSApp, None) + self._view.addSubview_(self._control) + + # Controls the style of the controller + if style == 'bordered': + self._control.setSegmentStyle_(NSSegmentStyleRoundRect) + elif style == 'rectangular': + self._control.setSegmentStyle_(NSSegmentStyleSmallSquare) + elif style == 'separated': + self._control.setSegmentStyle_(NSSegmentStyleSeparated) + + self._menuitem = NSMenuItem.alloc().init() + self._menuitem.setTarget_(NSApp) + self._menuitem.setView_(self._view) + self.set_callback(callback) + + def __repr__(self): + return '<{0}: [selection: {1}; callback: {2}]>'.format( + type(self).__name__, + self.selection, + repr(self.callback) + ) + + def set_callback(self, callback): + """Set the function serving as callback for when a slide event occurs on this menu item. + + :param callback: the function to be called when the user drags the marker on the slider. + """ + def wrapped_callback(s): + index = self._control.selectedSegment() + if not self.__multiselect: + self.__state = [False for _ in self.__state] + self.__state[index] = not self.__state[index] + if callable(callback): + callback(s) + + NSApp._ns_to_py_and_callback[self._control] = self, wrapped_callback + self._control.setAction_('callback:') + + @property + def callback(self): + return NSApp._ns_to_py_and_callback[self._control][1] + + @property + def selection(self): + """The currently selected segments.""" + return [segment for index, segment in enumerate(self.__segments) if self.__state[index] is True] + + @selection.setter + def selection(self, new_selection): + # Clear current selection + self._control.setSelectedSegment_(-1) + self.__state = [False for _ in self.__state] + + # Select segments if they appear in the new_selection list + for new_segment in new_selection: + for index, segment in enumerate(self.__segments): + if new_segment == segment: + self._control.setSelected_forSegment_(True, index) + self.__state[index] = True + + class SeparatorMenuItem(object): """Visual separator between :class:`rumps.MenuItem` objects in the application menu.""" def __init__(self): From cb4b34895adcb283849dbf9fdf869d1f3e7dad97 Mon Sep 17 00:00:00 2001 From: Stephen Kaplan Date: Sun, 19 Mar 2023 22:11:16 -0400 Subject: [PATCH 2/3] Update docs --- docs/SegmentedMenuItem.rst | 5 +++++ docs/SeparatorMenuItem.rst | 5 +++++ docs/SliderMenuItem.rst | 5 +++++ docs/classes.rst | 3 +++ docs/functions.rst | 2 ++ docs/segmented.rst | 4 ++++ docs/slider.rst | 4 ++++ examples/example_segmented_control.py | 6 +++--- rumps/rumps.py | 6 +++--- 9 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 docs/SegmentedMenuItem.rst create mode 100644 docs/SeparatorMenuItem.rst create mode 100644 docs/SliderMenuItem.rst create mode 100644 docs/segmented.rst create mode 100644 docs/slider.rst diff --git a/docs/SegmentedMenuItem.rst b/docs/SegmentedMenuItem.rst new file mode 100644 index 0000000..eae6efa --- /dev/null +++ b/docs/SegmentedMenuItem.rst @@ -0,0 +1,5 @@ +SegmentedMenuItem +================= + +.. autoclass:: rumps.SegmentedMenuItem + :members: diff --git a/docs/SeparatorMenuItem.rst b/docs/SeparatorMenuItem.rst new file mode 100644 index 0000000..9557376 --- /dev/null +++ b/docs/SeparatorMenuItem.rst @@ -0,0 +1,5 @@ +SeparatorMenuItem +================= + +.. autoclass:: rumps.SeparatorMenuItem + :members: diff --git a/docs/SliderMenuItem.rst b/docs/SliderMenuItem.rst new file mode 100644 index 0000000..6b8cab8 --- /dev/null +++ b/docs/SliderMenuItem.rst @@ -0,0 +1,5 @@ +SliderMenuItem +============== + +.. autoclass:: rumps.SliderMenuItem + :members: diff --git a/docs/classes.rst b/docs/classes.rst index 660168f..8916b34 100644 --- a/docs/classes.rst +++ b/docs/classes.rst @@ -8,4 +8,7 @@ rumps Classes MenuItem Window Response + SegmentedMenuItem + SeparatorMenuItem + SliderMenuItem Timer diff --git a/docs/functions.rst b/docs/functions.rst index 68f3560..9c946b6 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -6,6 +6,8 @@ rumps Functions notifications clicked + segmented + slider timerfunc timers application_support diff --git a/docs/segmented.rst b/docs/segmented.rst new file mode 100644 index 0000000..e3e1e29 --- /dev/null +++ b/docs/segmented.rst @@ -0,0 +1,4 @@ +segmented +========= + +.. autofunction:: rumps.segmented diff --git a/docs/slider.rst b/docs/slider.rst new file mode 100644 index 0000000..9a56a9f --- /dev/null +++ b/docs/slider.rst @@ -0,0 +1,4 @@ +slider +====== + +.. autofunction:: rumps.slider diff --git a/examples/example_segmented_control.py b/examples/example_segmented_control.py index a4b486e..e217f40 100644 --- a/examples/example_segmented_control.py +++ b/examples/example_segmented_control.py @@ -1,10 +1,10 @@ import rumps @rumps.segmented(segments=["10"]) -def button_press(sender): - print("Button pressed!") +def button_press(self, sender): + print(self, sender) -app = rumps.App('Buzz Application', quit_button=rumps.MenuItem('Quit Buzz', key='q')) +app = rumps.App('Segments', quit_button=rumps.MenuItem('Quit', key='q')) app.menu = [ rumps.SegmentedMenuItem(["1", "2", "3"], multiselect=True, style='bordered', callback=lambda item: print("Current selection:", item.selection)), rumps.SegmentedMenuItem(["4", "5", "6"], style='rectangular', callback=lambda item: print("Selected", item.selection[0])), diff --git a/rumps/rumps.py b/rumps/rumps.py index 32b9a89..e361ccc 100644 --- a/rumps/rumps.py +++ b/rumps/rumps.py @@ -768,16 +768,16 @@ def set_callback(self, callback): :param callback: the function to be called when the user drags the marker on the slider. """ - def wrapped_callback(s): + def wrapped_callback(sender): index = self._control.selectedSegment() if not self.__multiselect: self.__state = [False for _ in self.__state] self.__state[index] = not self.__state[index] if callable(callback): - callback(s) + _internal.call_as_function_or_method(callback, sender) NSApp._ns_to_py_and_callback[self._control] = self, wrapped_callback - self._control.setAction_('callback:') + self._control.setAction_('callback:' if callback is not None else None) @property def callback(self): From 9b381e72693fa5729c4524044f89580687023bd0 Mon Sep 17 00:00:00 2001 From: Stephen Kaplan Date: Sun, 19 Mar 2023 22:23:50 -0400 Subject: [PATCH 3/3] Update example_segmented_control.py --- examples/example_segmented_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/example_segmented_control.py b/examples/example_segmented_control.py index e217f40..4110b19 100644 --- a/examples/example_segmented_control.py +++ b/examples/example_segmented_control.py @@ -1,8 +1,8 @@ import rumps @rumps.segmented(segments=["10"]) -def button_press(self, sender): - print(self, sender) +def button_press(sender): + print(sender) app = rumps.App('Segments', quit_button=rumps.MenuItem('Quit', key='q')) app.menu = [