Source code for simvx.core.ui.file_dialog

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

Draws as a popup overlay with semi-transparent backdrop.
Uses the renderer (Draw2D) classmethods for all drawing.
Colours are float tuples (r, g, b, a) in 0.0-1.0 range.
"""


from __future__ import annotations

import logging
from pathlib import Path

from ..descriptors import Signal
from .core import Control

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_COLOR = (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_COLOR = (0.3, 0.6, 1.0, 1.0)
_FILE_COLOR = (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)
_INPUT_BG = (0.07, 0.07, 0.08, 1.0)
_INPUT_BORDER = (0.32, 0.32, 0.34, 1.0)
_INPUT_FOCUS_BORDER = (0.5, 0.8, 1.0, 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_COLOR = (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 dialog for open/save operations. Shows a popup overlay with directory listing, filename input, and confirm/cancel buttons. Emits ``file_selected`` with the full path when the user confirms a selection. Example: dialog = FileDialog() dialog.file_selected.connect(lambda path: print("Chosen:", path)) dialog.show(mode="open", path="/home", filter="*.json") """ def __init__(self, **kwargs): super().__init__(**kwargs) # State self._mode: str = "open" self._current_dir: Path = Path.home() self._selected_file: str = "" self._filter: str = "*.*" self._entries: list[tuple[str, bool]] = [] self._visible: bool = False # Interaction state self._selected_index: int = -1 self._hovered_index: int = -1 self._scroll_offset: float = 0.0 self._filename_text: str = "" self._filename_focused: bool = False self._filename_cursor: int = 0 self._cursor_blink: float = 0.0 self._close_hovered: bool = False self._confirm_hovered: bool = False self._cancel_hovered: bool = False self._last_click_index: int = -1 self._last_click_time: float = 0.0 self._click_timer: float = 0.0 # Cached geometry (kept in sync each frame to avoid resize mismatch) 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) # Signals self.file_selected = Signal() self.canceled = Signal() self.z_index = 2000 # ------------------------------------------------------------------ public
[docs] def show(self, mode: str = "open", path: str | Path | None = None, filter: str = "*.*"): """Open the dialog. Args: mode: ``"open"`` or ``"save"``. path: Starting directory. Defaults to home directory. filter: Extension filter, e.g. ``"*.json"`` or ``"*.*"``. """ self._mode = mode self._filter = filter self._selected_index = -1 self._hovered_index = -1 self._scroll_offset = 0.0 self._filename_text = "" self._filename_focused = False self._filename_cursor = 0 if path is not None: p = Path(path) if p.is_file(): self._current_dir = p.parent self._filename_text = p.name self._filename_cursor = len(self._filename_text) elif p.is_dir(): self._current_dir = p else: self._current_dir = Path.home() else: self._current_dir = Path.home() self._scan_directory() self._visible = True if self._tree: self._tree.push_popup(self)
[docs] def hide(self): """Close the dialog without selecting a file.""" if not self._visible: return self._visible = False self._filename_focused = False if self._tree: self._tree.pop_popup(self)
# ---------------------------------------------------------------- internal def _scan_directory(self): """Read current directory and populate _entries list. Entries are sorted: directories first (alphabetical), then files matching the filter (alphabetical). """ 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(): if self._matches_filter(name): files.append(name) dirs.sort(key=str.lower) files.sort(key=str.lower) # Parent directory entry 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: """Check if filename matches the current extension filter.""" if self._filter in ("*.*", "*"): return True # Support patterns like "*.json", "*.py" if self._filter.startswith("*."): ext = self._filter[1:] # e.g. ".json" return filename.lower().endswith(ext.lower()) return True def _navigate_to(self, path: Path): """Change directory and rescan.""" resolved = path.resolve() if resolved.is_dir(): self._current_dir = resolved self._scan_directory() def _confirm(self): """Emit file_selected with the full path and close.""" # Check if an entry is selected in the list selected_name, selected_is_dir = "", False if 0 <= self._selected_index < len(self._entries): selected_name, selected_is_dir = self._entries[self._selected_index] 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 # In "open" mode: selected folder → emit as folder; selected file → emit as file if self._mode == "open": if not self._filename_text and selected_is_dir and selected_name: if selected_name == "..": self._navigate_to(self._current_dir.parent) return # User selected a folder and pressed Open — emit the folder path self.file_selected.emit(str(self._current_dir / selected_name)) self.hide() return if not self._filename_text and not selected_is_dir and selected_name: self._filename_text = selected_name if not self._filename_text: return full_path = str(self._current_dir / self._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]: """Compute the centered 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 _title_rect(self, dr=None) -> tuple[float, float, float, float]: dx, dy, dw, _ = dr or self._dialog_rect() return (dx, dy, dw, _TITLE_BAR_HEIGHT) def _close_button_rect(self, dr=None) -> tuple[float, float, float, float]: dx, dy, dw, _ = dr or self._dialog_rect() size = _TITLE_BAR_HEIGHT - 6 return (dx + dw - size - 3, dy + 3, size, size) def _path_rect(self, dr=None) -> tuple[float, float, float, float]: dx, dy, dw, _ = dr or self._dialog_rect() return (dx, dy + _TITLE_BAR_HEIGHT, dw, _PATH_BAR_HEIGHT) def _list_rect(self, dr=None) -> tuple[float, float, float, float]: dx, dy, dw, dh = dr or self._dialog_rect() 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=None) -> tuple[float, float, float, float]: dx, dy, dw, dh = dr or self._dialog_rect() return (dx, dy + dh - _BOTTOM_BAR_HEIGHT, dw, _BOTTOM_BAR_HEIGHT) def _filename_input_rect(self, dr=None) -> tuple[float, float, float, float]: bx, by, bw, bh = self._bottom_rect(dr) pad = _PADDING # Leave room for filter label + confirm + cancel buttons on the right 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=None) -> 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=None) -> tuple[float, float, float, float]: bx, by, bw, bh = self._bottom_rect(dr) 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=None) -> tuple[float, float, float, float]: bx, by, bw, bh = self._bottom_rect(dr) pad = _PADDING return (bx + bw - _BUTTON_WIDTH - pad, by + (bh - _BUTTON_HEIGHT) / 2, _BUTTON_WIDTH, _BUTTON_HEIGHT) def _visible_row_count(self) -> int: """Number of rows visible in the file list area.""" _, _, _, lh = self._cached_list_rect return max(1, int(lh / _ROW_HEIGHT)) def _max_scroll(self) -> float: """Maximum scroll offset in pixels.""" _, _, _, lh = self._cached_list_rect content_h = len(self._entries) * _ROW_HEIGHT return max(0.0, content_h - lh) def _clamp_scroll(self): """Keep scroll within bounds.""" self._scroll_offset = max(0.0, min(self._scroll_offset, self._max_scroll())) def _entry_index_at(self, px: float, py: float) -> int: """Return entry index under screen position, or -1.""" 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 # ----------------------------------------------------------- popup API
[docs] def is_popup_point_inside(self, point) -> bool: """The entire screen is captured by the modal backdrop.""" return self._visible
[docs] def popup_input(self, event): """Handle all input while the dialog is active.""" if not self._visible: return 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] # Recompute geometry each input event to handle window resize correctly self._cached_dialog_rect = self._dialog_rect() self._cached_list_rect = self._list_rect(self._cached_dialog_rect) dr = self._cached_dialog_rect 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 events if event.key == "scroll_up": self._scroll_offset = max(0.0, self._scroll_offset - _ROW_HEIGHT * 3) self._clamp_scroll() return if event.key == "scroll_down": self._scroll_offset += _ROW_HEIGHT * 3 self._clamp_scroll() return # Keyboard input for filename field if self._filename_focused and not event.button: self._handle_filename_key(event) return # Keyboard shortcuts if event.key == "escape" and not event.pressed: self.canceled.emit() self.hide() return if event.key == "enter" and not event.pressed: self._confirm() return # Mouse clicks if event.button == 1 and event.pressed: self._handle_click(px, py)
[docs] def dismiss_popup(self): """Dismiss modal on outside click (backdrop absorbs all clicks).""" self.canceled.emit() self.hide()
def _handle_click(self, px: float, py: float): """Process a left mouse-button press at screen coords (px, py).""" dr = self._cached_dialog_rect # Close button if self._point_in_rect(px, py, *self._close_button_rect(dr)): self.canceled.emit() self.hide() return # Confirm button if self._point_in_rect(px, py, *self._confirm_button_rect(dr)): self._confirm() return # Cancel button if self._point_in_rect(px, py, *self._cancel_button_rect(dr)): self.canceled.emit() self.hide() return # Filename input — gain focus if self._point_in_rect(px, py, *self._filename_input_rect(dr)): self._filename_focused = True self._filename_cursor = len(self._filename_text) return # File list idx = self._entry_index_at(px, py) if idx >= 0: self._filename_focused = False name, is_dir = self._entries[idx] # Double-click detection 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_text = name self._filename_cursor = len(name) self._confirm() else: self._selected_index = idx if not is_dir: self._filename_text = name self._filename_cursor = len(name) return # Click on backdrop (outside dialog) — cancel if not self._point_in_rect(px, py, *self._cached_dialog_rect): self.canceled.emit() self.hide() return # Click somewhere else in dialog — unfocus filename self._filename_focused = False def _handle_filename_key(self, event): """Process keyboard events while filename input is focused.""" if event.key == "backspace" and not event.pressed: if self._filename_cursor > 0: self._filename_text = ( self._filename_text[: self._filename_cursor - 1] + self._filename_text[self._filename_cursor :] ) self._filename_cursor -= 1 elif event.key == "delete" and not event.pressed: if self._filename_cursor < len(self._filename_text): self._filename_text = ( self._filename_text[: self._filename_cursor] + self._filename_text[self._filename_cursor + 1 :] ) elif event.key == "left" and not event.pressed: self._filename_cursor = max(0, self._filename_cursor - 1) elif event.key == "right" and not event.pressed: self._filename_cursor = min(len(self._filename_text), self._filename_cursor + 1) elif event.key == "home" and not event.pressed: self._filename_cursor = 0 elif event.key == "end" and not event.pressed: self._filename_cursor = len(self._filename_text) elif event.key == "enter" and not event.pressed: self._confirm() elif event.key == "escape" and not event.pressed: self._filename_focused = False elif event.char and len(event.char) == 1: self._filename_text = ( self._filename_text[: self._filename_cursor] + event.char + self._filename_text[self._filename_cursor :] ) self._filename_cursor += 1 # ----------------------------------------------------------------- timing
[docs] def process(self, dt: float): """Update blink cursor and double-click timer.""" self._cursor_blink += dt if self._cursor_blink > 1.0: self._cursor_blink = 0.0 self._click_timer += dt
# ---------------------------------------------------------------- drawing
[docs] def draw(self, renderer): # FileDialog draws entirely via draw_popup; nothing in normal pass. pass
[docs] def draw_popup(self, renderer): """Draw the full modal dialog as an overlay.""" if not self._visible: return # Cache geometry so input handlers use the same rects we render self._cached_dialog_rect = self._dialog_rect() self._cached_list_rect = self._list_rect() renderer.new_layer() screen = self._get_parent_size() # Semi-transparent backdrop renderer.draw_filled_rect(0, 0, screen.x, screen.y, _BACKDROP_COLOR) dx, dy, dw, dh = self._cached_dialog_rect # Dialog background renderer.draw_filled_rect(dx, dy, dw, dh, _DIALOG_BG) self._draw_title_bar(renderer, dx, dy, dw) self._draw_path_bar(renderer) self._draw_file_list(renderer) self._draw_bottom_bar(renderer) # Dialog border renderer.draw_rect_coloured(dx, dy, dw, dh, (0.38, 0.38, 0.40, 1.0))
def _draw_title_bar(self, renderer, dx: float, dy: float, dw: float): """Draw the title bar with mode label and close button.""" renderer.draw_filled_rect(dx, dy, dw, _TITLE_BAR_HEIGHT, _TITLE_BG) 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_coloured(title, dx + _PADDING, text_y, scale, _TITLE_TEXT) # Close button 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_filled_rect(cx, cy, cw, ch, bg) # X mark (two small lines approximated with text) x_scale = 0.85 x_w = renderer.text_width("X", x_scale) renderer.draw_text_coloured("X", cx + (cw - x_w) / 2, cy + (ch - 12) / 2, x_scale, _TITLE_TEXT) def _draw_path_bar(self, renderer): """Draw the current path breadcrumb.""" px, py, pw, ph = self._path_rect(self._cached_dialog_rect) renderer.draw_filled_rect(px, py, pw, ph, _PATH_BG) path_str = str(self._current_dir) scale = 0.85 text_y = py + (ph - 12) / 2 # Truncate from the left if too long 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_coloured(path_str, px + _PADDING, text_y, scale, _PATH_TEXT) def _draw_file_list(self, renderer): """Draw the scrollable file entry listing.""" lx, ly, lw, lh = self._cached_list_rect renderer.draw_filled_rect(lx, ly, lw, lh, _LIST_BG) if not self._entries: scale = 0.85 msg = "(empty directory)" mw = renderer.text_width(msg, scale) renderer.draw_text_coloured(msg, lx + (lw - mw) / 2, ly + lh / 2 - 6, scale, _FILTER_TEXT) return # Determine visible range 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 # Skip rows entirely outside the visible area if row_y + _ROW_HEIGHT < ly or row_y > ly + lh: continue # Selection / hover highlight if i == self._selected_index: renderer.draw_filled_rect(lx, row_y, usable_w, _ROW_HEIGHT, _SELECTED_BG) elif i == self._hovered_index: renderer.draw_filled_rect(lx, row_y, usable_w, _ROW_HEIGHT, _HOVER_BG) # Entry text display = name + "/" if is_dir else name colour = _DIR_COLOR if is_dir else _FILE_COLOR text_y = row_y + (_ROW_HEIGHT - 12) / 2 renderer.draw_text_coloured(display, lx + _PADDING, text_y, scale, colour) # Scrollbar content_h = len(self._entries) * _ROW_HEIGHT if content_h > lh: # Track renderer.draw_filled_rect(lx + lw - _SCROLLBAR_WIDTH, ly, _SCROLLBAR_WIDTH, lh, _SCROLLBAR_BG) # Thumb 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_filled_rect(lx + lw - _SCROLLBAR_WIDTH, thumb_y, _SCROLLBAR_WIDTH, thumb_h, _SCROLLBAR_COLOR) def _draw_bottom_bar(self, renderer): """Draw the bottom bar with filename input, filter label, and buttons.""" dr = self._cached_dialog_rect bx, by, bw, bh = self._bottom_rect(dr) renderer.draw_filled_rect(bx, by, bw, bh, _BOTTOM_BG) scale = 0.85 # Filename input ix, iy, iw, ih = self._filename_input_rect(dr) renderer.draw_filled_rect(ix, iy, iw, ih, _INPUT_BG) border = _INPUT_FOCUS_BORDER if self._filename_focused else _INPUT_BORDER renderer.draw_rect_coloured(ix, iy, iw, ih, border) if self._filename_text: text_y = iy + (ih - 12) / 2 renderer.draw_text_coloured(self._filename_text, ix + 5, text_y, scale, _FILE_COLOR) else: text_y = iy + (ih - 12) / 2 placeholder = "filename..." renderer.draw_text_coloured(placeholder, ix + 5, text_y, scale, _FILTER_TEXT) # Cursor if self._filename_focused and self._cursor_blink < 0.5: cursor_x = ix + 5 + renderer.text_width(self._filename_text[: self._filename_cursor], scale) renderer.draw_filled_rect(cursor_x, iy + 4, 1, ih - 8, _FILE_COLOR) # Filter label fx, fy, fw, fh = self._filter_label_rect(dr) text_y = fy + (fh - 12) / 2 renderer.draw_text_coloured(self._filter, fx, text_y, scale, _FILTER_TEXT) # Confirm button self._draw_button( renderer, self._confirm_button_rect(dr), "Save" if self._mode in ("save", "save_as") else "Open", self._confirm_hovered, ) # Cancel button self._draw_button(renderer, self._cancel_button_rect(dr), "Cancel", self._cancel_hovered) def _draw_button(self, renderer, rect: tuple[float, float, float, float], text: str, hovered: bool): """Draw a simple button with text.""" bx, by, bw, bh = rect bg = _BUTTON_HOVER if hovered else _BUTTON_BG renderer.draw_filled_rect(bx, by, bw, bh, bg) renderer.draw_rect_coloured(bx, by, bw, bh, _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_coloured(text, text_x, text_y, scale, _BUTTON_TEXT)