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, claims_tab=False, ) bui.containerwidget( edit=self._subcontainer, claims_left_right=False, claims_tab=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)