-
Notifications
You must be signed in to change notification settings - Fork 65
Expand file tree
/
Copy path_base.py
More file actions
637 lines (507 loc) · 23 KB
/
Copy path_base.py
File metadata and controls
637 lines (507 loc) · 23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
from __future__ import annotations
import inspect
from collections.abc import Callable
from functools import partial
from typing import Literal
from imgui_bundle import imgui
from ..layouts._rect import RectManager
# edges that reserve space, ordered as they are carved from the render area
EDGES = ["left", "right", "top", "bottom"]
# all valid keyed locations, "toolbar" is subplot only, "floating" uses auto-placement
LOCATIONS = EDGES + ["toolbar", "floating"]
def _wrap_update_call(func: Callable, parent) -> Callable:
"""
Wrap an imgui draw function for use as a window or popup update call. The parent, a ``Figure``, ``Subplot`` or
``Graphic``, is passed as the only positional arg if the function accepts one, otherwise the function is called
with no args.
"""
params = inspect.signature(func).parameters.values()
takes_arg = any(
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL)
for p in params
)
if takes_arg:
return partial(func, parent)
return func
class ImguiBase:
"""
Base class for all ImGUI based GUIs, windows and popups
The main purpose of this base is for setting a unique ID between multiple figs with identical UI elements
This ID can be pushed in subclasses within the `update()` method
"""
ID_COUNTER: int = 0
def __init__(self):
ImguiBase.ID_COUNTER += 1
self._id_counter = ImguiBase.ID_COUNTER
def draw(self):
"""must be implemented in subclass"""
raise NotImplementedError
class ImguiWindow(ImguiBase):
def __init__(self, update_call: Callable = None):
"""
An imgui window drawn within a Figure. Subclass and implement ``update()`` to draw imgui elements, or pass a
callable as ``update_call`` (this is what the ``add_imgui_window()`` decorator does).
Windows are not added directly, use ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` which
provide the host and placement, i.e. location, size, window flags, etc., via ``_fpl_add_hook()``.
Parameters
----------
update_call: callable
a callable that draws imgui elements, used instead of ``update()`` when decorating, see ``add_imgui_window``
"""
super().__init__()
# imgui element draw calls, run in order within the window on each render
if update_call is None:
self._update_calls = [self.update]
else:
self._update_calls = [update_call]
# host and placement, set by the host in add_imgui_window() via _fpl_add_hook()
self._figure = None
self._subplot = None
self._location = None
self._size = None
self._rect_manager = None
self._floating = False
self._title = None
self._window_flags = (
imgui.WindowFlags_.no_collapse
| imgui.WindowFlags_.no_resize
| imgui.WindowFlags_.no_title_bar
)
# pixel rect, set by the host on each layout pass
self._x, self._y, self._width, self._height = 0, 0, 0, 0
# resize and collapse state, only used by figure-level resizeable edge windows
self._resize_cursor_set = False
self._resize_blocked = False
self._right_gui_resizing = False
self._separator_thickness = 14.0
self._collapsed = False
self._old_size = None
def _fpl_add_hook(
self,
figure,
subplot=None,
location: Literal["left", "right", "top", "bottom", "toolbar", "floating"] = None,
size: int = None,
rect: tuple = None,
extent: tuple = None,
title: str = None,
window_flags: imgui.WindowFlags_ = None,
):
"""
Set the host and placement of this window, called by ``Figure.add_imgui_window()`` or
``Subplot.add_imgui_window()``.
Parameters
----------
figure: ImguiFigure
the figure this window is drawn in
subplot: Subplot, optional
the subplot this window is confined to, ``None`` for figure-level windows
location: str, "left" | "right" | "top" | "bottom" | "toolbar" | "floating"
edge and toolbar windows reserve canvas space, "floating" is auto-sized and draggable
size: int
edge or toolbar thickness in pixels
rect: (x, y, w, h), optional
fractional or pixel rect for a fixed floating window
extent: (xmin, xmax, ymin, ymax), optional
fractional or pixel extent for a fixed floating window
title: str, optional
window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn.
window_flags: ``imgui.WindowFlags_``
window flag enum, can be combined with the ``|`` operator. If not provided, the default depends on the
placement: edge and toolbar windows use ``no_collapse | no_resize | no_title_bar |
no_bring_to_front_on_focus`` (custom title bar, and they stay behind floating and fixed overlays);
floating windows use ``none`` (native imgui title bar, collapsible and movable); fixed rect/extent
windows use ``no_collapse | no_move | no_resize`` (native imgui title bar). Valid flags are:
.. code-block:: py
imgui.WindowFlags_.no_title_bar
imgui.WindowFlags_.no_resize
imgui.WindowFlags_.no_move
imgui.WindowFlags_.no_scrollbar
imgui.WindowFlags_.no_scroll_with_mouse
imgui.WindowFlags_.no_collapse
imgui.WindowFlags_.always_auto_resize
imgui.WindowFlags_.no_background
imgui.WindowFlags_.no_saved_settings
imgui.WindowFlags_.no_mouse_inputs
imgui.WindowFlags_.menu_bar
imgui.WindowFlags_.horizontal_scrollbar
imgui.WindowFlags_.no_focus_on_appearing
imgui.WindowFlags_.no_bring_to_front_on_focus
imgui.WindowFlags_.always_vertical_scrollbar
imgui.WindowFlags_.always_horizontal_scrollbar
imgui.WindowFlags_.no_nav_inputs
imgui.WindowFlags_.no_nav_focus
imgui.WindowFlags_.unsaved_document
imgui.WindowFlags_.no_docking
imgui.WindowFlags_.no_nav
imgui.WindowFlags_.no_decoration
imgui.WindowFlags_.no_inputs
"""
self._figure = figure
self._subplot = subplot
self._location = location
self._size = int(size) if size is not None else None
self._title = title
self._floating = location == "floating"
if rect is not None:
width, height = figure.canvas.get_logical_size()
self._rect_manager = RectManager(*rect, (0, 0, width, height))
elif extent is not None:
width, height = figure.canvas.get_logical_size()
self._rect_manager = RectManager.from_extent(extent, (0, 0, width, height))
if window_flags is None:
# edge and toolbar windows draw their own title bar; floating and fixed windows use the native
# imgui title bar so they can be collapsed, and floating windows can also be moved
if location in EDGES or location == "toolbar":
# reserved windows never come to front on focus, otherwise clicking one would bury a
# floating or fixed overlay drawn over it and make the overlay inaccessible
window_flags = (
imgui.WindowFlags_.no_collapse
| imgui.WindowFlags_.no_resize
| imgui.WindowFlags_.no_title_bar
| imgui.WindowFlags_.no_bring_to_front_on_focus
)
elif location == "floating":
window_flags = imgui.WindowFlags_.none
else:
# fixed rect or extent window
window_flags = (
imgui.WindowFlags_.no_collapse
| imgui.WindowFlags_.no_move
| imgui.WindowFlags_.no_resize
)
self._window_flags = window_flags
@property
def location(self) -> str:
"""location of the window"""
return self._location
@property
def size(self) -> int | None:
"""edge or toolbar thickness in pixels, ``None`` for floating and fractional windows"""
return self._size
@size.setter
def size(self, value: int):
if not isinstance(value, int):
raise TypeError(f"{self.__class__.__name__}.size must be an <int>")
self._size = value
# reserving windows change the layout when resized
if self._reserves and self._figure is not None:
self._figure._fpl_reset_layout()
@property
def window_flags(self) -> imgui.WindowFlags_:
"""imgui window flags"""
return self._window_flags
@window_flags.setter
def window_flags(self, flags: imgui.WindowFlags_):
self._window_flags = flags
@property
def x(self) -> int:
"""canvas x position of the window"""
return self._x
@property
def y(self) -> int:
"""canvas y position of the window"""
return self._y
@property
def width(self) -> int:
"""width of the window"""
return self._width
@property
def height(self) -> int:
"""height of the window"""
return self._height
@property
def _reserves(self) -> bool:
"""whether this window reserves canvas space, i.e. edge or toolbar windows"""
return self._location in EDGES or self._location == "toolbar"
def _fpl_set_rect(self, x: int, y: int, width: int, height: int):
"""set the pixel rect, called by the host on each layout pass"""
self._x, self._y, self._width, self._height = x, y, width, height
def _draw_resize_handle(self):
if self._location not in ("bottom", "right"):
return
if self._location == "bottom":
imgui.set_cursor_pos((0, 0))
imgui.invisible_button("##resize_handle", imgui.ImVec2(imgui.get_window_width(), self._separator_thickness))
hovered = imgui.is_item_hovered()
active = imgui.is_item_active()
# Get the actual screen rect of the button after it's been laid out
rect_min = imgui.get_item_rect_min()
rect_max = imgui.get_item_rect_max()
elif self._location == "right":
imgui.set_cursor_pos((0, 0))
screen_pos = imgui.get_cursor_screen_pos()
win_height = imgui.get_window_height()
mouse_pos = imgui.get_mouse_pos()
rect_min = imgui.ImVec2(screen_pos.x, screen_pos.y)
rect_max = imgui.ImVec2(screen_pos.x + self._separator_thickness, screen_pos.y + win_height)
hovered = (
rect_min.x <= mouse_pos.x <= rect_max.x
and rect_min.y <= mouse_pos.y <= rect_max.y
)
if hovered and imgui.is_mouse_clicked(0):
self._right_gui_resizing = True
if not imgui.is_mouse_down(0):
self._right_gui_resizing = False
active = self._right_gui_resizing
imgui.set_cursor_pos((self._separator_thickness, 0))
if hovered and imgui.is_mouse_double_clicked(0):
if not self._collapsed:
self._old_size = self.size
if self._location == "bottom":
self.size = int(self._separator_thickness)
elif self._location == "right":
self.size = int(self._separator_thickness)
self._collapsed = True
else:
self.size = self._old_size
self._collapsed = False
if hovered or active:
if not self._resize_cursor_set:
if self._location == "bottom":
self._figure.canvas.set_cursor("ns_resize")
elif self._location == "right":
self._figure.canvas.set_cursor("ew_resize")
self._resize_cursor_set = True
imgui.set_tooltip("Drag to resize, double click to expand/collapse")
elif self._resize_cursor_set:
self._figure.canvas.set_cursor("default")
self._resize_cursor_set = False
if active and imgui.is_mouse_dragging(0):
if self._location == "bottom":
delta = imgui.get_mouse_drag_delta(0).y
elif self._location == "right":
delta = imgui.get_mouse_drag_delta(0).x
imgui.reset_mouse_drag_delta(0)
px, py, pw, ph = self._figure.get_pygfx_render_area()
if self._location == "bottom":
new_render_size = ph + delta
elif self._location == "right":
new_render_size = pw + delta
# check if the new size would make the pygfx render area too small
if (delta < 0) and (new_render_size < 150):
print("not enough render area")
self._resize_blocked = True
if self._resize_blocked:
# check if cursor has returned
if self._location == "bottom":
_min, pos, _max = rect_min.y, imgui.get_mouse_pos().y, rect_max.y
elif self._location == "right":
_min, pos, _max = rect_min.x, imgui.get_mouse_pos().x, rect_max.x
if ((_min - 5) <= pos <= (_max + 5)) and delta > 0:
# if the mouse cursor is back on the bar and the delta > 0, i.e. render area increasing
self._resize_blocked = False
if not self._resize_blocked:
self.size = max(30, round(self.size - delta))
self._collapsed = False
draw_list = imgui.get_window_draw_list()
line_color = (
imgui.get_color_u32(imgui.ImVec4(0.9, 0.9, 0.9, 1.0))
if (hovered or active)
else imgui.get_color_u32(imgui.ImVec4(0.5, 0.5, 0.5, 0.8))
)
bg_color = (
imgui.get_color_u32(imgui.ImVec4(0.2, 0.2, 0.2, 0.8))
if (hovered or active)
else imgui.get_color_u32(imgui.ImVec4(0.15, 0.15, 0.15, 0.6))
)
# Background bar
draw_list.add_rect_filled(
imgui.ImVec2(rect_min.x, rect_min.y),
imgui.ImVec2(rect_max.x, rect_max.y),
bg_color,
)
# Three grip dots centered on the line
dot_spacing = 7.0
dot_radius = 2
if self._location == "bottom":
mid_y = (rect_min.y + rect_max.y) * 0.5
center_x = (rect_min.x + rect_max.x) * 0.5
for i in (-1, 0, 1):
cx = center_x + i * dot_spacing
draw_list.add_circle_filled(imgui.ImVec2(cx, mid_y), dot_radius, line_color)
imgui.set_cursor_pos((0, imgui.get_cursor_pos_y() - imgui.get_style().item_spacing.y))
elif self._location == "right":
mid_x = (rect_min.x + rect_max.x) * 0.5
center_y = (rect_min.y + rect_max.y) * 0.5
for i in (-1, 0, 1):
cy = center_y + i * dot_spacing
draw_list.add_circle_filled(
imgui.ImVec2(mid_x, cy), dot_radius, line_color
)
def _draw_title(self, title: str):
padding = imgui.ImVec2(10, 4)
text_size = imgui.calc_text_size(title)
win_width = imgui.get_window_width()
box_size = imgui.ImVec2(win_width, text_size.y + padding.y * 2)
box_screen_pos = imgui.get_cursor_screen_pos()
draw_list = imgui.get_window_draw_list()
# Background — use imgui's default title bar color
draw_list.add_rect_filled(
imgui.ImVec2(box_screen_pos.x, box_screen_pos.y),
imgui.ImVec2(box_screen_pos.x + box_size.x, box_screen_pos.y + box_size.y),
imgui.get_color_u32(imgui.Col_.title_bg_active),
)
# Centered text
text_pos = imgui.ImVec2(
box_screen_pos.x + (win_width - text_size.x) * 0.5,
box_screen_pos.y + padding.y,
)
draw_list.add_text(
text_pos, imgui.get_color_u32(imgui.ImVec4(1, 1, 1, 1)), title
)
imgui.dummy(imgui.ImVec2(win_width, box_size.y))
def draw(self):
"""helps simplify using imgui by managing window creation & position, and pushing/popping the ID"""
# window position & size
if self._floating:
# floating windows are auto-sized by imgui, only set the initial position
imgui.set_next_window_pos((self.x, self.y), imgui.Cond_.appearing)
else:
imgui.set_next_window_size((self.width, self.height))
imgui.set_next_window_pos((self.x, self.y))
# append the id to keep the window unique without changing the visible title
expanded = imgui.begin(f"{self._title or ''}##{self._id_counter}", p_open=None, flags=self._window_flags)
if self._reserves:
# edge and toolbar windows draw a custom title bar and collapse via the resize handle
# resize handle for right and bottom edge windows on the figure
if self._subplot is None and self._location in ("bottom", "right"):
self._draw_resize_handle()
# push ID to prevent conflict between multiple figs with same UI
imgui.push_id(self._id_counter)
# collapse the UI if the separator state is collapsed
# otherwise the UI renders partially on the separator for "right" guis and it looks weird
main_height = 1.0 if self._collapsed else 0.0
imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height))
if self._title is not None:
self._draw_title(self._title)
imgui.indent(6.0)
# draw imgui elements from the subclass or decorated function(s)
for update_call in self._update_calls:
update_call()
imgui.end_child()
imgui.pop_id()
elif expanded:
# floating and fixed windows use the native imgui title bar; only draw when not collapsed
imgui.push_id(self._id_counter)
for update_call in self._update_calls:
update_call()
imgui.pop_id()
# end the window
imgui.end()
def update(self):
"""Implement your GUI here and it will be drawn within the window. See the GUI examples"""
raise NotImplementedError
class ImguiPopup(ImguiBase):
def __init__(self, update_call: Callable = None):
"""
An imgui popup drawn within a Figure, opened by a right-click. Subclass and implement ``update()`` to draw
imgui elements, or pass a callable as ``update_call``.
Popups are not added directly, use ``ImguiFigure.set_imgui_right_click()``,
``Subplot.set_imgui_right_click()`` or ``Graphic.set_imgui_right_click()`` which provide the parent and
window flags via ``_fpl_add_hook()``.
Parameters
----------
update_call: callable
a callable that draws imgui elements, used instead of ``update()``, see ``set_imgui_right_click``
"""
super().__init__()
if update_call is None:
self._update_calls = [self.update]
else:
self._update_calls = [update_call]
# parent, set by the parent in set_imgui_right_click() via _fpl_add_hook()
self._figure = None
self._parent = None
self._window_flags = imgui.WindowFlags_.none
# popups are identified by a str id, the counter keeps it unique between popups
self._popup_id = f"popup##{self._id_counter}"
# what this popup was opened on, set by the right-click dispatch in Subplot
self._subplot = None
self._graphic = None
self._open_requested = False
self._pos = None
self._is_open = False
def _fpl_add_hook(
self,
figure,
parent,
window_flags: imgui.WindowFlags_ = None,
):
"""
Set the parent of this popup, called by ``set_imgui_right_click()``.
Parameters
----------
figure: ImguiFigure
the figure this popup is drawn in
parent: ImguiFigure | Subplot | Graphic
the object this popup is set on
window_flags: ``imgui.WindowFlags_``
window flag enum, can be combined with the ``|`` operator, see ``ImguiWindow._fpl_add_hook`` for the
valid flags
"""
self._figure = figure
self._parent = parent
if window_flags is not None:
self._window_flags = window_flags
@property
def parent(self):
"""the object this popup is set on, an ``ImguiFigure``, ``Subplot`` or ``Graphic``"""
return self._parent
@property
def subplot(self):
"""the subplot this popup was opened in"""
return self._subplot
@property
def graphic(self):
"""the graphic this popup was opened on, ``None`` if it was not opened on a graphic"""
return self._graphic
@property
def is_open(self) -> bool:
"""whether the popup is currently open"""
return self._is_open
@property
def window_flags(self) -> imgui.WindowFlags_:
"""imgui window flags"""
return self._window_flags
@window_flags.setter
def window_flags(self, flags: imgui.WindowFlags_):
self._window_flags = flags
def open(self, pos: tuple[int, int] = None):
"""
Request that this popup is opened on the next render.
Parameters
----------
pos: (int, int), optional
canvas position of the popup, imgui uses the current mouse position if not provided
"""
self._pos = pos
self._open_requested = True
def _fpl_open(self, subplot, graphic):
"""set what the popup is opened on and open it, called by the right-click dispatch in ``Subplot``"""
self._subplot = subplot
self._graphic = graphic
self.open()
def _fpl_close(self):
"""called when another popup replaces this one as the open popup"""
self._is_open = False
def draw(self):
"""helps simplify using imgui by managing the popup open state, and pushing/popping the ID"""
if self._open_requested:
self._open_requested = False
if self._pos is not None:
imgui.set_next_window_pos(self._pos)
imgui.open_popup(self._popup_id)
if imgui.begin_popup(self._popup_id, self._window_flags):
self._is_open = True
# push ID to prevent conflict between multiple figs with same UI
imgui.push_id(self._id_counter)
for update_call in self._update_calls:
update_call()
imgui.pop_id()
imgui.end_popup()
else:
self._is_open = False
def update(self):
"""Implement your GUI here and it will be drawn within the popup. See the GUI examples"""
raise NotImplementedError