Source code for simvx.core.ui.file_dialog

"""FileDialog -- file browser for open/save operations.

Renders as a full-screen ``blocking`` overlay with a centred dialog card: the
overlay layer dims the world (which stays live) and scopes input to the dialog.
The filename input is a real :class:`TextEdit` child: keyboard input flows
through the standard ``UIInputManager`` focus path, not a hand-rolled key handler.
"""

import logging
from pathlib import Path

from ..signals import Signal
from ..math.types import Vec2
from .core import Control
from .enums import AnchorPreset, FocusMode
from .widgets import TextEdit
from ..input.enums import MouseButton

log = logging.getLogger(__name__)

__all__ = ["FileDialog"]

# Layout constants
_DIALOG_WIDTH = 600.0
_DIALOG_HEIGHT = 400.0
_TITLE_BAR_HEIGHT = 30.0
_PATH_BAR_HEIGHT = 28.0
_BOTTOM_BAR_HEIGHT = 34.0
_ROW_HEIGHT = 24.0
_PADDING = 8.0
_BUTTON_WIDTH = 80.0
_BUTTON_HEIGHT = 26.0

# Colours (the screen-wide dim is the overlay layer's job, not a colour here)
_DIALOG_BG = (0.14, 0.14, 0.15, 1.0)
_TITLE_BG = (0.08, 0.08, 0.09, 1.0)
_TITLE_TEXT = (1.0, 1.0, 1.0, 1.0)
_PATH_BG = (0.07, 0.07, 0.08, 1.0)
_PATH_TEXT = (0.8, 0.8, 0.8, 1.0)
_LIST_BG = (0.08, 0.08, 0.09, 1.0)
_DIR_COLOUR = (0.3, 0.6, 1.0, 1.0)
_FILE_COLOUR = (1.0, 1.0, 1.0, 1.0)
_SELECTED_BG = (0.2, 0.45, 0.8, 1.0)
_HOVER_BG = (0.24, 0.24, 0.27, 1.0)
_BOTTOM_BG = (0.11, 0.11, 0.12, 1.0)
_BUTTON_BG = (0.24, 0.24, 0.27, 1.0)
_BUTTON_HOVER = (0.32, 0.32, 0.36, 1.0)
_BUTTON_BORDER = (0.38, 0.38, 0.40, 1.0)
_BUTTON_TEXT = (1.0, 1.0, 1.0, 1.0)
_CLOSE_HOVER = (0.8, 0.2, 0.2, 1.0)
_FILTER_TEXT = (0.56, 0.56, 0.58, 1.0)
_SCROLLBAR_COLOUR = (0.42, 0.42, 0.47, 0.6)
_SCROLLBAR_BG = (0.11, 0.11, 0.12, 0.3)
_SCROLLBAR_WIDTH = 8.0


[docs] class FileDialog(Control): """File browser for open/save operations. Set ``mode`` and call :meth:`show` to open. Emits ``file_selected`` with the chosen path on confirm and ``canceled`` on dismiss. Example:: dialog = FileDialog() dialog.file_selected.connect(lambda path: print("Chosen:", path)) dialog.show(mode="save", path="/home/user/project", filter="*.json") """ def __init__(self, **kwargs): super().__init__(**kwargs) # The dialog covers the whole screen so the overlay scope captures all # input. ``show_overlay("blocking")`` dims the world (which stays live) # via the overlay collector; the centred dialog card is hand-drawn in # :meth:`on_draw`. No DimBackdrop child: dim is an overlay flag now. self.set_anchor_preset(AnchorPreset.FULL_RECT) self.visible = False self.z_index = 2000 # The overlay router fires ``cancel_requested`` on an outside click / # Escape and pops us from the registry; mirror that into the dialog's # own visibility + ``canceled`` contract. self.cancel_requested.connect(self._on_dismissed) # File-browser state self._mode: str = "open" self._current_dir: Path = Path.home() self._filter: str = "*.*" self._entries: list[tuple[str, bool]] = [] self._selected_index: int = -1 self._hovered_index: int = -1 self._scroll_offset: float = 0.0 # Hover state for buttons self._close_hovered: bool = False self._confirm_hovered: bool = False self._cancel_hovered: bool = False # Double-click detection self._last_click_index: int = -1 self._click_timer: float = 0.0 # Geometry cache (recomputed each draw / input event) self._cached_dialog_rect: tuple[float, float, float, float] = (0, 0, 0, 0) self._cached_list_rect: tuple[float, float, float, float] = (0, 0, 0, 0) # Real TextEdit child for the filename: receives focus and keystrokes # through the standard router instead of a hand-rolled buffer. self._filename_edit = self.add_child( TextEdit(name="FilenameInput", placeholder="filename...") ) self._filename_edit.focus_mode = FocusMode.ALL self._filename_edit.text_submitted.connect(self._on_filename_submitted) # Signals self.file_selected = Signal() self.canceled = Signal() # ------------------------------------------------------------------ public
[docs] def show(self, mode: str = "open", path: str | Path | None = None, filter: str = "*.*"): """Open the dialog. Args: mode: ``"open"``, ``"save"``, ``"save_as"``, or ``"open_folder"``. path: Starting directory or pre-selected file. filter: Extension filter, e.g. ``"*.json"``. """ self._mode = mode self._filter = filter self._selected_index = -1 self._hovered_index = -1 self._scroll_offset = 0.0 self._filename_edit.text = "" self._filename_edit.cursor_pos = 0 if path is not None: p = Path(path) if p.is_file(): self._current_dir = p.parent self._filename_edit.text = p.name self._filename_edit.cursor_pos = len(p.name) elif p.is_dir(): self._current_dir = p else: self._current_dir = Path.home() else: self._current_dir = Path.home() self._scan_directory() # FULL_RECT was set in __init__; register as a blocking overlay (dim, the # world stays live) after positioning. Declare the filename field as the # initial focus so keystrokes land there on open. self.show_overlay("blocking", initial_focus=self._filename_edit)
[docs] def hide(self): """Close the dialog without selecting a file.""" if not self.visible: return self.visible = False self.close_overlay()
def _on_dismissed(self): """Router emitted ``cancel_requested`` (outside click / Escape): unwind. ``close_overlay`` is idempotent, so this stays consistent whether the router popped us first or :meth:`hide` did. """ self.hide() # ---------------------------------------------------------------- internal def _scan_directory(self): """Populate ``_entries``: directories first, then files matching the filter.""" self._entries = [] self._selected_index = -1 self._hovered_index = -1 self._scroll_offset = 0.0 try: items = list(self._current_dir.iterdir()) except PermissionError: return dirs: list[str] = [] files: list[str] = [] for item in items: name = item.name if name.startswith("."): continue if item.is_dir(): dirs.append(name) elif item.is_file() and self._matches_filter(name): files.append(name) dirs.sort(key=str.lower) files.sort(key=str.lower) if self._current_dir.parent != self._current_dir: self._entries.append(("..", True)) for d in dirs: self._entries.append((d, True)) for f in files: self._entries.append((f, False)) def _matches_filter(self, filename: str) -> bool: if self._filter in ("*.*", "*"): return True if self._filter.startswith("*."): ext = self._filter[1:] return filename.lower().endswith(ext.lower()) return True def _navigate_to(self, path: Path): resolved = path.resolve() if resolved.is_dir(): self._current_dir = resolved self._scan_directory() def _on_filename_submitted(self, _text: str): """TextEdit emits ``text_submitted`` on Enter: confirm the selection.""" self._confirm() def _confirm(self): """Emit ``file_selected`` and close, applying the same selection rules as before the migration.""" selected_name, selected_is_dir = "", False if 0 <= self._selected_index < len(self._entries): selected_name, selected_is_dir = self._entries[self._selected_index] filename_text = self._filename_edit.text if self._mode == "open_folder": if selected_is_dir and selected_name != "..": self.file_selected.emit(str(self._current_dir / selected_name)) elif selected_name and not selected_is_dir: self.file_selected.emit(str(self._current_dir / selected_name)) else: self.file_selected.emit(str(self._current_dir)) self.hide() return if self._mode == "open": if not filename_text and selected_is_dir and selected_name: if selected_name == "..": self._navigate_to(self._current_dir.parent) return self.file_selected.emit(str(self._current_dir / selected_name)) self.hide() return if not filename_text and not selected_is_dir and selected_name: filename_text = selected_name self._filename_edit.text = filename_text self._filename_edit.cursor_pos = len(filename_text) if not filename_text: return full_path = str(self._current_dir / filename_text) if self._mode == "open" and not Path(full_path).is_file(): return self.file_selected.emit(full_path) self.hide() # ----------------------------------------------------------- geometry helpers def _dialog_rect(self) -> tuple[float, float, float, float]: """Centred dialog rect in screen coords.""" screen = self._get_parent_size() dx = (screen.x - _DIALOG_WIDTH) / 2 dy = (screen.y - _DIALOG_HEIGHT) / 2 return (dx, dy, _DIALOG_WIDTH, _DIALOG_HEIGHT) def _close_button_rect(self, dr) -> tuple[float, float, float, float]: dx, dy, dw, _ = dr size = _TITLE_BAR_HEIGHT - 6 return (dx + dw - size - 3, dy + 3, size, size) def _path_rect(self, dr) -> tuple[float, float, float, float]: dx, dy, dw, _ = dr return (dx, dy + _TITLE_BAR_HEIGHT, dw, _PATH_BAR_HEIGHT) def _list_rect(self, dr) -> tuple[float, float, float, float]: dx, dy, dw, dh = dr top = dy + _TITLE_BAR_HEIGHT + _PATH_BAR_HEIGHT bottom = dy + dh - _BOTTOM_BAR_HEIGHT return (dx, top, dw, bottom - top) def _bottom_rect(self, dr) -> tuple[float, float, float, float]: dx, dy, dw, dh = dr return (dx, dy + dh - _BOTTOM_BAR_HEIGHT, dw, _BOTTOM_BAR_HEIGHT) def _filename_input_rect(self, dr) -> tuple[float, float, float, float]: bx, by, bw, bh = self._bottom_rect(dr) pad = _PADDING input_w = bw - _BUTTON_WIDTH * 2 - pad * 4 - 80 return (bx + pad, by + (bh - _BUTTON_HEIGHT) / 2, max(input_w, 100), _BUTTON_HEIGHT) def _filter_label_rect(self, dr) -> tuple[float, float, float, float]: ix, iy, iw, ih = self._filename_input_rect(dr) return (ix + iw + _PADDING, iy, 70, ih) def _confirm_button_rect(self, dr) -> tuple[float, float, float, float]: bx, by, bw, bh = dr[0], dr[1] + dr[3] - _BOTTOM_BAR_HEIGHT, dr[2], _BOTTOM_BAR_HEIGHT pad = _PADDING return (bx + bw - _BUTTON_WIDTH * 2 - pad * 2, by + (bh - _BUTTON_HEIGHT) / 2, _BUTTON_WIDTH, _BUTTON_HEIGHT) def _cancel_button_rect(self, dr) -> tuple[float, float, float, float]: bx, by, bw, bh = dr[0], dr[1] + dr[3] - _BOTTOM_BAR_HEIGHT, dr[2], _BOTTOM_BAR_HEIGHT pad = _PADDING return (bx + bw - _BUTTON_WIDTH - pad, by + (bh - _BUTTON_HEIGHT) / 2, _BUTTON_WIDTH, _BUTTON_HEIGHT) def _entry_index_at(self, px: float, py: float) -> int: lx, ly, lw, lh = self._cached_list_rect if not (lx <= px <= lx + lw - _SCROLLBAR_WIDTH and ly <= py <= ly + lh): return -1 row = int((py - ly + self._scroll_offset) / _ROW_HEIGHT) if 0 <= row < len(self._entries): return row return -1 @staticmethod def _point_in_rect(px: float, py: float, rx: float, ry: float, rw: float, rh: float) -> bool: return rx <= px <= rx + rw and ry <= py <= ry + rh def _max_scroll(self) -> float: _, _, _, lh = self._cached_list_rect content_h = len(self._entries) * _ROW_HEIGHT return max(0.0, content_h - lh) def _clamp_scroll(self): self._scroll_offset = max(0.0, min(self._scroll_offset, self._max_scroll())) def _refresh_geometry(self): """Recompute cached rects and reposition the TextEdit child.""" dr = self._dialog_rect() self._cached_dialog_rect = dr self._cached_list_rect = self._list_rect(dr) ix, iy, iw, ih = self._filename_input_rect(dr) self._filename_edit.position = Vec2(ix, iy) self._filename_edit.size = Vec2(iw, ih) # ------------------------------------------------------------------ input def _on_gui_input(self, event): """Handle clicks (geometric) and scroll within the dialog card.""" if not self.visible: return self._refresh_geometry() dr = self._cached_dialog_rect px = event.position.x py = event.position.y # Hover state for buttons / list entries self._close_hovered = self._point_in_rect(px, py, *self._close_button_rect(dr)) self._confirm_hovered = self._point_in_rect(px, py, *self._confirm_button_rect(dr)) self._cancel_hovered = self._point_in_rect(px, py, *self._cancel_button_rect(dr)) self._hovered_index = self._entry_index_at(px, py) # Scroll the file list when the wheel turns over the dialog card. if event.key == "scroll_up": if self._point_in_rect(px, py, *dr): self._scroll_offset = max(0.0, self._scroll_offset - _ROW_HEIGHT * 3) self._clamp_scroll() event.handled = True return if event.key == "scroll_down": if self._point_in_rect(px, py, *dr): self._scroll_offset += _ROW_HEIGHT * 3 self._clamp_scroll() event.handled = True return # Mouse press if event.button == MouseButton.LEFT and event.pressed: self._handle_click(px, py, dr) return def _handle_click(self, px: float, py: float, dr): """Process a left mouse-button press at screen coords (px, py).""" if self._point_in_rect(px, py, *self._close_button_rect(dr)): self.canceled.emit() self.hide() return if self._point_in_rect(px, py, *self._confirm_button_rect(dr)): self._confirm() return if self._point_in_rect(px, py, *self._cancel_button_rect(dr)): self.canceled.emit() self.hide() return idx = self._entry_index_at(px, py) if idx >= 0: name, is_dir = self._entries[idx] is_double = idx == self._last_click_index and self._click_timer < 0.4 self._last_click_index = idx self._click_timer = 0.0 if is_double: if is_dir: if name == "..": self._navigate_to(self._current_dir.parent) else: self._navigate_to(self._current_dir / name) else: self._filename_edit.text = name self._filename_edit.cursor_pos = len(name) self._confirm() else: self._selected_index = idx if not is_dir: self._filename_edit.text = name self._filename_edit.cursor_pos = len(name) return # Click outside the dialog card → cancel. The overlay router also # dismisses the chain on an outside click, but emitting ``canceled`` # here keeps the dialog's own signal contract intact. if not self._point_in_rect(px, py, *dr): self.canceled.emit() self.hide() # ----------------------------------------------------------------- timing
[docs] def on_update(self, dt: float): """Advance the double-click timer and refresh layout.""" self._click_timer += dt if self.visible: self._refresh_geometry()
# ---------------------------------------------------------------- drawing
[docs] def on_draw(self, renderer): if not self.visible: return # The overlay layer paints the screen-wide dim (blocking modality) behind # this subtree. We only draw the centred dialog card here. self._refresh_geometry() dx, dy, dw, dh = self._cached_dialog_rect renderer.draw_rect((dx, dy), (dw, dh), colour=_DIALOG_BG, filled=True) self._draw_title_bar(renderer, dx, dy, dw) self._draw_path_bar(renderer) self._draw_file_list(renderer) self._draw_bottom_bar(renderer) renderer.draw_rect((dx, dy), (dw, dh), colour=(0.38, 0.38, 0.40, 1.0))
def _draw_title_bar(self, renderer, dx: float, dy: float, dw: float): renderer.draw_rect((dx, dy), (dw, _TITLE_BAR_HEIGHT), colour=_TITLE_BG, filled=True) titles = {"open": "Open File", "save": "Save File", "save_as": "Save As", "open_folder": "Open Folder"} title = titles.get(self._mode, "File") scale = 1.0 text_y = dy + (_TITLE_BAR_HEIGHT - 14) / 2 renderer.draw_text(title, (dx + _PADDING, text_y), colour=_TITLE_TEXT, scale=scale) cx, cy, cw, ch = self._close_button_rect(self._cached_dialog_rect) bg = _CLOSE_HOVER if self._close_hovered else (0.24, 0.24, 0.27, 1.0) renderer.draw_rect((cx, cy), (cw, ch), colour=bg, filled=True) x_scale = 0.85 x_w = renderer.text_width("X", x_scale) renderer.draw_text("X", (cx + (cw - x_w) / 2, cy + (ch - 12) / 2), colour=_TITLE_TEXT, scale=x_scale) def _draw_path_bar(self, renderer): px, py, pw, ph = self._path_rect(self._cached_dialog_rect) renderer.draw_rect((px, py), (pw, ph), colour=_PATH_BG, filled=True) path_str = str(self._current_dir) scale = 0.85 text_y = py + (ph - 12) / 2 max_chars = int((pw - _PADDING * 2) / (12 * 0.6 * scale)) if len(path_str) > max_chars: path_str = "..." + path_str[-(max_chars - 3):] renderer.draw_text(path_str, (px + _PADDING, text_y), colour=_PATH_TEXT, scale=scale) def _draw_file_list(self, renderer): lx, ly, lw, lh = self._cached_list_rect renderer.draw_rect((lx, ly), (lw, lh), colour=_LIST_BG, filled=True) if not self._entries: scale = 0.85 msg = "(empty directory)" mw = renderer.text_width(msg, scale) renderer.draw_text(msg, (lx + (lw - mw) / 2, ly + lh / 2 - 6), colour=_FILTER_TEXT, scale=scale) return first_visible = int(self._scroll_offset / _ROW_HEIGHT) visible_count = int(lh / _ROW_HEIGHT) + 2 last_visible = min(first_visible + visible_count, len(self._entries)) scale = 0.85 usable_w = lw - _SCROLLBAR_WIDTH for i in range(first_visible, last_visible): name, is_dir = self._entries[i] row_y = ly + i * _ROW_HEIGHT - self._scroll_offset if row_y + _ROW_HEIGHT < ly or row_y > ly + lh: continue if i == self._selected_index: renderer.draw_rect((lx, row_y), (usable_w, _ROW_HEIGHT), colour=_SELECTED_BG, filled=True) elif i == self._hovered_index: renderer.draw_rect((lx, row_y), (usable_w, _ROW_HEIGHT), colour=_HOVER_BG, filled=True) display = name + "/" if is_dir else name colour = _DIR_COLOUR if is_dir else _FILE_COLOUR text_y = row_y + (_ROW_HEIGHT - 12) / 2 renderer.draw_text(display, (lx + _PADDING, text_y), colour=colour, scale=scale) content_h = len(self._entries) * _ROW_HEIGHT if content_h > lh: renderer.draw_rect( (lx + lw - _SCROLLBAR_WIDTH, ly), (_SCROLLBAR_WIDTH, lh), colour=_SCROLLBAR_BG, filled=True, ) ratio = lh / content_h thumb_h = max(20.0, lh * ratio) max_scroll = max(1.0, content_h - lh) scroll_ratio = self._scroll_offset / max_scroll thumb_y = ly + scroll_ratio * (lh - thumb_h) renderer.draw_rect( (lx + lw - _SCROLLBAR_WIDTH, thumb_y), (_SCROLLBAR_WIDTH, thumb_h), colour=_SCROLLBAR_COLOUR, filled=True, ) def _draw_bottom_bar(self, renderer): dr = self._cached_dialog_rect bx, by, bw, bh = dr[0], dr[1] + dr[3] - _BOTTOM_BAR_HEIGHT, dr[2], _BOTTOM_BAR_HEIGHT renderer.draw_rect((bx, by), (bw, bh), colour=_BOTTOM_BG, filled=True) scale = 0.85 # The TextEdit child draws itself via the standard child-draw walk; # nothing to paint here for the filename input. fx, fy, fw, fh = self._filter_label_rect(dr) text_y = fy + (fh - 12) / 2 renderer.draw_text(self._filter, (fx, text_y), colour=_FILTER_TEXT, scale=scale) self._draw_button( renderer, self._confirm_button_rect(dr), "Save" if self._mode in ("save", "save_as") else "Open", self._confirm_hovered, ) self._draw_button(renderer, self._cancel_button_rect(dr), "Cancel", self._cancel_hovered) def _draw_button(self, renderer, rect, text: str, hovered: bool): bx, by, bw, bh = rect bg = _BUTTON_HOVER if hovered else _BUTTON_BG renderer.draw_rect((bx, by), (bw, bh), colour=bg, filled=True) renderer.draw_rect((bx, by), (bw, bh), colour=_BUTTON_BORDER) scale = 0.85 tw = renderer.text_width(text, scale) text_x = bx + (bw - tw) / 2 text_y = by + (bh - 12) / 2 renderer.draw_text(text, (text_x, text_y), colour=_BUTTON_TEXT, scale=scale)