imgui_data_loader 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ """imgui_data_loader — a themed, configurable imgui file/folder open dialog.
2
+
3
+ Quick start
4
+ -----------
5
+ >>> from imgui_data_loader import run_file_dialog, FileDialogConfig, FileType
6
+ >>> result = run_file_dialog(FileDialogConfig(
7
+ ... title="My App",
8
+ ... filetypes=[FileType("Images", "*.tif *.png"), FileType("All Files", "*")],
9
+ ... ))
10
+ >>> if result:
11
+ ... print(result.paths)
12
+
13
+ Embed it in your own imgui/hello_imgui app instead:
14
+ >>> from imgui_data_loader import FileDialog, FileDialogConfig
15
+ >>> dlg = FileDialog(FileDialogConfig(close_on_select=False))
16
+ >>> # params.callbacks.show_gui = dlg.render
17
+ >>> # each frame: r = dlg.take_result(); if r: ...
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._assets import ensure_assets
23
+ from .config import (
24
+ ButtonSpec,
25
+ DialogResult,
26
+ FileDialogConfig,
27
+ FileType,
28
+ PickKind,
29
+ flatten_filters,
30
+ )
31
+ from .dialog import FileDialog
32
+ from .persistence import JsonPreferenceStore, PreferenceStore
33
+ from .runner import run_file_dialog
34
+ from .theme import Theme, to_vec4
35
+ from .widgets import (
36
+ call_draw,
37
+ center_next_item,
38
+ center_text,
39
+ icon_button,
40
+ pop_button_style,
41
+ push_button_style,
42
+ text_wrapped_colored,
43
+ wrapped_tooltip,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ # config
50
+ "FileDialogConfig",
51
+ "FileType",
52
+ "ButtonSpec",
53
+ "PickKind",
54
+ "DialogResult",
55
+ "flatten_filters",
56
+ # dialog + harness
57
+ "FileDialog",
58
+ "run_file_dialog",
59
+ "ensure_assets",
60
+ # theme
61
+ "Theme",
62
+ "to_vec4",
63
+ # persistence
64
+ "PreferenceStore",
65
+ "JsonPreferenceStore",
66
+ # reusable widgets
67
+ "icon_button",
68
+ "wrapped_tooltip",
69
+ "text_wrapped_colored",
70
+ "center_text",
71
+ "center_next_item",
72
+ "push_button_style",
73
+ "pop_button_style",
74
+ "call_draw",
75
+ # meta
76
+ "__version__",
77
+ ]
@@ -0,0 +1,53 @@
1
+ """Asset / config-path helpers.
2
+
3
+ The dialog relies on the FontAwesome 6 font so button icons render. That font
4
+ ships inside ``imgui-bundle`` and hello_imgui loads it into the default font
5
+ when the assets folder points at a directory containing
6
+ ``fonts/Font_Awesome_6_Free-Solid-900.otf``. :func:`ensure_assets` points
7
+ hello_imgui at imgui-bundle's own bundled assets, so the library ships no fonts
8
+ of its own.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ def imgui_bundle_assets_dir() -> Optional[str]:
19
+ """Path to imgui-bundle's bundled ``assets`` folder, or None."""
20
+ try:
21
+ import imgui_bundle
22
+
23
+ p = Path(imgui_bundle.__file__).parent / "assets"
24
+ return str(p) if p.is_dir() else None
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def ensure_assets(assets_folder: Optional[str] = None) -> None:
30
+ """Point hello_imgui at an assets folder that provides the icon font.
31
+
32
+ Defaults to imgui-bundle's bundled assets. Pass ``assets_folder`` to use
33
+ your own (it must contain ``fonts/Font_Awesome_6_Free-Solid-900.otf`` for
34
+ the icons to show).
35
+ """
36
+ from imgui_bundle import hello_imgui
37
+
38
+ folder = assets_folder or imgui_bundle_assets_dir()
39
+ if folder:
40
+ hello_imgui.set_assets_folder(str(folder))
41
+
42
+
43
+ def config_dir() -> Path:
44
+ """Per-user config dir (``$XDG_CONFIG_HOME/imgui_data_loader``)."""
45
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
46
+ d = Path(base) / "imgui_data_loader"
47
+ d.mkdir(parents=True, exist_ok=True)
48
+ return d
49
+
50
+
51
+ def default_ini_path(name: str = "file_dialog") -> str:
52
+ """Path for hello_imgui's ``.ini`` (window layout) file."""
53
+ return str(config_dir() / f"{name}.ini")
@@ -0,0 +1,196 @@
1
+ """Configuration objects for the file dialog.
2
+
3
+ None of these require an active imgui context, so a config can be built and
4
+ inspected anywhere (``_default_buttons`` only imports icon *string constants*).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Callable,
14
+ List,
15
+ Optional,
16
+ Sequence,
17
+ Union,
18
+ )
19
+
20
+ from .theme import Theme
21
+
22
+ if TYPE_CHECKING: # pragma: no cover
23
+ from .dialog import FileDialog
24
+ from .persistence import PreferenceStore
25
+
26
+ # A draw callback may take the FileDialog instance or nothing; both are
27
+ # accepted (see widgets.call_draw for the arity handling).
28
+ DrawCallback = Callable[..., None]
29
+
30
+
31
+ class PickKind(str, Enum):
32
+ """What a button asks the OS picker for."""
33
+
34
+ OPEN_FILE = "open_file"
35
+ SELECT_FOLDER = "select_folder"
36
+ SAVE_FILE = "save_file"
37
+
38
+
39
+ @dataclass
40
+ class FileType:
41
+ """A named file filter, e.g. ``FileType("Images", "*.tif *.png")``.
42
+
43
+ ``patterns`` is a space-separated glob string (portable_file_dialogs
44
+ format). A list/tuple of patterns is also accepted and joined.
45
+ """
46
+
47
+ name: str
48
+ patterns: str = "*"
49
+
50
+ def __post_init__(self) -> None:
51
+ if isinstance(self.patterns, (list, tuple)):
52
+ self.patterns = " ".join(self.patterns)
53
+
54
+
55
+ def flatten_filters(filetypes: Sequence[FileType]) -> List[str]:
56
+ """Flatten ``[FileType, ...]`` into pfd's ``[name, patterns, ...]`` list."""
57
+ out: List[str] = []
58
+ for ft in filetypes:
59
+ out.append(ft.name)
60
+ out.append(ft.patterns)
61
+ return out
62
+
63
+
64
+ @dataclass
65
+ class ButtonSpec:
66
+ """One picker button on the dialog.
67
+
68
+ Parameters
69
+ ----------
70
+ label : str
71
+ Button text.
72
+ kind : PickKind
73
+ Which native picker to open.
74
+ icon : str
75
+ A FontAwesome 6 icon character (e.g. ``fa.ICON_FA_FOLDER_OPEN``);
76
+ optional.
77
+ tooltip : str
78
+ Hover tooltip.
79
+ multiselect : bool
80
+ For ``OPEN_FILE`` only: allow selecting several files.
81
+ filetypes : list[FileType] | None
82
+ Overrides the dialog-wide ``filetypes`` for this button.
83
+ title : str | None
84
+ Native dialog window title (defaults to ``label``).
85
+ """
86
+
87
+ label: str
88
+ kind: PickKind = PickKind.OPEN_FILE
89
+ icon: str = ""
90
+ tooltip: str = ""
91
+ multiselect: bool = False
92
+ filetypes: Optional[List[FileType]] = None
93
+ title: Optional[str] = None
94
+
95
+
96
+ @dataclass
97
+ class DialogResult:
98
+ """Outcome of the dialog.
99
+
100
+ ``paths`` holds the selected path(s); it is empty when the user quit /
101
+ cancelled. ``bool(result)`` is True only for a real, non-empty selection.
102
+ """
103
+
104
+ paths: List[str] = field(default_factory=list)
105
+ kind: Optional[PickKind] = None
106
+ cancelled: bool = False
107
+
108
+ @property
109
+ def path(self) -> Optional[str]:
110
+ """First selected path, or None."""
111
+ return self.paths[0] if self.paths else None
112
+
113
+ def __bool__(self) -> bool:
114
+ return bool(self.paths) and not self.cancelled
115
+
116
+
117
+ def _default_buttons() -> List[ButtonSpec]:
118
+ from imgui_bundle import icons_fontawesome_6 as fa
119
+
120
+ return [
121
+ ButtonSpec(
122
+ "Open File(s)",
123
+ PickKind.OPEN_FILE,
124
+ icon=fa.ICON_FA_FILE_IMAGE,
125
+ tooltip="Select one or more files",
126
+ multiselect=True,
127
+ ),
128
+ ButtonSpec(
129
+ "Select Folder",
130
+ PickKind.SELECT_FOLDER,
131
+ icon=fa.ICON_FA_FOLDER_OPEN,
132
+ tooltip="Select a folder",
133
+ ),
134
+ ]
135
+
136
+
137
+ @dataclass
138
+ class FileDialogConfig:
139
+ """Everything the dialog can be told to do.
140
+
141
+ All fields have sensible defaults; the empty ``FileDialogConfig()`` gives
142
+ an "Open File(s)" + "Select Folder" launcher.
143
+
144
+ Content slots (``header_draw``, ``top_draw``, ``info``, ``options_draw``,
145
+ ``footer_draw``) are callbacks that draw arbitrary imgui inside the dialog.
146
+ Each callback may accept the :class:`FileDialog` instance or no argument.
147
+ """
148
+
149
+ # --- header / branding ---
150
+ title: str = "Open Data"
151
+ subtitle: str = ""
152
+
153
+ # --- picker buttons ---
154
+ buttons: List[ButtonSpec] = field(default_factory=_default_buttons)
155
+ filetypes: List[FileType] = field(
156
+ default_factory=lambda: [FileType("All Files", "*")]
157
+ )
158
+ default_dir: str = ""
159
+
160
+ # --- look ---
161
+ theme: Theme = field(default_factory=Theme.dark)
162
+
163
+ # --- content slots (each: fn(dialog) or fn()) ---
164
+ header_draw: Optional[DrawCallback] = None
165
+ top_draw: Optional[DrawCallback] = None
166
+ info: Union[DrawCallback, Sequence[DrawCallback], None] = None
167
+ options_draw: Optional[DrawCallback] = None
168
+ footer_draw: Optional[DrawCallback] = None
169
+
170
+ # --- footer labels / behavior ---
171
+ options_label: str = "Options"
172
+ show_options_button: bool = True
173
+ show_quit_button: bool = True
174
+ quit_label: str = "Quit"
175
+ quit_on_escape: bool = True
176
+ close_on_select: bool = True
177
+
178
+ # --- window / harness (used by run_file_dialog) ---
179
+ window_title: Optional[str] = None
180
+ window_size: tuple = (360, 720)
181
+ resizable: bool = True
182
+ ini_path: Optional[str] = None
183
+ assets_folder: Optional[str] = None
184
+
185
+ # --- integrations ---
186
+ persistence: "Optional[PreferenceStore]" = None
187
+ on_select: Optional[Callable[[DialogResult], None]] = None
188
+ on_cancel: Optional[Callable[[], None]] = None
189
+
190
+ def info_callbacks(self) -> List[DrawCallback]:
191
+ """Normalize ``info`` to a list of callbacks."""
192
+ if self.info is None:
193
+ return []
194
+ if callable(self.info):
195
+ return [self.info]
196
+ return list(self.info)
@@ -0,0 +1,348 @@
1
+ """The :class:`FileDialog` widget.
2
+
3
+ Render it inside a hello_imgui / immapp frame (set ``params.callbacks.show_gui
4
+ = dialog.render``). Buttons trigger the OS-native picker via
5
+ ``portable_file_dialogs``; when a selection completes it lands on
6
+ :attr:`FileDialog.result`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from imgui_bundle import (
15
+ hello_imgui,
16
+ icons_fontawesome_6 as fa,
17
+ imgui,
18
+ imgui_ctx,
19
+ portable_file_dialogs as pfd,
20
+ )
21
+
22
+ from .config import (
23
+ ButtonSpec,
24
+ DialogResult,
25
+ FileDialogConfig,
26
+ FileType,
27
+ PickKind,
28
+ flatten_filters,
29
+ )
30
+ from .theme import Theme, to_vec4
31
+ from .widgets import (
32
+ call_draw,
33
+ center_next_item,
34
+ center_text,
35
+ icon_button,
36
+ pop_button_style,
37
+ push_button_style,
38
+ wrapped_tooltip,
39
+ )
40
+
41
+
42
+ class FileDialog:
43
+ """A themed, configurable file/folder open dialog.
44
+
45
+ Parameters
46
+ ----------
47
+ config : FileDialogConfig | None
48
+ Behavior and content. Defaults to a plain "Open File(s) / Select
49
+ Folder" launcher.
50
+ """
51
+
52
+ def __init__(self, config: Optional[FileDialogConfig] = None):
53
+ self.config = config or FileDialogConfig()
54
+ self._pending = None # active portable_file_dialogs handle
55
+ self._pending_kind: Optional[PickKind] = None
56
+ self._result: Optional[DialogResult] = None
57
+ self._open_options = False
58
+
59
+ # ------------------------------------------------------------------
60
+ # public surface
61
+ # ------------------------------------------------------------------
62
+ @property
63
+ def theme(self) -> Theme:
64
+ return self.config.theme
65
+
66
+ @property
67
+ def result(self) -> Optional[DialogResult]:
68
+ """Last completed result (peek; does not clear)."""
69
+ return self._result
70
+
71
+ def take_result(self) -> Optional[DialogResult]:
72
+ """Return the last result and clear it (for embedded per-frame polling)."""
73
+ r, self._result = self._result, None
74
+ return r
75
+
76
+ def open_options(self) -> None:
77
+ """Programmatically open the Options popup on the next frame."""
78
+ self._open_options = True
79
+
80
+ def cancel(self) -> None:
81
+ """Finish with an empty, cancelled result (as the Quit button does)."""
82
+ self._finish(DialogResult(paths=[], kind=None, cancelled=True))
83
+
84
+ # ------------------------------------------------------------------
85
+ # native picker plumbing
86
+ # ------------------------------------------------------------------
87
+ def _start_dir(self) -> str:
88
+ if self.config.default_dir:
89
+ return str(self.config.default_dir)
90
+ store = self.config.persistence
91
+ if store is not None:
92
+ try:
93
+ d = store.default_dir()
94
+ if d:
95
+ return str(d)
96
+ except Exception:
97
+ pass
98
+ return str(Path.home())
99
+
100
+ def _filters_for(self, btn: ButtonSpec) -> List[str]:
101
+ fts = btn.filetypes if btn.filetypes is not None else self.config.filetypes
102
+ return flatten_filters(fts or [FileType("All Files", "*")])
103
+
104
+ def _launch(self, btn: ButtonSpec) -> None:
105
+ start = self._start_dir()
106
+ title = btn.title or btn.label
107
+ if btn.kind == PickKind.OPEN_FILE:
108
+ opt = pfd.opt.multiselect if btn.multiselect else pfd.opt.none
109
+ self._pending = pfd.open_file(title, start, self._filters_for(btn), opt)
110
+ elif btn.kind == PickKind.SELECT_FOLDER:
111
+ self._pending = pfd.select_folder(title, start)
112
+ elif btn.kind == PickKind.SAVE_FILE:
113
+ self._pending = pfd.save_file(title, start, self._filters_for(btn))
114
+ else:
115
+ return
116
+ self._pending_kind = btn.kind
117
+
118
+ def _poll(self) -> None:
119
+ if self._pending is None or not self._pending.ready():
120
+ return
121
+ raw = self._pending.result()
122
+ kind = self._pending_kind
123
+ self._pending = None
124
+ self._pending_kind = None
125
+ if isinstance(raw, list):
126
+ paths = [p for p in raw if p]
127
+ elif raw:
128
+ paths = [raw]
129
+ else:
130
+ paths = []
131
+ # An empty result means the user cancelled the native picker itself;
132
+ # stay open so they can try another button.
133
+ if paths:
134
+ self._finish(DialogResult(paths=paths, kind=kind, cancelled=False))
135
+
136
+ def _finish(self, result: DialogResult) -> None:
137
+ self._result = result
138
+ cfg = self.config
139
+ if not result.cancelled:
140
+ if cfg.persistence is not None:
141
+ try:
142
+ cfg.persistence.record_selection(result)
143
+ except Exception:
144
+ pass
145
+ if cfg.on_select is not None:
146
+ cfg.on_select(result)
147
+ if cfg.close_on_select:
148
+ self._request_exit()
149
+ else:
150
+ if cfg.on_cancel is not None:
151
+ cfg.on_cancel()
152
+ self._request_exit()
153
+
154
+ @staticmethod
155
+ def _request_exit() -> None:
156
+ try:
157
+ hello_imgui.get_runner_params().app_shall_exit = True
158
+ except Exception:
159
+ pass
160
+
161
+ # ------------------------------------------------------------------
162
+ # rendering
163
+ # ------------------------------------------------------------------
164
+ def render(self) -> None:
165
+ """Draw one frame of the dialog. Use as ``callbacks.show_gui``."""
166
+ theme = self.theme
167
+ imgui.push_style_color(imgui.Col_.window_bg, to_vec4(theme.bg))
168
+ imgui.push_style_color(imgui.Col_.child_bg, imgui.ImVec4(0, 0, 0, 0))
169
+ imgui.push_style_color(imgui.Col_.text, to_vec4(theme.text))
170
+ imgui.push_style_color(imgui.Col_.border, to_vec4(theme.border))
171
+ imgui.push_style_color(imgui.Col_.separator, to_vec4(theme.separator))
172
+ imgui.push_style_color(imgui.Col_.frame_bg, to_vec4(theme.frame_bg))
173
+ imgui.push_style_color(imgui.Col_.frame_bg_hovered, to_vec4(theme.frame_bg_hovered))
174
+ imgui.push_style_color(imgui.Col_.check_mark, to_vec4(theme.accent))
175
+ imgui.push_style_var(imgui.StyleVar_.window_padding, hello_imgui.em_to_vec2(1.0, 0.8))
176
+ imgui.push_style_var(imgui.StyleVar_.frame_padding, hello_imgui.em_to_vec2(0.6, 0.4))
177
+ imgui.push_style_var(imgui.StyleVar_.item_spacing, hello_imgui.em_to_vec2(0.6, 0.4))
178
+ imgui.push_style_var(imgui.StyleVar_.frame_rounding, theme.frame_rounding)
179
+
180
+ with imgui_ctx.begin_child(
181
+ "##idl_main",
182
+ size=imgui.ImVec2(0, 0),
183
+ window_flags=imgui.WindowFlags_.no_scrollbar,
184
+ ):
185
+ imgui.push_id("imgui_data_loader")
186
+
187
+ self._draw_header()
188
+
189
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
190
+ imgui.separator()
191
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
192
+
193
+ if self.config.top_draw is not None:
194
+ call_draw(self.config.top_draw, self)
195
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
196
+
197
+ self._draw_buttons()
198
+ self._draw_info_card()
199
+ self._draw_options_popup()
200
+ self._poll()
201
+ self._draw_footer()
202
+
203
+ imgui.pop_id()
204
+
205
+ imgui.pop_style_var(4)
206
+ imgui.pop_style_color(8)
207
+
208
+ def _button_width(self) -> float:
209
+ avail_w = imgui.get_content_region_avail().x
210
+ return min(avail_w - hello_imgui.em_size(2), hello_imgui.em_size(16))
211
+
212
+ def _draw_header(self) -> None:
213
+ if self.config.header_draw is not None:
214
+ call_draw(self.config.header_draw, self)
215
+ return
216
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
217
+ center_text(self.config.title, self.theme.accent)
218
+ if self.config.subtitle:
219
+ center_text(self.config.subtitle, self.theme.text_dim)
220
+
221
+ def _draw_buttons(self) -> None:
222
+ btn_w = self._button_width()
223
+ btn_h = hello_imgui.em_size(1.8)
224
+ for i, btn in enumerate(self.config.buttons):
225
+ center_next_item(btn_w)
226
+ imgui.push_id(i)
227
+ if icon_button(
228
+ btn.icon,
229
+ btn.label,
230
+ imgui.ImVec2(btn_w, btn_h),
231
+ theme=self.theme,
232
+ tooltip=btn.tooltip,
233
+ ):
234
+ self._launch(btn)
235
+ imgui.pop_id()
236
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
237
+
238
+ def _draw_info_card(self) -> None:
239
+ callbacks = self.config.info_callbacks()
240
+ if not callbacks:
241
+ return
242
+ theme = self.theme
243
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
244
+ avail_w = imgui.get_content_region_avail().x
245
+ card_w = avail_w - hello_imgui.em_size(1)
246
+ center_next_item(card_w)
247
+
248
+ imgui.push_style_color(imgui.Col_.child_bg, to_vec4(theme.bg_card))
249
+ imgui.push_style_var(imgui.StyleVar_.child_rounding, theme.child_rounding)
250
+ imgui.push_style_var(imgui.StyleVar_.cell_padding, hello_imgui.em_to_vec2(0.4, 0.2))
251
+
252
+ child_flags = imgui.ChildFlags_.borders | imgui.ChildFlags_.auto_resize_y
253
+ window_flags = imgui.WindowFlags_.no_scrollbar
254
+ with imgui_ctx.begin_child(
255
+ "##idl_info",
256
+ size=imgui.ImVec2(card_w, 0),
257
+ child_flags=child_flags,
258
+ window_flags=window_flags,
259
+ ):
260
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
261
+ imgui.indent(hello_imgui.em_size(0.6))
262
+ for j, cb in enumerate(callbacks):
263
+ if j:
264
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
265
+ imgui.push_id(1000 + j)
266
+ call_draw(cb, self)
267
+ imgui.pop_id()
268
+ imgui.unindent(hello_imgui.em_size(0.6))
269
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
270
+
271
+ imgui.pop_style_var(2)
272
+ imgui.pop_style_color()
273
+
274
+ def _draw_options_popup(self) -> None:
275
+ if self.config.options_draw is None:
276
+ return
277
+ if self._open_options:
278
+ imgui.open_popup("##idl_options")
279
+ self._open_options = False
280
+ if not imgui.begin_popup("##idl_options", imgui.WindowFlags_.always_auto_resize):
281
+ return
282
+ try:
283
+ imgui.text_colored(
284
+ to_vec4(self.theme.accent),
285
+ f"{fa.ICON_FA_GEARS} {self.config.options_label}",
286
+ )
287
+ imgui.separator()
288
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.2))
289
+ call_draw(self.config.options_draw, self)
290
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
291
+ if imgui.button("Close", imgui.ImVec2(hello_imgui.em_size(6), 0)):
292
+ imgui.close_current_popup()
293
+ finally:
294
+ imgui.end_popup()
295
+
296
+ def _draw_footer(self) -> None:
297
+ cfg = self.config
298
+ if cfg.footer_draw is not None:
299
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
300
+ call_draw(cfg.footer_draw, self)
301
+ self._handle_escape()
302
+ return
303
+
304
+ show_options = cfg.options_draw is not None and cfg.show_options_button
305
+ show_quit = cfg.show_quit_button
306
+ if not (show_options or show_quit):
307
+ self._handle_escape()
308
+ return
309
+
310
+ imgui.dummy(hello_imgui.em_to_vec2(0, 0.3))
311
+ theme = self.theme
312
+ style = imgui.get_style()
313
+ # size each button to its (icon + label) text so custom labels don't clip
314
+ opt_text = f"{fa.ICON_FA_GEARS} {cfg.options_label}"
315
+ quit_text = f"{fa.ICON_FA_XMARK} {cfg.quit_label}"
316
+ pad = style.frame_padding.x * 2 + hello_imgui.em_size(0.6)
317
+ min_w = hello_imgui.em_size(4.5)
318
+ opt_w = max(imgui.calc_text_size(opt_text).x + pad, min_w)
319
+ quit_w = max(imgui.calc_text_size(quit_text).x + pad, min_w)
320
+ spacing = style.item_spacing.x
321
+ row_w = 0.0
322
+ if show_options:
323
+ row_w += opt_w
324
+ if show_quit:
325
+ row_w += quit_w
326
+ if show_options and show_quit:
327
+ row_w += spacing
328
+ center_next_item(row_w)
329
+
330
+ btn_h = hello_imgui.em_size(1.5)
331
+ push_button_style(theme, primary=False)
332
+ if show_options:
333
+ if imgui.button(opt_text, imgui.ImVec2(opt_w, btn_h)):
334
+ self._open_options = True
335
+ if imgui.is_item_hovered():
336
+ wrapped_tooltip(cfg.options_label)
337
+ if show_quit:
338
+ imgui.same_line()
339
+ if show_quit:
340
+ if imgui.button(quit_text, imgui.ImVec2(quit_w, btn_h)):
341
+ self.cancel()
342
+ pop_button_style()
343
+
344
+ self._handle_escape()
345
+
346
+ def _handle_escape(self) -> None:
347
+ if self.config.quit_on_escape and imgui.is_key_pressed(imgui.Key.escape):
348
+ self.cancel()