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