# Released under the MIT License. See LICENSE for details.
#
"""UI functionality for selecting files."""
from __future__ import annotations
import os
import time
import logging
from threading import Thread
from typing import TYPE_CHECKING, override
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
[docs]
class FileSelectorWindow(bui.MainWindow):
"""Window for selecting files."""
def __init__(
self,
path: str,
callback: Callable[[str | None], Any] | None = None,
*,
show_base_path: bool = True,
valid_file_extensions: Sequence[str] | None = None,
allow_folders: bool = False,
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
):
if valid_file_extensions is None:
valid_file_extensions = []
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._width = 850 if uiscale is bui.UIScale.SMALL else 600
self._x_inset = x_inset = 100 if uiscale is bui.UIScale.SMALL else 0
self._height = 365 if uiscale is bui.UIScale.SMALL else 418
self._callback = callback
self._base_path = path
self._path: str | None = None
self._recent_paths: list[str] = []
self._show_base_path = show_base_path
self._valid_file_extensions = [
'.' + ext for ext in valid_file_extensions
]
self._allow_folders = allow_folders
self._subcontainer: bui.Widget | None = None
self._subcontainerheight: float | None = None
self._scroll_width = self._width - (80 + 2 * x_inset)
self._scroll_height = self._height - 170
self._r = 'fileSelectorWindow'
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height),
scale=(
1.93
if uiscale is bui.UIScale.SMALL
else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, -35) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 42),
size=(0, 0),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
text=(
bui.Lstr(resource=f'{self._r}.titleFolderText')
if (allow_folders and not valid_file_extensions)
else (
bui.Lstr(resource=f'{self._r}.titleFileText')
if not allow_folders
else bui.Lstr(resource=f'{self._r}.titleFileFolderText')
)
),
maxwidth=210,
)
self._button_width = 146
self._cancel_button = bui.buttonwidget(
parent=self._root_widget,
position=(35 + x_inset, self._height - 67),
autoselect=True,
size=(self._button_width, 50),
label=bui.Lstr(resource='cancelText'),
on_activate_call=self._cancel,
)
bui.widget(edit=self._cancel_button, left_widget=self._cancel_button)
b_color = (0.6, 0.53, 0.63)
self._back_button = bui.buttonwidget(
parent=self._root_widget,
button_type='square',
position=(43 + x_inset, self._height - 113),
color=b_color,
textcolor=(0.75, 0.7, 0.8),
enable_sound=False,
size=(55, 35),
label=bui.charstr(bui.SpecialChar.LEFT_ARROW),
on_activate_call=self._on_back_press,
)
self._folder_tex = bui.gettexture('folder')
self._folder_color = (1.1, 0.8, 0.2)
self._file_tex = bui.gettexture('file')
self._file_color = (1, 1, 1)
self._use_folder_button: bui.Widget | None = None
self._folder_center = self._width * 0.5 + 15
self._folder_icon = bui.imagewidget(
parent=self._root_widget,
size=(40, 40),
position=(40, self._height - 117),
texture=self._folder_tex,
color=self._folder_color,
)
self._path_text = bui.textwidget(
parent=self._root_widget,
position=(self._folder_center, self._height - 98),
size=(0, 0),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
text=self._path,
maxwidth=self._width * 0.9,
)
self._scrollwidget: bui.Widget | None = None
bui.containerwidget(
edit=self._root_widget, cancel_button=self._cancel_button
)
self._set_path(path)
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
# Pull everything out of self here. If we do it below in the lambda,
# we'll keep self alive which is bad.
path = self._base_path
callback = self._callback
show_base_path = self._show_base_path
valid_file_extensions = self._valid_file_extensions
allow_folders = self._allow_folders
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
path=path,
callback=callback,
show_base_path=show_base_path,
valid_file_extensions=valid_file_extensions,
allow_folders=allow_folders,
)
)
def _on_up_press(self) -> None:
self._on_entry_activated('..')
def _on_back_press(self) -> None:
if len(self._recent_paths) > 1:
bui.getsound('swish').play()
self._recent_paths.pop()
self._set_path(self._recent_paths.pop())
else:
bui.getsound('error').play()
def _on_folder_entry_activated(self) -> None:
if self._callback is not None:
assert self._path is not None
self._callback(self._path)
def _on_entry_activated(self, entry: str) -> None:
# pylint: disable=too-many-branches
new_path = None
try:
assert self._path is not None
if entry == '..':
chunks = self._path.split('/')
if len(chunks) > 1:
new_path = '/'.join(chunks[:-1])
if new_path == '':
new_path = '/'
else:
bui.getsound('error').play()
else:
if self._path == '/':
test_path = self._path + entry
else:
test_path = self._path + '/' + entry
if os.path.isdir(test_path):
bui.getsound('swish').play()
new_path = test_path
elif os.path.isfile(test_path):
if self._is_valid_file_path(test_path):
bui.getsound('swish').play()
if self._callback is not None:
self._callback(test_path)
else:
bui.getsound('error').play()
else:
print(
(
'Error: FileSelectorWindow found non-file/dir:',
test_path,
)
)
except Exception:
logging.exception(
'Error in FileSelectorWindow._on_entry_activated().'
)
if new_path is not None:
self._set_path(new_path)
class _RefreshThread(Thread):
def __init__(
self, path: str, callback: Callable[[list[str], str | None], Any]
):
super().__init__()
self._callback = callback
self._path = path
@override
def run(self) -> None:
try:
starttime = time.time()
files = os.listdir(self._path)
duration = time.time() - starttime
min_time = 0.1
# Make sure this takes at least 1/10 second so the user
# has time to see the selection highlight.
if duration < min_time:
time.sleep(min_time - duration)
bui.pushcall(
bui.Call(self._callback, files, None),
from_other_thread=True,
)
except Exception as exc:
# Ignore permission-denied.
if 'Errno 13' not in str(exc):
logging.exception('Error in fileselector refresh thread.')
nofiles: list[str] = []
bui.pushcall(
bui.Call(self._callback, nofiles, str(exc)),
from_other_thread=True,
)
def _set_path(self, path: str, add_to_recent: bool = True) -> None:
self._path = path
if add_to_recent:
self._recent_paths.append(path)
self._RefreshThread(path, self._refresh).start()
def _refresh(self, file_names: list[str], error: str | None) -> None:
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
if not self._root_widget:
return
scrollwidget_selected = (
self._scrollwidget is None
or self._root_widget.get_selected_child() == self._scrollwidget
)
in_top_folder = self._path == self._base_path
hide_top_folder = in_top_folder and self._show_base_path is False
if hide_top_folder:
folder_name = ''
elif self._path == '/':
folder_name = '/'
else:
assert self._path is not None
folder_name = os.path.basename(self._path)
b_color = (0.6, 0.53, 0.63)
b_color_disabled = (0.65, 0.65, 0.65)
if len(self._recent_paths) < 2:
bui.buttonwidget(
edit=self._back_button,
color=b_color_disabled,
textcolor=(0.5, 0.5, 0.5),
)
else:
bui.buttonwidget(
edit=self._back_button,
color=b_color,
textcolor=(0.75, 0.7, 0.8),
)
max_str_width = 300.0
str_width = min(
max_str_width,
bui.get_string_width(folder_name, suppress_warning=True),
)
bui.textwidget(
edit=self._path_text, text=folder_name, maxwidth=max_str_width
)
bui.imagewidget(
edit=self._folder_icon,
position=(
self._folder_center - str_width * 0.5 - 40,
self._height - 117,
),
opacity=0.0 if hide_top_folder else 1.0,
)
if self._scrollwidget is not None:
self._scrollwidget.delete()
if self._use_folder_button is not None:
self._use_folder_button.delete()
bui.widget(edit=self._cancel_button, right_widget=self._back_button)
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
position=(
(self._width - self._scroll_width) * 0.5,
self._height - self._scroll_height - 119,
),
size=(self._scroll_width, self._scroll_height),
)
if scrollwidget_selected:
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
# show error case..
if error is not None:
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(self._scroll_width, self._scroll_height),
background=False,
)
bui.textwidget(
parent=self._subcontainer,
color=(1, 1, 0, 1),
text=error,
maxwidth=self._scroll_width * 0.9,
position=(
self._scroll_width * 0.48,
self._scroll_height * 0.57,
),
size=(0, 0),
h_align='center',
v_align='center',
)
else:
file_names = [f for f in file_names if not f.startswith('.')]
file_names.sort(key=lambda x: x[0].lower())
entries = file_names
entry_height = 35
folder_entry_height = 100
show_folder_entry = False
show_use_folder_button = self._allow_folders and not in_top_folder
self._subcontainerheight = entry_height * len(entries) + (
folder_entry_height if show_folder_entry else 0
)
v = self._subcontainerheight - (
folder_entry_height if show_folder_entry else 0
)
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(self._scroll_width, self._subcontainerheight),
background=False,
)
bui.containerwidget(
edit=self._scrollwidget,
claims_left_right=False,
)
bui.containerwidget(
edit=self._subcontainer,
claims_left_right=False,
selection_loops=False,
print_list_exit_instructions=False,
)
bui.widget(edit=self._subcontainer, up_widget=self._back_button)
if show_use_folder_button:
self._use_folder_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(
self._width - self._button_width - 35 - self._x_inset,
self._height - 67,
),
size=(self._button_width, 50),
label=bui.Lstr(
resource=f'{self._r}.useThisFolderButtonText'
),
on_activate_call=self._on_folder_entry_activated,
)
bui.widget(
edit=btn,
left_widget=self._cancel_button,
down_widget=self._scrollwidget,
)
bui.widget(edit=self._cancel_button, right_widget=btn)
bui.containerwidget(edit=self._root_widget, start_button=btn)
folder_icon_size = 35
for num, entry in enumerate(entries):
cnt = bui.containerwidget(
parent=self._subcontainer,
position=(0, v - entry_height),
size=(self._scroll_width, entry_height),
root_selectable=True,
background=False,
click_activate=True,
on_activate_call=bui.Call(self._on_entry_activated, entry),
)
if num == 0:
bui.widget(edit=cnt, up_widget=self._back_button)
is_valid_file_path = self._is_valid_file_path(entry)
assert self._path is not None
is_dir = os.path.isdir(self._path + '/' + entry)
if is_dir:
bui.imagewidget(
parent=cnt,
size=(folder_icon_size, folder_icon_size),
position=(
10,
0.5 * entry_height - folder_icon_size * 0.5,
),
draw_controller=cnt,
texture=self._folder_tex,
color=self._folder_color,
)
else:
bui.imagewidget(
parent=cnt,
size=(folder_icon_size, folder_icon_size),
position=(
10,
0.5 * entry_height - folder_icon_size * 0.5,
),
opacity=1.0 if is_valid_file_path else 0.5,
draw_controller=cnt,
texture=self._file_tex,
color=self._file_color,
)
bui.textwidget(
parent=cnt,
draw_controller=cnt,
text=entry,
h_align='left',
v_align='center',
position=(10 + folder_icon_size * 1.05, entry_height * 0.5),
size=(0, 0),
maxwidth=self._scroll_width * 0.93 - 50,
color=(
(1, 1, 1, 1)
if (is_valid_file_path or is_dir)
else (0.5, 0.5, 0.5, 1)
),
)
v -= entry_height
def _is_valid_file_path(self, path: str) -> bool:
return any(
path.lower().endswith(ext) for ext in self._valid_file_extensions
)
def _cancel(self) -> None:
self.main_window_back()
if self._callback is not None:
self._callback(None)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum