Skip to content

Commit e9be534

Browse files
authored
🔀 Merge pull request #9 from davidfokkema/windows-drive-select
Add a drive selection widget, only on Windows
2 parents 261a41e + 6546711 commit e9be534

File tree

5 files changed

+163
-9
lines changed

5 files changed

+163
-9
lines changed

textual_fspicker/base_dialog.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
##############################################################################
44
# Python imports.
55
from __future__ import annotations
6+
7+
import sys
68
from pathlib import Path
79
from typing import Optional
810

@@ -17,7 +19,7 @@
1719

1820
##############################################################################
1921
# Local imports.
20-
from .parts import DirectoryNavigation
22+
from .parts import DirectoryNavigation, DriveNavigation
2123

2224

2325
##############################################################################
@@ -100,12 +102,19 @@ def compose(self) -> ComposeResult:
100102
"""
101103
with Dialog() as dialog:
102104
dialog.border_title = self._title
103-
yield DirectoryNavigation(self._location)
105+
with Horizontal():
106+
if sys.platform == "win32":
107+
yield DriveNavigation(self._location)
108+
yield DirectoryNavigation(self._location)
104109
with InputBar():
105110
yield from self._input_bar()
106111
yield Button(self._select_button, id="select")
107112
yield Button("Cancel", id="cancel")
108113

114+
def on_mount(self) -> None:
115+
"""Focus directory widget on mount."""
116+
self.query_one(DirectoryNavigation).focus()
117+
109118
def _set_error(self, message: str = "") -> None:
110119
"""Set or clear the error message.
111120
@@ -114,6 +123,11 @@ def _set_error(self, message: str = "") -> None:
114123
"""
115124
self.query_one(Dialog).border_subtitle = message
116125

126+
@on(DriveNavigation.DriveSelected)
127+
def _change_drive(self, event: DriveNavigation.DriveSelected) -> None:
128+
"""Reload DirectoryNavigation in response to drive change."""
129+
self.query_one(DirectoryNavigation).location = event.drive_root
130+
117131
@on(DirectoryNavigation.Changed)
118132
def _clear_error(self) -> None:
119133
"""Clear any error that might be showing."""

textual_fspicker/file_dialog.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
##############################################################################
44
# Python imports.
55
from __future__ import annotations
6+
7+
import sys
68
from pathlib import Path
79

810
##############################################################################
@@ -15,7 +17,7 @@
1517
##############################################################################
1618
# Local imports.
1719
from .base_dialog import FileSystemPickerScreen
18-
from .parts import DirectoryNavigation
20+
from .parts import DirectoryNavigation, DriveNavigation
1921
from .path_filters import Filters
2022
from .path_maker import MakePath
2123

@@ -160,9 +162,13 @@ def _confirm_file(self, event: Input.Submitted | Button.Pressed) -> None:
160162
# doing a "cd".
161163
try:
162164
if chosen.is_dir():
163-
self.query_one(Input).value = ""
165+
if sys.platform == "win32":
166+
drive = MakePath.of(file_name.value).drive
167+
if drive:
168+
self.query_one(DriveNavigation).drive = drive
164169
self.query_one(DirectoryNavigation).location = chosen
165170
self.query_one(DirectoryNavigation).focus()
171+
self.query_one(Input).value = ""
166172
return
167173
except PermissionError:
168174
self._set_error("Permission error")

textual_fspicker/parts/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
##############################################################################
44
# Local imports.
55
from .directory_navigation import DirectoryNavigation
6+
from .drive_navigation import DriveNavigation
67

78
##############################################################################
89
# Export public items.
9-
__all__ = ["DirectoryNavigation"]
10+
__all__ = ["DirectoryNavigation", "DriveNavigation"]
1011

1112
### __init__.py ends here

textual_fspicker/parts/directory_navigation.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
##############################################################################
44
# Python imports.
55
from __future__ import annotations
6+
67
from dataclasses import dataclass
78
from datetime import datetime
89
from pathlib import Path
@@ -19,8 +20,8 @@
1920
##############################################################################
2021
# Textual imports.
2122
from textual import work
22-
from textual.reactive import var
2323
from textual.message import Message
24+
from textual.reactive import var
2425
from textual.widgets import OptionList
2526
from textual.widgets.option_list import Option
2627
from textual.worker import get_current_worker
@@ -51,7 +52,7 @@ class DirectoryEntryStyling(NamedTuple):
5152

5253
##############################################################################
5354
class DirectoryEntry(Option):
54-
"""A directory entry for the `DirectoryNaviation` class."""
55+
"""A directory entry for the `DirectoryNavigation` class."""
5556

5657
FOLDER_ICON: Final[Text] = Text.from_markup(":file_folder:")
5758
"""The icon to use for a folder."""
@@ -305,8 +306,7 @@ def _settle_highlight(self) -> None:
305306
@property
306307
def is_root(self) -> bool:
307308
"""Are we at the root of the filesystem?"""
308-
# TODO: Worry about portability.
309-
return self._location == MakePath.of(self._location.root)
309+
return self._location == MakePath.of(self._location.parent)
310310

311311
@staticmethod
312312
def is_hidden(path: Path) -> bool:
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Provides a widget for drive navigation."""
2+
3+
##############################################################################
4+
# Python imports.
5+
import sys
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
9+
try:
10+
from os import listdrives # type: ignore[attr-defined]
11+
except ImportError:
12+
import string
13+
14+
def listdrives() -> list[str]:
15+
"""Return a list containing the names of drives in the system.
16+
17+
A drive name typically looks like 'C:\\'. This is an implementation for
18+
Python versions before 3.12. It does _not_ list removable drives which
19+
have no media inserted.
20+
21+
Returns:
22+
list[str]: The list of available drives.
23+
"""
24+
return [
25+
f"{letter}:"
26+
for letter in string.ascii_uppercase
27+
if Path(f"{letter}:\\").exists()
28+
]
29+
30+
31+
##############################################################################
32+
# Textual imports.
33+
from textual import on
34+
from textual.message import Message
35+
from textual.reactive import var
36+
from textual.widgets import OptionList
37+
from textual.widgets.option_list import Option
38+
39+
##############################################################################
40+
# Local imports.
41+
from ..path_maker import MakePath
42+
43+
44+
##############################################################################
45+
class DriveEntry(Option):
46+
"""A drive entry for the `DriveNavigation` class."""
47+
48+
def __init__(self, drive: Path | str) -> None:
49+
self.drive_root: Path = MakePath.of(drive)
50+
"""The drive root for this entry."""
51+
super().__init__(self.drive_root.drive, id=self.drive_root.drive)
52+
53+
54+
class DriveNavigation(OptionList):
55+
"""A drive navigation widget.
56+
57+
Provides a single-pane widget that lets the user select a drive. This is
58+
very useful in combination with the `DirectoryNavigation` widget. A dialog can
59+
reload that widget in response to drive selection changes.
60+
"""
61+
62+
DEFAULT_CSS = """
63+
DriveNavigation, DriveNavigation:focus {
64+
border: blank;
65+
border-right: $panel-darken-1;
66+
width: 10;
67+
height: 100%;
68+
}
69+
70+
DriveNavigation > .option-list--option-highlighted {
71+
text-style: bold;
72+
color: white;
73+
}
74+
"""
75+
"""Default styling for the widget."""
76+
77+
@dataclass
78+
class DriveSelected(Message):
79+
"""Message sent when a drive is selected."""
80+
81+
drive_root: Path
82+
"""The selected drive root, like `c:\\`."""
83+
84+
drive: var[str] = var[str](MakePath.of(".").absolute().drive, init=False)
85+
86+
def __init__(self, location: Path | str = ".") -> None:
87+
"""Initialise the drive navigation widget.
88+
89+
Args:
90+
location: The starting location.
91+
"""
92+
super().__init__()
93+
self.set_reactive(DriveNavigation.drive, MakePath.of(location).absolute().drive)
94+
if sys.platform == "win32":
95+
self._entries = [DriveEntry(drive) for drive in listdrives()]
96+
else:
97+
self._entries: list[DriveEntry] = []
98+
99+
def on_mount(self) -> None:
100+
"""Add available drives to the widget."""
101+
self.add_options(self._entries)
102+
self.highlight_drive(self.drive)
103+
104+
def _watch_drive(self, drive: str) -> None:
105+
"""Highlight the new drive.
106+
107+
Args:
108+
drive: The new value of the current drive.
109+
"""
110+
self.highlight_drive(drive)
111+
112+
def highlight_drive(self, drive: str) -> None:
113+
"""Highlight the given drive.
114+
115+
Args:
116+
drive: The drive to be highlighted.
117+
"""
118+
self.highlighted = self.get_option_index(drive.upper())
119+
120+
@on(OptionList.OptionSelected)
121+
def drive_selected(self, event: OptionList.OptionSelected) -> None:
122+
"""Post a DriveSelected message.
123+
124+
Args:
125+
event: The drive selected event from the parent `OptionList`.
126+
"""
127+
assert isinstance(event.option, DriveEntry)
128+
event.stop()
129+
self.drive = event.option.drive_root.drive
130+
self.post_message(self.DriveSelected(drive_root=event.option.drive_root))
131+
132+
133+
### drive_navigation.py ends here

0 commit comments

Comments
 (0)