Source code for bauiv1lib.fileselector

# 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