nion-software / nionui

Nion UI is a Python UI framework with a Qt backend.
Other
5 stars 15 forks source link

PushButton widgets reflecting states #62

Closed kociak closed 1 year ago

kociak commented 1 year ago

Using swift for driving external hardwares, I am always trying to simplify the UI for the users. Although the question is quite more generic, the best way I found to synchronize the UI and the state of the hardware is the following. I define a state property, a proxy to the state machine state of the HW, of an instrument, itself a proxy for the hardware. The instrument fires the relevant Event.Event. This Event.Event is filtered by the UI handler, which takes responsability to update the UI, especially enabling/disabling various buttons and textfields. This works suprinsingly well, as soon as the state machine is rigorously defined. The next step for me is to have push buttons with icons that could be dynamically changed upon certain states. This would increase readability and ergonomy and optimize space. I guess we ccould bind the iconproperty of the button to some property of the instrument, but this won't be really easy. I was wondering if there is somewhere (did not find it) a mix of checkbox group_valueand icon property of the pushbutton. Otherwise speaking, having the icon of a pushbutton, and also possibily the enable property of it that would be tight to an int. Then I could much more easily tight the appearance and reactivity of pushbutton to the state of my instrument. Probably another way would be to do it outside of the Declarativeway, but this is not really robust I guess. Any hint? not a priority, but certainly something out of my expertise. Thanks

cmeyer commented 1 year ago

I think the best way is to use converter with the binding, as described here: https://nionui.readthedocs.io/en/latest/index.html#converters

There are a few converters already available which you can use directly or as examples: https://github.com/nion-software/nionutils/blob/master/nion/utils/Converter.py

Unfortunately I don't have an example of state <-> icon converter. But I think it would work as a convert from str to numpy.array with dtype=numpy.uint32. You would not need to implement convert_back since it would only be a one way conversion.

I'm going to leave this issue open to see if I can find some other examples of how this might be done too. I'll also post some additional examples since I'll be reworking some of the scan and camera control panels soon using declarative UI code.

Additional simple example is here: https://github.com/nion-software/nionui/blob/master/nionui_app/nionui_examples/ui_demo/Converters.py

There are also many binding/converter examples throughout the rest of the project.

kociak commented 1 year ago

All right. I did not know the iconwas bindable. It is therefore trivial once you get the way to load images. thanks for the hint!

Here is my test version (only 3 states and one button) I still need to test how the on_click which will certainly trigger a Statechange, will react (the state changing the icon ...) Also, might be stupid to reload each time the icons, and probably I'll need to load them once for all in the Handler __init__.

from nion.ui import CanvasItem
import pkgutil
#  The new converter, as suggested; no convert back needed
class StateToIconConverter():
    """ Convert a MonchActuatorDevice.State to an icon"""

    def __init__(self, stateToIconDict: typing.Dict[MonchActuatorDevice.State,str]):
        self.__stateToIconDict = stateToIconDict

    def convert(self, value:typing.Optional[MonchActuatorDevice.State]):
        if value:
            if dataname := self.__stateToIconDict[value]:
                data = pkgutil.get_data(__name__, dataname)
                icon = CanvasItem.load_rgba_data_from_bytes(data, "png")
                return icon
        return None

# somewhere in the init of the Handler. In my case, I will need one converter per #button because all the buttons won't reflect/react to the MonchActuatorDevice.State s the same way

mydict = {MonchActuatorDevice.State.Observe : "resources/icon2.png",MonchActuatorDevice.State.Observemove : "resources/icon3.png", MonchActuatorDevice.State.Freeze : "resources/icon1.png"}
 self.conv_debug = StateToIconConverter(mydict)

## somewhere in the View
# instrument is a MonchActuatorDevice of course
self.icon_debug_pb = ui.create_push_button(icon='@binding(instrument.state,converter=conv_debug)',width=22, height=22,name ="icon_debug_pb")
cmeyer commented 1 year ago

I'll close this issue now. If you run into another problem, make a comment here or file another issue.

kociak commented 1 year ago

Just for the record, a better version of the multiple state button, in case someone wants to reproduce it:

# the converter convert a `MonchActuatorDevice.state` into an icon, based on a dictionnary. `MonchActuatorDevice.state` is an `Enum` specific to my project,  but  of course, you can use whatever fits your need and adapt.

class StateToIconConverter():
    """ Convert a MonchActuatorDevice.State to an icon"""

    def __init__(self, stateToIconDict: typing.Dict[MonchActuatorDevice.State,typing.Optional[DrawingContext.RGBA32Type]]):
        self.__stateToIconDict = stateToIconDict

    def convert(self, value:typing.Optional[MonchActuatorDevice.State]):

            return self.__stateToIconDict[value] if value else None

# Somewhere in the handler, you need to create a dict that maps the `MonchActuatorDevice.state` to an icon. This can be easily adapted to your needs

def create_state_to_icon_dict(self) -> typing.Dict[MonchActuatorDevice.State,typing.Optional[DrawingContext.RGBA32Type]]:
        icon_name_list = [
            'resources/icon1.png',
            'resources/icon2.png',
            'resources/icon3.png'
                        ]
        state_list = [member for member in MonchActuatorDevice.State if 'Observe' in member.name or 'Freeze' in member.name]
        # I know that state_list has only 3 members, but you better adapt this ...
        data_list = [pkgutil.get_data(__name__, file) for file in icon_name_list]
        icon_list = [CanvasItem.load_rgba_data_from_bytes(data, "png") for data in data_list]
        return dict(zip(state_list, icon_list))

mydict = self.create_state_to_icon_dict()
self.conv_debug = StateToIconConverter(mydict)

# and of course, somewhere in the Panel

self.icon_debug_pb = ui.create_push_button(icon='@binding(instrument.state,converter=conv_debug)',width=26, height=26,name ="icon_debug_pb")
cmeyer commented 1 year ago

One additional note, instead of using the bitmap data directly, you have the option of using the Bitmap object which can be created like this:

from nion.ui import Bitmap
bitmap = Bitmap.Bitmap(rgba_bitmap_data=rgba_bitmap_data, shape=Geometry.IntSize(24, 24))

The advantage of doing this is that you can specify the intended size separately from the bitmap data. This allows you to create high resolution bitmaps that look good on high DPI monitors. Typically we create bitmap data that is 48x48 pixels but specify its size as 24x24 so that when it draws on a high DPI monitor, it will take advantage of the high resolution, i.e. it will draw 48x48 pixels into a screen space of 24x24, taking advantage of the high DPI.

Anywhere you can pass a numpy array for a bitmap, you should be able to use a Bitmap object instead. If I missed any case in the API's, please file a bug so I can fix it.

kociak commented 1 year ago

I am now using Bitmap and it seemed to work until I used some .pngwith alpha layer to 0. Then I understood that there was an ambiguity in the dimensions definitions, which are obvious with a transparent layer. The image is a file with a given dimension (say, for a square, an edge of size Fsize in pixels). Then the bitmap has another dimension, (shape), say Bitsize. Finally, the button has also another size (Butsize, specified by parameters widthand height). If Bitsize << Butsize, then a square is drawn around the button, the image might be too small but everything seems fine. If Bitsize = Butsize, the image appears larger than the button (we can see the frame of the button being smaller than the image), with potential overlap between the images of different buttons. Buttons clickable area is within the frame, as expected. I don't think Fsize affects anything. I am wondering if this is an expected behaviour, but I doubt...

Here is an example code:

self.StageSafeIcon=Bitmap.Bitmap(rgba_bitmap_data=CanvasItem.load_rgba_data_from_bytes(pkgutil.get_data(__name__, "resources/StageSafe.png"), "png"),
                      shape=Geometry.IntSize(42, 42))
        self.StageFocusIcon = Bitmap.Bitmap(rgba_bitmap_data=CanvasItem.load_rgba_data_from_bytes(pkgutil.get_data(__name__, "resources/StageFocus.png"), "png"),
                      shape=Geometry.IntSize(42, 42))

self.StageFocusSShftZ = ui.create_push_button( name="StageFocusSShftZ_pb",icon='StageFocusIcon', on_clicked="StageFocusSShftZ",width = 42, height = 42)
            self.StageSafeSShftZ = ui.create_push_button( name="StageSafeSShftZ_pb", icon = 'StageSafeIcon', width = 42, height = 42,
                                                          on_clicked="StageSafeSShftZ")