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