"""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)