Source code for simvx.core.ui.file_dialog

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

Renders as a full-screen modal Control with a centred dialog card. 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
_BACKDROP_COLOUR = (0.0, 0.0, 0.0, 0.5)
_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): """Modal 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) # Modal Control covers the whole screen so it captures all input. The # auto-injected ``DimBackdrop`` paints the screen-wide dim overlay; the # centred dialog card is hand-drawn in :meth:`draw`. self.set_anchor_preset(AnchorPreset.FULL_RECT) self.modal = True self.dismiss_on_outside_click = True self.pause_tree_when_modal = True # pick DimBackdrop for the visible dim self.top_level = True self.visible = False self.z_index = 2000 # 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() self.show_modal() # Focus the filename field by default. self._filename_edit.grab_focus()
[docs] def hide(self): """Close the dialog without selecting a file.""" if not self.visible: return self.close_modal()
# ---------------------------------------------------------------- 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 if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] # 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 (router would also dismiss us # via the outside-click path, but emitting ``canceled`` keeps the # legacy signal contract intact). if not self._point_in_rect(px, py, *dr): self.canceled.emit() self.hide() # ----------------------------------------------------------------- timing
[docs] def on_process(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 auto-injected DimBackdrop already paints the screen-wide dim. # 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)