Skip to main content

Clickable file links for Textual applications.

Project description

textual-filelink

CI PyPI Python Versions Downloads Coverage License

Clickable file links for Textual applications. Open files in your editor directly from your TUI.

Features

  • ๐Ÿ”— Clickable file links that open in your preferred editor (VSCode, vim, nano, etc.)
  • โ˜‘๏ธ Toggle controls for selecting/deselecting files
  • โŒ Remove buttons for file management interfaces
  • ๐ŸŽจ Multiple status icons with unicode support for rich visual feedback
  • ๐Ÿ“ Icon positioning - place icons before or after filenames
  • ๐Ÿ”ข Icon ordering - control display order with explicit indices
  • ๐Ÿ‘† Clickable icons - make icons interactive with click events
  • ๐Ÿ‘๏ธ Dynamic visibility - show/hide icons on the fly
  • ๐ŸŽฏ Jump to specific line and column in your editor
  • ๐Ÿ”ง Customizable command builders for any editor
  • ๐ŸŽญ Flexible layouts - show/hide controls as needed
  • ๐Ÿ’ฌ Tooltips for all interactive elements
  • ๐Ÿš€ Command orchestration with play/stop controls and animated spinners
  • โŒจ๏ธ Keyboard accessible - fully tabbable and navigable without a mouse

Installation

pip install textual-filelink

Or with PDM:

pdm add textual-filelink

Quick Start

Basic FileLink

from pathlib import Path
from textual.app import App, ComposeResult
from textual_filelink import FileLink

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield FileLink("README.md", line=10, column=5)
    
    def on_file_link_clicked(self, event: FileLink.Clicked):
        self.notify(f"Opened {event.path.name} at line {event.line}")

if __name__ == "__main__":
    MyApp().run()

ToggleableFileLink with Multiple Icons

from textual_filelink import ToggleableFileLink

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield ToggleableFileLink(
            "script.py",
            initial_toggle=True,
            show_toggle=True,
            show_remove=True,
            icons=[
                {"name": "status", "icon": "โœ“", "clickable": True, "tooltip": "Validated"},
                {"name": "lock", "icon": "๐Ÿ”’", "position": "after", "tooltip": "Read-only"},
                {"name": "modified", "icon": "๐Ÿ“", "visible": False, "tooltip": "Modified"},
            ],
            toggle_tooltip="Toggle selection",
            remove_tooltip="Remove file",
            line=42
        )
    
    def on_toggleable_file_link_toggled(self, event: ToggleableFileLink.Toggled):
        self.notify(f"{event.path.name} toggled: {event.is_toggled}")
    
    def on_toggleable_file_link_removed(self, event: ToggleableFileLink.Removed):
        self.notify(f"{event.path.name} removed")
    
    def on_toggleable_file_link_icon_clicked(self, event: ToggleableFileLink.IconClicked):
        self.notify(f"Clicked '{event.icon_name}' icon on {event.path.name}")
        
        # Example: dynamically update icon
        link = event.control
        link.update_icon("status", icon="โณ", tooltip="Processing...")

if __name__ == "__main__":
    MyApp().run()

CommandLink for Command Orchestration

โœ… โ–ถ๏ธ Build โš™๏ธ ร— - last run successful, play button ot start again, command name ("Build"), settings icon, remove icon โŒ โ–ถ๏ธ Build โš™๏ธ ร— - last run failed, play button to start agai1n, command name ("Build"), settings icon, remove icon โ ง โน๏ธ Build โš™๏ธ ร— - spinner, stop button to cancel run, command name ("Build"), settings icon, remove icon

from textual_filelink import CommandLink

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield CommandLink(
            "Run Tests",
            initial_status_icon="โ“",
            initial_status_tooltip="Not run yet",
        )

    def on_command_link_play_clicked(self, event: CommandLink.PlayClicked):
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(running=True, tooltip="Running...")
        # Start your command here

    def on_command_link_stop_clicked(self, event: CommandLink.StopClicked):
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(icon="โน", running=False, tooltip="Stopped")

if __name__ == "__main__":
    MyApp().run()

Keyboard Navigation

Tab Navigation

All FileLink widgets are fully keyboard accessible and can be navigated using standard terminal keyboard shortcuts:

  • Tab - Move focus to the next widget
  • Shift+Tab - Move focus to the previous widget

When a FileLink widget has focus, it displays a visual indicator (border with accent color). You can customize the focus appearance using CSS.

Built-in Keyboard Shortcuts

All FileLink widgets support keyboard activation:

FileLink:

  • o - Open file in editor

ToggleableFileLink:

  • o - Open file in editor
  • Space or t - Toggle checkbox
  • Delete or x - Remove widget
  • 1-9 - Activate clickable icons (in order of appearance)

CommandLink:

  • o - Open output file (if path is set)
  • Space or p - Play/Stop command
  • s - Settings
  • t - Toggle checkbox
  • Delete or x - Remove widget

Customizing Keyboard Shortcuts

Override the BINDINGS class variable in a subclass to customize keyboard shortcuts:

from textual.binding import Binding
from textual_filelink import FileLink

class MyFileLink(FileLink):
    BINDINGS = [
        Binding("enter", "open_file", "Open"),  # Use Enter instead of 'o'
        Binding("ctrl+o", "open_file", "Open"), # Add Ctrl+O
    ]

Dynamic App-Level Bindings

Bind number keys to activate specific widgets in a list without requiring focus (useful for scrollable lists of commands):

from textual import events
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual_filelink import CommandLink

class MyApp(App):
    def compose(self) -> ComposeResult:
        with ScrollableContainer():
            yield CommandLink("Build")   # Press 1 to toggle play/stop
            yield CommandLink("Test")    # Press 2 to toggle play/stop
            yield CommandLink("Deploy")  # Press 3 to toggle play/stop

    def on_key(self, event: events.Key) -> None:
        """Route number keys to commands - triggers play/stop toggle."""
        if event.key.isdigit():
            num = int(event.key)
            commands = list(self.query(CommandLink))
            if 0 < num <= len(commands):
                cmd = commands[num - 1]
                # Use action method to toggle play/stop automatically
                cmd.action_play_stop()
                event.prevent_default()

How it works:

  • Number keys work globally - no need to Tab to the widget first
  • Pressing '1' toggles the first command between play and stop
  • Pressing '2' toggles the second command, etc.
  • Uses the widget's action_play_stop() method which handles state checking internally (checks if running and posts appropriate message)

Alternative approaches:

If you need more control over the behavior, you can manually post messages:

def on_key(self, event: events.Key) -> None:
    if event.key.isdigit():
        num = int(event.key)
        commands = list(self.query(CommandLink))
        if 0 < num <= len(commands):
            cmd = commands[num - 1]

            # Option 1: Always play (ignore if already running)
            cmd.post_message(CommandLink.PlayClicked(
                cmd.output_path, cmd.name, cmd.output_path, cmd.is_toggled
            ))

            # Option 2: Always stop (ignore if not running)
            cmd.post_message(CommandLink.StopClicked(
                cmd.output_path, cmd.name, cmd.output_path, cmd.is_toggled
            ))

            # Option 3: Custom logic based on state
            if cmd.is_running:
                cmd.post_message(CommandLink.StopClicked(
                    cmd.output_path, cmd.name, cmd.output_path, cmd.is_toggled
                ))
            else:
                cmd.post_message(CommandLink.PlayClicked(
                    cmd.output_path, cmd.name, cmd.output_path, cmd.is_toggled
                ))

            event.prevent_default()

For ToggleableFileLink:

The same pattern works for other widget actions:

from textual_filelink import ToggleableFileLink

class MyApp(App):
    def on_key(self, event: events.Key) -> None:
        if event.key.isdigit():
            num = int(event.key)
            links = list(self.query(ToggleableFileLink))
            if 0 < num <= len(links):
                link = links[num - 1]

                # Open the file
                link.action_open_file()

                # Or toggle checkbox
                link.action_toggle()

                # Or remove
                link.action_remove()

                event.prevent_default()

Complete Example

class KeyboardAccessibleApp(App):
    def compose(self) -> ComposeResult:
        yield FileLink("file1.py", name="link1")
        yield FileLink("file2.py", name="link2")
        yield ToggleableFileLink("file3.py", name="link3")
        yield CommandLink("Run Tests", name="cmd1")

if __name__ == "__main__":
    # Now fully keyboard accessible!
    # Tab to navigate, o/space/p/etc to activate
    KeyboardAccessibleApp().run()

Keyboard Shortcut Discoverability

All interactive elements automatically display their keyboard shortcuts in tooltips. This makes keyboard navigation discoverable without reading documentation.

Examples:

  • Toggle checkbox: "Click to toggle (space/t)"
  • Remove button: "Remove (delete/x)"
  • Play button: "Run command (p/space)"
  • Settings: "Settings (s)"
  • Clickable icon 1: "Status (1)"

How it works: Tooltips are automatically enhanced with keyboard shortcuts based on the widget's BINDINGS. When you hover over or focus on an interactive element, the tooltip displays both the description and the available keyboard shortcuts.

Custom Bindings: If you override BINDINGS in a subclass, tooltips will automatically reflect your custom keys:

class CustomFileLink(FileLink):
    BINDINGS = [
        Binding("enter", "open_file", "Open"),
    ]

# Tooltip will show "(enter)" instead of "(o)"
link = CustomFileLink("file.txt")

FileLink API

Constructor

FileLink(
    path: Path | str,
    *,
    line: int | None = None,
    column: int | None = None,
    command_builder: Callable | None = None,
    name: str | None = None,
    id: str | None = None,
    classes: str | None = None,
)

Parameters:

  • path: Full path to the file
  • line: Optional line number to jump to
  • column: Optional column number to jump to
  • command_builder: Custom function to build the editor command

Properties

  • path: Path - The file path
  • line: int | None - The line number
  • column: int | None - The column number

Messages

FileLink.Clicked

Posted when the link is clicked.

Attributes:

  • path: Path
  • line: int | None
  • column: int | None

ToggleableFileLink API

Constructor

ToggleableFileLink(
    path: Path | str,
    *,
    initial_toggle: bool = False,
    show_toggle: bool = True,
    show_remove: bool = True,
    icons: list[IconConfig | dict] | None = None,
    line: int | None = None,
    column: int | None = None,
    command_builder: Callable | None = None,
    disable_on_untoggle: bool = False,
    toggle_tooltip: str | None = None,
    remove_tooltip: str | None = None,
    name: str | None = None,
    id: str | None = None,
    classes: str | None = None,
)

Parameters:

  • path: Full path to the file
  • initial_toggle: Whether the item starts toggled (checked)
  • show_toggle: Whether to display the toggle control (โ˜/โ˜‘)
  • show_remove: Whether to display the remove button (ร—)
  • icons: List of icon configurations (see IconConfig below)
  • line: Optional line number to jump to
  • column: Optional column number to jump to
  • command_builder: Custom function to build the editor command
  • disable_on_untoggle: If True, dim/disable the link when untoggled
  • toggle_tooltip: Optional tooltip text for the toggle button
  • remove_tooltip: Optional tooltip text for the remove button

IconConfig

Icons can be specified as dicts or IconConfig dataclasses:

from textual_filelink.toggleable_file_link import IconConfig

# As dict
{"name": "status", "icon": "โœ“", "clickable": True, "tooltip": "Done"}

# As dataclass
IconConfig(name="status", icon="โœ“", clickable=True, tooltip="Done")

IconConfig Properties:

  • name (str, required): Unique identifier for the icon
  • icon (str, required): Unicode character to display
  • position (str): "before" or "after" the filename (default: "before")
  • index (int | None): Explicit ordering index (default: None = use list order)
  • visible (bool): Whether icon is initially visible (default: True)
  • clickable (bool): Whether icon posts IconClicked messages (default: False)
  • tooltip (str | None): Tooltip text (default: None)

Properties

  • path: Path - The file path
  • is_toggled: bool - Current toggle state
  • icons: list[dict] - List of all icon configurations
  • file_link: FileLink - The internal FileLink widget

Methods

set_icon_visible(name: str, visible: bool)

Show or hide a specific icon.

link.set_icon_visible("warning", True)   # Show icon
link.set_icon_visible("warning", False)  # Hide icon

update_icon(name: str, **kwargs)

Update any properties of an existing icon.

link.update_icon("status", icon="โœ“", tooltip="Complete")
link.update_icon("status", visible=False)
link.update_icon("status", position="after", index=5)

Updatable properties: icon, position, index, visible, clickable, tooltip

get_icon(name: str) -> dict | None

Get a copy of an icon's configuration.

config = link.get_icon("status")
if config:
    print(f"Icon: {config['icon']}, Visible: {config['visible']}")

set_toggle_tooltip(tooltip: str | None)

Update the toggle button tooltip.

set_remove_tooltip(tooltip: str | None)

Update the remove button tooltip.

Messages

ToggleableFileLink.Toggled

Posted when the toggle state changes.

Attributes:

  • path: Path
  • is_toggled: bool

ToggleableFileLink.Removed

Posted when the remove button is clicked.

Attributes:

  • path: Path

ToggleableFileLink.IconClicked

Posted when a clickable icon is clicked.

Attributes:

  • path: Path
  • icon_name: str - The name of the clicked icon
  • icon: str - The unicode character of the icon

CommandLink API

CommandLink is a specialized widget for command orchestration and status display, extending ToggleableFileLink. It's designed for single-instance commands (not multiple concurrent runs of the same command).

Quick Start

from textual_filelink import CommandLink

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield CommandLink(
            "Run Tests",
            output_path="test_output.log",
            initial_status_icon="โ“",
            initial_status_tooltip="Not run yet",
            show_toggle=True,
            show_settings=True,
            show_remove=True,
        )

    def on_command_link_play_clicked(self, event: CommandLink.PlayClicked):
        # Event provides full context: name, path, output_path, is_toggled
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(running=True, tooltip="Running tests...")
        self.run_worker(self.run_tests(link))

    def on_command_link_stop_clicked(self, event: CommandLink.StopClicked):
        # Event provides full context including toggle state
        self.notify(f"Stopping {event.name}")

    def on_command_link_settings_clicked(self, event: CommandLink.SettingsClicked):
        # Event provides full context for configuration
        self.notify(f"Settings for {event.name}")

    async def run_tests(self, link: CommandLink):
        # Simulate test run
        await asyncio.sleep(2)
        link.set_status(icon="โœ…", running=False, tooltip="All tests passed")
        link.set_output_path(Path("test_output.log"), tooltip="Click to view results")

Constructor

CommandLink(
    name: str,
    output_path: Path | str | None = None,
    *,
    initial_toggle: bool = False,
    initial_status_icon: str = "โ“",
    initial_status_tooltip: str | None = None,
    running: bool = False,
    show_toggle: bool = True,
    show_settings: bool = True,
    show_remove: bool = True,
    toggle_tooltip: str | None = None,
    settings_tooltip: str | None = None,
    remove_tooltip: str | None = None,
    command_builder: Callable | None = None,
    disable_on_untoggle: bool = False,
)

Parameters:

  • name: Command display name (also used as widget ID)
  • output_path: Path to output file. If None, clicking command name does nothing
  • initial_toggle: Whether the command starts toggled/selected
  • initial_status_icon: Initial status icon (default: "โ“")
  • initial_status_tooltip: Initial tooltip for status icon
  • running: Whether command is currently running. If True, shows spinner and stop button
  • show_toggle: Whether to show the toggle checkbox
  • show_settings: Whether to show the settings icon
  • show_remove: Whether to show the remove button
  • toggle_tooltip: Tooltip for toggle checkbox
  • settings_tooltip: Tooltip for settings icon
  • remove_tooltip: Tooltip for remove button
  • command_builder: Custom command builder for opening output files
  • disable_on_untoggle: If True, dim/disable when untoggled

Layout

[toggle] [status/spinner] [play/stop] command_name [settings] [remove]
  • toggle: Checkbox for selecting commands (inherited from ToggleableFileLink)
  • status/spinner: Shows status icon, or animated spinner when running
  • play/stop: โ–ถ when stopped, โน when running
  • command_name: Clickable link to output file (if set)
  • settings: โš™ icon for configuration
  • remove: ร— button (inherited from ToggleableFileLink)

Properties

  • name: str - The command name (alias for display_name)
  • display_name: str - The command display name (e.g., "Test", "Build")
  • output_path: Path | None - Current output file path
  • path: Path | None - The output file path (returns the actual output path, not a display path)
  • is_running: bool - Whether the command is currently running
  • is_toggled: bool - Current toggle state (inherited)

Methods

set_status(icon: str | None = None, tooltip: str | None = None, running: bool | None = None)

Update command status display.

# Start running (shows spinner)
link.set_status(running=True, tooltip="Running tests...")

# Complete with success
link.set_status(icon="โœ…", running=False, tooltip="All tests passed")

# Complete with failure
link.set_status(icon="โŒ", running=False, tooltip="3 tests failed")

# Update tooltip only
link.set_status(tooltip="Still running...")

set_output_path(path: Path | str | None, tooltip: str | None = None)

Update the output file path.

link.set_output_path(Path("output.log"), tooltip="Click to view output")
link.set_output_path(None)  # Clear output path

set_toggle(toggled: bool, tooltip: str | None = None)

Update toggle state programmatically.

link.set_toggle(True, tooltip="Selected for batch run")
link.set_toggle(False)

set_settings_tooltip(tooltip: str | None)

Update settings icon tooltip.

link.set_settings_tooltip("Configure test options")

Messages

CommandLink.PlayClicked

Posted when play button (โ–ถ) is clicked.

Attributes:

  • name: str - The command name
  • path: Path | None - The output file path (or None if not set)
  • output_path: Path | None - The output file path (same as path)
  • is_toggled: bool - Whether the command is selected for batch run

CommandLink.StopClicked

Posted when stop button (โน) is clicked.

Attributes:

  • name: str - The command name
  • path: Path | None - The output file path (or None if not set)
  • output_path: Path | None - The output file path (same as path)
  • is_toggled: bool - Whether the command is selected for batch run

CommandLink.SettingsClicked

Posted when settings icon (โš™) is clicked.

Attributes:

  • name: str - The command name
  • path: Path | None - The output file path (or None if not set)
  • output_path: Path | None - The output file path (same as path)
  • is_toggled: bool - Whether the command is selected for batch run

Inherited Messages:

  • ToggleableFileLink.Toggled - When toggle state changes
  • ToggleableFileLink.Removed - When remove button is clicked

Status Icons

Common status icons for commands:

"โ“"  # Not run / Unknown
"โœ…"  # Success / Passed
"โŒ"  # Failed / Error
"โš ๏ธ"  # Warning
"โญ๏ธ"  # Skipped
"๐Ÿ”„"  # Needs rerun

Complete Example

from pathlib import Path
import asyncio
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Static
from textual_filelink import CommandLink

class CommandRunnerApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    Vertical {
        width: 60;
        height: auto;
        border: solid green;
        padding: 1;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()

        with Vertical():
            yield Static("๐Ÿš€ Command Runner")

            yield CommandLink(
                "Unit Tests",
                initial_status_icon="โ“",
                initial_status_tooltip="Not run",
                settings_tooltip="Configure test options",
            )

            yield CommandLink(
                "Lint",
                initial_status_icon="โ“",
                initial_status_tooltip="Not run",
                show_settings=False,
            )

            yield CommandLink(
                "Build",
                initial_status_icon="โ“",
                initial_status_tooltip="Not run",
                settings_tooltip="Build configuration",
            )

        yield Footer()

    def on_command_link_play_clicked(self, event: CommandLink.PlayClicked):
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(running=True, tooltip=f"Running {event.name}...")
        self.run_worker(self.simulate_command(link, event.name))

    def on_command_link_stop_clicked(self, event: CommandLink.StopClicked):
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(icon="โน", running=False, tooltip="Stopped")
        self.notify(f"Stopped {event.name}", severity="warning")

    def on_command_link_settings_clicked(self, event: CommandLink.SettingsClicked):
        self.notify(f"Settings for {event.name}")

    async def simulate_command(self, link: CommandLink, name: str):
        await asyncio.sleep(2)
        # Simulate success/failure
        import random
        if random.random() > 0.3:
            link.set_status(icon="โœ…", running=False, tooltip="Passed")
            link.set_output_path(Path(f"{name.lower().replace(' ', '_')}.log"))
            self.notify(f"{name} passed!", severity="information")
        else:
            link.set_status(icon="โŒ", running=False, tooltip="Failed")
            self.notify(f"{name} failed!", severity="error")

if __name__ == "__main__":
    CommandRunnerApp().run()

Using Enriched Message Properties

As of version 0.2.0, CommandLink messages include enriched context that eliminates the need to query widgets in event handlers:

class SmartCommandApp(App):
    def compose(self) -> ComposeResult:
        yield CommandLink("Tests", initial_status_icon="โ“", show_toggle=True)
        yield CommandLink("Build", initial_status_icon="โ“", initial_toggle=True)

    def on_command_link_play_clicked(self, event: CommandLink.PlayClicked):
        """Event provides full context about the command."""
        # Instead of querying: link = self.query_one(f"#{event.name}", CommandLink)
        # Just use the event properties:

        self.log(f"Playing {event.name}")
        self.log(f"Output path: {event.path}")        # Path to output file
        self.log(f"Is toggled: {event.is_toggled}")   # Selected for batch run?

        # You still need to query for widget methods (like set_status),
        # but now you have context directly from the message
        link = self.query_one(f"#{event.name}", CommandLink)
        link.set_status(running=True, tooltip="Running...")

    def on_command_link_stop_clicked(self, event: CommandLink.StopClicked):
        """Stop button includes state context."""
        # All message types (PlayClicked, StopClicked, SettingsClicked)
        # include: name, path, output_path, is_toggled
        self.notify(f"Stopping {event.name} (toggled={event.is_toggled})")

    def on_command_link_settings_clicked(self, event: CommandLink.SettingsClicked):
        """Settings event has full context."""
        # Can now make decisions based on command state without querying
        if event.is_toggled:
            self.notify(f"Settings for {event.name} (part of batch run)")
        else:
            self.notify(f"Settings for {event.name} (standalone)")

Custom Editor Commands

Using Built-in Command Builders

from textual_filelink import FileLink

# Set default for all FileLink instances
FileLink.default_command_builder = FileLink.vim_command

# Or per instance
link = FileLink(path, command_builder=FileLink.nano_command)

Available builders:

  • FileLink.vscode_command - VSCode (default)
  • FileLink.vim_command - Vim
  • FileLink.nano_command - Nano
  • FileLink.eclipse_command - Eclipse
  • FileLink.copy_path_command - Copy path to clipboard

Custom Command Builder

def my_editor_command(path: Path, line: int | None, column: int | None) -> list[str]:
    """Build command for my custom editor."""
    cmd = ["myeditor"]
    if line:
        cmd.extend(["--line", str(line)])
    if column:
        cmd.extend(["--column", str(column)])
    cmd.append(str(path))
    return cmd

link = FileLink(path, command_builder=my_editor_command)

Icon Examples

Icon Positioning

# Icons before filename
ToggleableFileLink(
    path,
    icons=[
        {"name": "type", "icon": "๐Ÿ", "position": "before"},
        {"name": "status", "icon": "โœ“", "position": "before"},
    ]
)
# Display: ๐Ÿ โœ“ script.py

# Icons after filename
ToggleableFileLink(
    path,
    icons=[
        {"name": "size", "icon": "๐Ÿ“Š", "position": "after"},
        {"name": "sync", "icon": "โ˜๏ธ", "position": "after"},
    ]
)
# Display: script.py ๐Ÿ“Š โ˜๏ธ

# Mixed positions
ToggleableFileLink(
    path,
    icons=[
        {"name": "type", "icon": "๐Ÿ", "position": "before"},
        {"name": "sync", "icon": "โ˜๏ธ", "position": "after"},
    ]
)
# Display: ๐Ÿ script.py โ˜๏ธ

Icon Ordering

# Explicit ordering with index
ToggleableFileLink(
    path,
    icons=[
        {"name": "third", "icon": "3๏ธโƒฃ", "index": 3},
        {"name": "first", "icon": "1๏ธโƒฃ", "index": 1},
        {"name": "second", "icon": "2๏ธโƒฃ", "index": 2},
    ]
)
# Display: 1๏ธโƒฃ 2๏ธโƒฃ 3๏ธโƒฃ filename.py

# Auto ordering (maintains list order)
ToggleableFileLink(
    path,
    icons=[
        {"name": "first", "icon": "A"},
        {"name": "second", "icon": "B"},
        {"name": "third", "icon": "C"},
    ]
)
# Display: A B C filename.py

Dynamic Icon Updates

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield ToggleableFileLink(
            "process.py",
            id="task-file",
            icons=[
                {"name": "status", "icon": "โณ", "tooltip": "Pending"},
                {"name": "result", "icon": "โšช", "visible": False},
            ]
        )
    
    def on_mount(self):
        # Simulate processing
        self.set_timer(2.0, self.complete_task)
    
    def complete_task(self):
        link = self.query_one("#task-file", ToggleableFileLink)
        link.update_icon("status", icon="โœ“", tooltip="Complete")
        link.set_icon_visible("result", True)
        link.update_icon("result", icon="๐ŸŸข", tooltip="Success")

Clickable Icons

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield ToggleableFileLink(
            path,
            icons=[
                {"name": "edit", "icon": "โœ๏ธ", "clickable": True, "tooltip": "Edit settings"},
                {"name": "refresh", "icon": "๐Ÿ”„", "clickable": True, "tooltip": "Refresh"},
                {"name": "info", "icon": "โ„น๏ธ", "clickable": True, "tooltip": "Show info"},
            ]
        )
    
    def on_toggleable_file_link_icon_clicked(self, event: ToggleableFileLink.IconClicked):
        if event.icon_name == "edit":
            self.edit_file(event.path)
        elif event.icon_name == "refresh":
            self.refresh_file(event.path)
        elif event.icon_name == "info":
            self.show_info(event.path)

Layout Configurations

Toggle Only

ToggleableFileLink(path, show_toggle=True, show_remove=False)

Display: โ˜ filename.txt

Remove Only

ToggleableFileLink(path, show_toggle=False, show_remove=True)

Display: filename.txt ร—

Both Controls

ToggleableFileLink(path, show_toggle=True, show_remove=True)

Display: โ˜ filename.txt ร—

Plain Link with Icons

ToggleableFileLink(
    path, 
    show_toggle=False, 
    show_remove=False,
    icons=[{"name": "type", "icon": "๐Ÿ“„"}]
)

Display: ๐Ÿ“„ filename.txt

Common Unicode Icons

# Status indicators
"โœ“"  # Success/Complete
"โš "  # Warning
"โœ—"  # Error/Failed
"โณ"  # In progress
"๐Ÿ”’"  # Locked
"๐Ÿ“"  # Modified
"โž•"  # New/Added
"โž–"  # Deleted
"๐Ÿ”„"  # Syncing

# File types
"๐Ÿ“„"  # Document
"๐Ÿ“"  # Folder
"๐Ÿ"  # Python file
"๐Ÿ“Š"  # Data file
"โš™๏ธ"  # Config file

# Actions
"โœ๏ธ"  # Edit
"๐Ÿ‘๏ธ"  # View
"๐Ÿ—‘๏ธ"  # Delete
"๐Ÿ’พ"  # Save
"๐Ÿ“‹"  # Copy

# States
"๐ŸŸข"  # Success/Green
"๐ŸŸก"  # Warning/Yellow
"๐Ÿ”ด"  # Error/Red
"โšช"  # Neutral/White
"๐ŸŸฃ"  # Info/Purple

Complete Example

from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Static
from textual_filelink import ToggleableFileLink

class FileManagerApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    Vertical {
        width: 80;
        height: auto;
        border: solid green;
        padding: 1;
    }
    Static {
        width: 100%;
        content-align: center middle;
        text-style: bold;
    }
    """
    
    def compose(self) -> ComposeResult:
        yield Header()
        
        with Vertical():
            yield Static("๐Ÿ“‚ Project Files")
            
            # Validated file with multiple icons
            yield ToggleableFileLink(
                Path("main.py"),
                initial_toggle=True,
                icons=[
                    {"name": "status", "icon": "โœ“", "tooltip": "Validated", "clickable": True},
                    {"name": "type", "icon": "๐Ÿ", "tooltip": "Python file"},
                    {"name": "lock", "icon": "๐Ÿ”’", "position": "after", "tooltip": "Read-only"},
                ]
            )
            
            # File needing review
            yield ToggleableFileLink(
                Path("config.json"),
                icons=[
                    {"name": "status", "icon": "โš ", "tooltip": "Needs review", "clickable": True},
                    {"name": "type", "icon": "โš™๏ธ", "tooltip": "Config file"},
                ]
            )
            
            # File being processed
            yield ToggleableFileLink(
                Path("data.csv"),
                id="processing-file",
                icons=[
                    {"name": "status", "icon": "โณ", "tooltip": "Processing...", "clickable": True},
                    {"name": "type", "icon": "๐Ÿ“Š", "tooltip": "Data file"},
                    {"name": "result", "icon": "โšช", "visible": False, "position": "after"},
                ]
            )
        
        yield Footer()
    
    def on_toggleable_file_link_toggled(self, event: ToggleableFileLink.Toggled):
        state = "selected" if event.is_toggled else "deselected"
        self.notify(f"๐Ÿ“‹ {event.path.name} {state}")
    
    def on_toggleable_file_link_removed(self, event: ToggleableFileLink.Removed):
        # Remove the widget
        for child in self.query(ToggleableFileLink):
            if child.path == event.path:
                child.remove()
        self.notify(f"๐Ÿ—‘๏ธ Removed {event.path.name}", severity="warning")
    
    def on_toggleable_file_link_icon_clicked(self, event: ToggleableFileLink.IconClicked):
        # Find the link by path
        link = None
        for child in self.query(ToggleableFileLink):
            if child.path == event.path:
                link = child
                break
        
        if not link:
            return
        
        if event.icon_name == "status":
            # Toggle processing status
            if event.icon == "โณ":
                # Complete processing
                link.update_icon("status", icon="โœ“", tooltip="Complete")
                # Only update result icon if it exists (for data.csv)
                if link.get_icon("result"):
                    link.set_icon_visible("result", True)
                    link.update_icon("result", icon="๐ŸŸข", tooltip="Success")
                self.notify(f"โœ… {event.path.name} processing complete")
            else:
                # Start processing
                link.update_icon("status", icon="โณ", tooltip="Processing...")
                # Only hide result icon if it exists (for data.csv)
                if link.get_icon("result"):
                    link.set_icon_visible("result", False)
                self.notify(f"โณ Processing {event.path.name}...")

if __name__ == "__main__":
    FileManagerApp().run()

Development

# Clone the repository
git clone https://github.com/eyecantell/textual-filelink.git
cd textual-filelink

# Install with dev dependencies
pdm install -d

# Run tests
pdm run pytest

# Run tests with coverage
pdm run pytest --cov

# Lint
pdm run ruff check .

# Format
pdm run ruff format .

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Acknowledgments

  • Built with Textual by Textualize
  • Inspired by the need for better file navigation in terminal applications

Links

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

textual_filelink-0.3.0.tar.gz (48.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

textual_filelink-0.3.0-py3-none-any.whl (26.3 kB view details)

Uploaded Python 3

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page