Skip to content

Better gui for many channels (wx-specific) #195

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2d121f2
Create fixed size scroll area
gnodar01 May 12, 2025
2876cfd
Add channel selector
gnodar01 May 12, 2025
316643f
Setup signaling for updating layout
gnodar01 May 13, 2025
2d69800
Make display options smarter
gnodar01 May 13, 2025
3d34227
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 13, 2025
732bb2e
Display previously visible items on mode change
gnodar01 May 14, 2025
5ea44a1
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 14, 2025
0ac7653
Make update_lut_view private
gnodar01 May 14, 2025
bcff280
'visible' -> 'displayed' to avoid ambiguity
gnodar01 May 14, 2025
7abd03d
Revert visibility signaling
gnodar01 May 16, 2025
e5980fe
Set lut view state to keep visibility and display info
gnodar01 May 16, 2025
9f5bec0
Set visible off when not displayed
gnodar01 May 16, 2025
c59ce25
Show display options only after thresh
gnodar01 May 16, 2025
26e186f
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 16, 2025
cb89ae7
Fix: no built-in for signal introspection
gnodar01 May 16, 2025
857aee6
Add initial display option tests
gnodar01 May 19, 2025
f33711c
Merge branch 'main' into wx-many-channel
gnodar01 May 19, 2025
97601f2
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 19, 2025
6796096
Fix merge conflict remnants
gnodar01 May 19, 2025
4aacff0
Remvoe unused method
gnodar01 May 19, 2025
f3d8360
Put channel removal under test, and fix 0 check bug
gnodar01 May 19, 2025
d375414
Test channel selection popup
gnodar01 May 19, 2025
66182ce
Test select [all|none]
gnodar01 May 19, 2025
774c7c7
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 270 additions & 11 deletions src/ndv/views/_wx/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,165 @@
btn.SetBitmapLabel(bitmap)


class _LutChannelSelector(wx.Panel):
# actually set[ChannelKey] | set
selectionChanged = Signal(set)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this signal actually necessary in practice? I tried commenting out wdg.lut_selector.selectionChanged.connect(lambda: self._wxwidget.Layout()), and I couldn't notice the effect...


def __init__(self, parent: wx.Window, channels: None | list[ChannelKey] = None):
super().__init__(parent)

self.channels: list[ChannelKey] = channels or []
# all channels displayed, by default
self.displayed_channels: set[ChannelKey] = set(self.channels)

# Dropdown button with currenct selection
self.dropdown_btn = wx.Button(
self, label="Channel Display Options", style=wx.BU_EXACTFIT
)
self.dropdown_btn.Bind(wx.EVT_BUTTON, self._on_dropdown_clicked)

# Display indicator for how many channels are currently displayed
self.selection_info = wx.StaticText(self, label="")
self._update_selection_info()

# Create a popup window for the checklist
self.popup = wx.Dialog(
self,
style=wx.BORDER_SIMPLE
| wx.STAY_ON_TOP
| wx.FRAME_NO_TASKBAR
| wx.FRAME_FLOAT_ON_PARENT,
)
self.popup.SetBackgroundColour(
wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
)
# evt handler for clicking outside of popup dialog
self.popup.Bind(wx.EVT_ACTIVATE, self._on_popup_deactivate)

# Create a checklist for the popup
self.checklist = wx.CheckListBox(self.popup, choices=[])
self.checklist.Bind(wx.EVT_CHECKLISTBOX, self._on_selection_changed)

# Select All / None buttons
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.select_all_btn = wx.Button(self.popup, label="Select All", size=(80, -1))
self.select_none_btn = wx.Button(self.popup, label="Select None", size=(80, -1))
self.select_all_btn.Bind(wx.EVT_BUTTON, self._on_select_all)
self.select_none_btn.Bind(wx.EVT_BUTTON, self._on_select_none)
btn_sizer.Add(self.select_all_btn, 0, wx.ALL, 5)
btn_sizer.Add(self.select_none_btn, 0, wx.ALL, 5)

# Layout for popup
popup_sizer = wx.BoxSizer(wx.VERTICAL)
popup_sizer.Add(self.checklist, 1, wx.EXPAND | wx.ALL, 5)
popup_sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 5)
self.popup.SetSizer(popup_sizer)

# Layout for the main widget
sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer.Add(self.dropdown_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
sizer.Add(self.selection_info, 0, wx.ALIGN_CENTER_VERTICAL)
self.SetSizer(sizer)

def set_displayed_channels(self, displayed_channels: list[ChannelKey]):
self.displayed_channels = set(displayed_channels)
self._update_checklist()
self._update_selection_info()

Check warning on line 134 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L132-L134

Added lines #L132 - L134 were not covered by tests

def add_channel(self, channel: ChannelKey):
if (type(channel) is int) or (type(channel) is str and channel.isdigit()):
if channel not in self.channels:
self.channels.append(channel)
self.displayed_channels.add(channel)
self._update_checklist()
self._update_selection_info()

Check warning on line 142 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L138-L142

Added lines #L138 - L142 were not covered by tests

def remove_channel(self, channel: ChannelKey):
self.channels = [ch for ch in self.channels if not ch == channel]
self.displayed_channels = {

Check warning on line 146 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L145-L146

Added lines #L145 - L146 were not covered by tests
ch for ch in self.displayed_channels if not ch == channel
}
self._update_checklist()
self._update_selection_info()

Check warning on line 150 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L149-L150

Added lines #L149 - L150 were not covered by tests

def _update_checklist(self):
"""Update the checklist with current channels."""
self.checklist.Clear()
for channel in self.channels:
self.checklist.Append(str(channel))
idx = self.checklist.FindString(str(channel))
if channel in self.displayed_channels:
self.checklist.Check(idx)

Check warning on line 159 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L154-L159

Added lines #L154 - L159 were not covered by tests

def _update_selection_info(self):
"""Update the selection info text."""
if len(self.displayed_channels) == len(self.channels):
self.selection_info.SetLabel(
f"All channels displayed ({len(self.channels)})"
)
else:
self.selection_info.SetLabel(

Check warning on line 168 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L168

Added line #L168 was not covered by tests
f"{len(self.displayed_channels)} of {len(self.channels)} channels displayed"
)
self.Layout()

def _on_dropdown_clicked(self, evt):
"""Show the dropdown checklist."""
# Position the popup below the button
btn_size = self.dropdown_btn.GetSize()
btn_pos = self.dropdown_btn.GetPosition()
popup_pos = self.ClientToScreen(

Check warning on line 178 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L176-L178

Added lines #L176 - L178 were not covered by tests
wx.Point(btn_pos.x, btn_pos.y + btn_size.height)
)

# Update the checklist
self._update_checklist()

Check warning on line 183 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L183

Added line #L183 was not covered by tests

# Size the popup according to content
self.popup.SetSize(wx.Size(250, min(400, 50 + len(self.channels) * 22)))
self.checklist.SetSize(self.popup.GetClientSize())
self.popup.Layout()

Check warning on line 188 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L186-L188

Added lines #L186 - L188 were not covered by tests

# Show the popup
self.popup.SetPosition(popup_pos)
self.popup.Show(show=True)

Check warning on line 192 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L191-L192

Added lines #L191 - L192 were not covered by tests

def _on_popup_deactivate(self, evt):
"""Close the popup when it loses focus (user clicks outside)."""
if not evt.GetActive():
self.popup.Show(show=False)

Check warning on line 197 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L196-L197

Added lines #L196 - L197 were not covered by tests

def _on_selection_changed(self, evt):
"""Handle selection changes in the checklist."""
idx = evt.GetSelection()
channel = self.channels[idx]

Check warning on line 202 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L201-L202

Added lines #L201 - L202 were not covered by tests

if self.checklist.IsChecked(idx):
self.displayed_channels.add(channel)

Check warning on line 205 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L204-L205

Added lines #L204 - L205 were not covered by tests
else:
self.displayed_channels.discard(channel)

Check warning on line 207 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L207

Added line #L207 was not covered by tests

self._update_selection_info()

Check warning on line 209 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L209

Added line #L209 was not covered by tests

self.selectionChanged.emit(self.displayed_channels)

Check warning on line 211 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L211

Added line #L211 was not covered by tests

def _on_select_all(self, evt):
"""Select all channels."""
for i in range(self.checklist.GetCount()):
self.checklist.Check(i, True)
self.displayed_channels = set(self.channels)
self._update_selection_info()
self.selectionChanged.emit(self.displayed_channels)

Check warning on line 219 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L215-L219

Added lines #L215 - L219 were not covered by tests

def _on_select_none(self, evt):
"""Deselect all channels."""
for i in range(self.checklist.GetCount()):
self.checklist.Check(i, False)
self.displayed_channels = set()
self._update_selection_info()
self.selectionChanged.emit(self.displayed_channels)

Check warning on line 227 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L223-L227

Added lines #L223 - L227 were not covered by tests


# mostly copied from _qt.qt_view._QLUTWidget
class _WxLUTWidget(wx.Panel):
def __init__(self, parent: wx.Window) -> None:
Expand Down Expand Up @@ -159,6 +318,8 @@
class WxLutView(LutView):
# NB: In practice this will be a ChannelKey but Unions not allowed here.
histogramRequested = psygnal.Signal(object)
# channel: ChannelKey, visible: bool|None, validate: bool|None
lutUpdated = Signal(object, bool, bool)

def __init__(self, parent: wx.Window, channel: ChannelKey = None) -> None:
super().__init__()
Expand Down Expand Up @@ -308,14 +469,17 @@
def set_channel_visible(self, visible: bool) -> None:
self._wxwidget.visible.SetValue(visible)

def set_visible(self, visible: bool) -> None:
if visible:
self._wxwidget.Show()
else:
self._wxwidget.Hide()
def set_visible(self, visible: bool, validate: bool = True) -> None:
"""Sets visibility.

`validate` will check the display options (`_LUTChannelSelctor`)
to ensure that visibility is toggled off there.
"""
self.lutUpdated.emit(self._channel, visible, validate)

Check warning on line 478 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L478

Added line #L478 was not covered by tests

def close(self) -> None:
self._wxwidget.Close()
self.lutUpdated.emit(self._channel)

Check warning on line 482 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L482

Added line #L482 was not covered by tests


class WxRGBView(WxLutView):
Expand Down Expand Up @@ -445,8 +609,21 @@
self.add_roi_btn = wx.ToggleButton(self, label="ROI", size=(40, -1))
_add_icon(self.add_roi_btn, "mdi:vector-rectangle")

# Add LUT channel selector
self.lut_selector = _LutChannelSelector(self)

# Toolbar for LUT management
self.lut_toolbar = wx.BoxSizer(wx.HORIZONTAL)
self.lut_toolbar.Add(self.lut_selector, 0, wx.EXPAND | wx.ALL, 5)
self.lut_toolbar.AddStretchSpacer()

# Create a scrolled panel for LUTs
self.luts_scroll = wx.ScrolledWindow(self, style=wx.VSCROLL)
self.luts_scroll.SetScrollRate(0, 10)

# LUT layout (simple vertical grouping for LUT widgets)
self.luts = wx.BoxSizer(wx.VERTICAL)
self.luts_scroll.SetSizer(self.luts)

self._btns = wx.BoxSizer(wx.HORIZONTAL)
self._btns.AddStretchSpacer()
Expand All @@ -465,13 +642,27 @@
inner.Add(self._canvas, 1, wx.EXPAND | wx.ALL)
inner.Add(self._hover_info_label, 0, wx.EXPAND | wx.BOTTOM)
inner.Add(self.dims_sliders, 0, wx.EXPAND | wx.BOTTOM)
inner.Add(self.luts, 0, wx.EXPAND)
inner.Add(self.lut_toolbar, 0, wx.EXPAND | wx.BOTTOM, 5)
inner.Add(self.luts_scroll, 0, wx.EXPAND | wx.BOTTOM, 5)
inner.Add(self._btns, 0, wx.EXPAND)
self._inner_sizer = inner

outer = wx.BoxSizer(wx.VERTICAL)
outer.Add(inner, 1, wx.EXPAND | wx.ALL, 10)
self.SetSizer(outer)
self.SetInitialSize(wx.Size(600, 800))

self.update_lut_scroll_size()
self.Layout()

def update_lut_scroll_size(self):
self.luts_scroll.Layout()
total_size = self.luts.GetMinSize()
total_height = total_size.GetHeight()
new_height = max(30, min(total_height, 200))
self._inner_sizer.SetItemMinSize(self.luts_scroll, -1, new_height)
self.luts_scroll.SetVirtualSize(total_size)
self.luts_scroll.FitInside()
self.Layout()


Expand All @@ -497,6 +688,8 @@
wdg.ndims_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_ndims_toggled)
wdg.add_roi_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_add_roi_toggled)

wdg.lut_selector.selectionChanged.connect(self._on_lut_selection_changed)

def _on_channel_mode_changed(self, event: wx.CommandEvent) -> None:
mode = self._wxwidget.channel_mode_combo.GetValue()
self.channelModeChanged.emit(mode)
Expand Down Expand Up @@ -528,6 +721,20 @@
InteractionMode.CREATE_ROI if create_roi else InteractionMode.PAN_ZOOM
)

def _on_lut_selection_changed(self, displayed_channels: list[ChannelKey]):
"""Handle changes in displayed LUT selection."""
self._wxwidget.lut_selector.set_displayed_channels(displayed_channels)
for channel, lut_view in self._luts.items():

Check warning on line 727 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L726-L727

Added lines #L726 - L727 were not covered by tests
# Show/Hide the LUT view based on selection
# don't validate since we just set the displayed channels
if channel in displayed_channels:
lut_view.set_visible(True, validate=False)

Check warning on line 731 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L730-L731

Added lines #L730 - L731 were not covered by tests
else:
lut_view.set_visible(False, validate=False)

Check warning on line 733 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L733

Added line #L733 was not covered by tests

# Update layout
self._wxwidget.Layout()

Check warning on line 736 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L736

Added line #L736 was not covered by tests

def visible_axes(self) -> Sequence[AxisKey]:
return self._visible_axes # no widget to control this yet

Expand All @@ -538,19 +745,53 @@
def frontend_widget(self) -> wx.Window:
return self._wxwidget

def _lut_area(self) -> wx.Window:
return self._wxwidget.luts_scroll

def _lut_selector(self) -> _LutChannelSelector:
return self._wxwidget.lut_selector

def add_lut_view(self, channel: ChannelKey) -> WxLutView:
wdg = self.frontend_widget()
view = WxRGBView(wdg, channel) if channel == "RGB" else WxLutView(wdg, channel)
scrollwdg = self._lut_area()
view = (
WxRGBView(scrollwdg, channel)
if channel == "RGB"
else WxLutView(scrollwdg, channel)
)
self._wxwidget.luts.Add(view._wxwidget, 0, wx.EXPAND | wx.BOTTOM, 5)
self._luts[channel] = view
# TODO: Reusable synchronization with ViewerModel
view._wxwidget.histogram_btn.Show(self._viewer_model.show_histogram_button)
view.histogramRequested.connect(self.histogramRequested)
view.lutUpdated.connect(self._update_lut_view)

self._lut_selector().add_channel(channel)
self._wxwidget.update_lut_scroll_size()

# Update the layout to reflect above changes
self._wxwidget.Layout()
return view

def _update_lut_view(
self,
channel: ChannelKey,
visible: None | bool = None,
validate: None | bool = None,
):
# NOTE: we validate on `channel_mode` change
# and only when changed to `COMPOSITE`
# so this working properly depends on
# `channel_mode_combo`'s value being changed FIRST
# before each lut_view's `set_visible` is called,
# which is currently the case
mode = self._wxwidget.channel_mode_combo.GetValue()
if validate and mode == ChannelMode.COMPOSITE.value:
displayed_channels = self._wxwidget.lut_selector.displayed_channels
visible = visible and channel in displayed_channels
if visible:
self._luts[channel]._wxwidget.Show()
elif visible is not None:
self._luts[channel]._wxwidget.Hide()
self._wxwidget.update_lut_scroll_size()

Check warning on line 793 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L785-L793

Added lines #L785 - L793 were not covered by tests

# TODO: Fix type
def add_histogram(self, channel: ChannelKey, canvas: HistogramCanvas) -> None:
if lut := self._luts.get(channel, None):
Expand All @@ -559,10 +800,24 @@
self._wxwidget.Layout()

def remove_lut_view(self, lut: LutView) -> None:
# Find the channel key for this LUT view
channel_to_remove = None
for channel, view in self._luts.items():
if view == lut:
channel_to_remove = channel
break

Check warning on line 808 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L804-L808

Added lines #L804 - L808 were not covered by tests

wxwdg = cast("_WxLUTWidget", lut.frontend_widget())
self._wxwidget.luts.Detach(wxwdg)
wxwdg.Destroy()
self._wxwidget.Layout()

# Remove from our dictionaries
if channel_to_remove:
del self._luts[channel_to_remove]

Check warning on line 816 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L815-L816

Added lines #L815 - L816 were not covered by tests

self._lut_selector().add_channel(channel)

Check warning on line 818 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L818

Added line #L818 was not covered by tests

self._wxwidget.update_lut_scroll_size()

Check warning on line 820 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L820

Added line #L820 was not covered by tests

def create_sliders(self, coords: Mapping[Hashable, Sequence]) -> None:
self._wxwidget.dims_sliders.create_sliders(coords)
Expand All @@ -588,6 +843,10 @@

def set_channel_mode(self, mode: ChannelMode) -> None:
self._wxwidget.channel_mode_combo.SetValue(mode)
if mode == ChannelMode.COMPOSITE:
self._wxwidget.lut_selector.Show()

Check warning on line 847 in src/ndv/views/_wx/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_wx/_array_view.py#L847

Added line #L847 was not covered by tests
else:
self._wxwidget.lut_selector.Hide()

def set_visible(self, visible: bool) -> None:
if visible:
Expand Down
Loading