Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
8ec8ac8
📝 spec: add design for TextWindow integration using TextArea
juftin Mar 25, 2026
00f44db
📝 spec: expand research and resource details in design doc
juftin Mar 25, 2026
22e37f7
📝 docs: add Textual TextArea references to spec and project documenta…
juftin Mar 25, 2026
b9b30be
feat: add TextWindow widget based on TextArea
juftin Mar 25, 2026
5314ae1
feat: implement smart theme and language mapping for TextWindow
juftin Mar 25, 2026
1ff7296
feat: update WindowSwitcher to route code and JSON to TextWindow
juftin Mar 25, 2026
336acf3
fix: final polish, theme updates and snapshot verification of TextWindow
juftin Mar 25, 2026
12e989d
fix: make line numbers dynamic and synchronized across windows
juftin Mar 25, 2026
1b9e995
fix: update snapshots after line number synchronization fix
juftin Mar 25, 2026
a90c5e7
feat: synchronize themes and dark mode across windows
juftin Mar 25, 2026
97ac08f
fix: refine subtitle updates and theme synchronization for TextWindow
juftin Mar 25, 2026
89b8075
feat: implement window-specific theme cycling for TextArea and Static…
juftin Mar 25, 2026
6575787
feat: implement comprehensive language mapping for TextWindow
juftin Mar 25, 2026
172f24e
feat: implement smarter language detection with FILENAME_MAP
juftin Mar 25, 2026
cd1664a
chore: enable syntax highlighting for Textual by adding syntax extra
juftin Mar 25, 2026
be35e5a
feat: default TextWindow theme to vscode_dark and persist between files
juftin Mar 25, 2026
2110636
feat: add Shift+C copy binding and ensure theme persistence for TextW…
juftin Mar 25, 2026
4bf5ad1
feat: explicitly bind Shift+C for copying selected text in code files
juftin Mar 25, 2026
d22071e
feat: use 'C' for shift+c binding with correct visual display in footer
juftin Mar 25, 2026
7d6e83c
refactor: polish TextArea integration, move maps to config, and simpl…
juftin Mar 25, 2026
4402905
feat: use ordered textarea_theme_map for theme cycling in TextWindow
juftin Mar 25, 2026
a1c48a1
refactor: use OrderedDict only for textarea_theme_map and clean up ot…
juftin Mar 25, 2026
4f131ec
copy text + bindings
juftin Mar 26, 2026
4fe63a8
✨ feat: finalize textarea integration and orjson migration
juftin Mar 26, 2026
63b1974
🚨 fix: resolve mypy type checking errors in windows.py
juftin Mar 26, 2026
f34a858
refactor: revert orjson to stdlib json
juftin Mar 27, 2026
bc75b2b
Merge remote-tracking branch 'origin/main' into feature/textarea-inte…
juftin Mar 31, 2026
8c8cb08
renderable
juftin Mar 31, 2026
6fba29b
snapshot updates
juftin Mar 31, 2026
644804f
✨ (docs): Add design spec for Keyboard Shortcuts widget
juftin Mar 31, 2026
59fb282
♻️ (docs): Refine design spec for Keyboard Shortcuts widget
juftin Mar 31, 2026
01ade67
✨ (docs): Add implementation plan for Keyboard Shortcuts widget
juftin Mar 31, 2026
995a026
♻️ (docs): Refine implementation plan for Keyboard Shortcuts widget
juftin Mar 31, 2026
47112d6
✨ (widgets): Add ShortcutsWindow and ShortcutsPopUp widgets
juftin Mar 31, 2026
770eb77
✨ (app): Integrate shortcuts window globally
juftin Mar 31, 2026
844dd95
✅ (tests): Add tests and snapshots for shortcuts widget
juftin Mar 31, 2026
d7ed366
✅ (tests): Update snapshots after adding global shortcuts binding
juftin Mar 31, 2026
03c18f4
✨ (shortcuts): Finalize implementation with keyboard support and fix …
juftin Apr 1, 2026
a4a6c2d
🎨 (shortcuts): Make ShortcutsWindow and ConfirmationWindow true overl…
juftin Apr 1, 2026
9516726
🐛 (shortcuts): Remove q binding from shortcuts window to avoid overri…
juftin Apr 1, 2026
a10f9b1
✨ (confirmation): Support esc to close the confirmation window
juftin Apr 1, 2026
68f537d
📝 (docs): Add implementation summary for Keyboard Shortcuts and TextA…
juftin Apr 1, 2026
c874278
♻️ (refactor): Fix recursion in TextWindow theme handling and abstrac…
juftin Apr 1, 2026
355f67a
♻️ (refactor): Break down large methods into smaller helpers for bett…
juftin Apr 1, 2026
e689e6d
🐛 (fix): Update snapshots after theme fix
juftin Apr 1, 2026
7c2dd39
🐛 (fix): Correct theme cycling in WindowSwitcher
juftin Apr 1, 2026
e583549
🐛 (confirmation): Fix double buttons in confirmation window
juftin Apr 1, 2026
0c6fa99
♻️ (refactor): Final optimizations for WindowSwitcher and CodeBrowser
juftin Apr 2, 2026
2b7319d
🐛 (fix): Add missing get_file_info import in windows.py
juftin Apr 2, 2026
f7f547a
♻️ (refactor): Finalize typing and code quality improvements
juftin Apr 2, 2026
b996c7b
whitelisted shortcuts
juftin Apr 2, 2026
dad6f11
✨ (shortcuts): Hide ctrl+q and use trusted actions list
juftin Apr 2, 2026
d720f15
📝 docs update
juftin Apr 2, 2026
95a71fb
Merge remote-tracking branch 'origin/main' into feature/textarea-inte…
juftin Apr 2, 2026
3bb8cbd
test fix
juftin Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ and remote filesystems with your keyboard or mouse.

You can quickly navigate through directories and peek at files whether they're hosted **locally**,
in **GitHub**, over **SSH**, in **AWS S3**, **Google Cloud Storage**, or **Azure Blob Storage**. View code files
with syntax highlighting, format JSON files, render images, convert data files to navigable
datatables, and more.
with high-performance syntax highlighting using Textual's [TextArea](https://textual.textualize.io/widgets/text_area/),
format JSON files, render images, convert data files to navigable datatables, and more.

![](https://raw.githubusercontent.com/juftin/browsr/main/docs/_static/screenshot_utils.png)

Expand Down
42 changes: 42 additions & 0 deletions browsr/browsr.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ ConfirmationWindow {
width: 100%;
height: 100%;
align: center middle;
layer: overlay;
background: $background 50%;
}

ConfirmationPopUp {
Expand All @@ -84,3 +86,43 @@ ConfirmationPopUp Button {
margin-top: 1;
width: 100%;
}

/* -- ShortcutsPopUp -- */

ShortcutsWindow {
width: 100%;
height: 100%;
align: center middle;
layer: overlay;
background: $background 50%;
}

ShortcutsPopUp {
background: $boost;
height: auto;
max-height: 80%;
max-width: 80;
min-width: 40;
border: wide $primary;
padding: 1 2;
margin: 1 2;
box-sizing: border-box;
}

#shortcuts-header {
width: 100%;
content-align: center middle;
text-style: bold;
margin-bottom: 1;
}

#shortcuts-table {
height: auto;
max-height: 20;
border: none;
}

ShortcutsPopUp Button {
margin-top: 1;
width: 100%;
}
8 changes: 7 additions & 1 deletion browsr/browsr.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class Browsr(App[str]):
CSS_PATH = "browsr.css"
BINDINGS: ClassVar[list[BindingType]] = [
Binding(key="q", action="quit", description="Quit"),
Binding(key="d", action="toggle_dark", description="Dark Mode"),
Binding(key="d", action="toggle_dark", description="Toggle Dark Mode"),
]

def __init__(
Expand Down Expand Up @@ -74,6 +74,12 @@ def action_download_file(self) -> None:
"""
self.code_browser_screen.code_browser.download_file_workflow()

def action_copy_text(self) -> None:
"""
An action to copy text.
"""
self.code_browser_screen.code_browser.window_switcher.text_window.copy_selected_text()


app = Browsr(
config_object=TextualAppContext(
Expand Down
19 changes: 11 additions & 8 deletions browsr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,18 @@ def browsr(
```

## Key Bindings
- **`Q`** - Quit the application
- **`F`** - Toggle the file tree sidebar
- **`T`** - Toggle the rich theme for code formatting
- **`N`** - Toggle line numbers for code formatting
- **`D`** - Toggle dark mode for the application
- **`q`** - Quit the application
- **`f`** - Toggle the file tree sidebar
- **`t`** - Toggle the rich theme for code formatting
- **`n`** - Toggle line numbers for code formatting
- **`d`** - Toggle dark mode for the application
- **`.`** - Parent Directory - go up one directory
- **`R`** - Reload the current directory
- **`C`** - Copy the current file or directory path to the clipboard
- **`X`** - Download the file from cloud storage
- **`r`** - Reload the current directory
- **`w`** - Toggle word wrap for code files
- **`c`** - Copy the current file or directory path to the clipboard
- **`shift+c`** - Copy the selected text to the clipboard
- **`x`** - Download the file from cloud storage
- **`?`** - View keyboard shortcuts
"""
extra_kwargs = {}
if kwargs:
Expand Down
61 changes: 61 additions & 0 deletions browsr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
browsr configuration file
"""

from collections import OrderedDict
from os import getenv

favorite_themes: list[str] = [
Expand Down Expand Up @@ -57,3 +58,63 @@
".xv",
".pdf",
]

textarea_default_theme = "vscode_dark"

textarea_theme_map = OrderedDict(
[
("monokai", "monokai"),
("dracula", "dracula"),
("github-dark", "vscode_dark"),
("solarized-light", "github_light"),
("material", "vscode_dark"),
("one-dark", "vscode_dark"),
("solarized-dark", "vscode_dark"),
("native", "vscode_dark"),
("emacs", "vscode_dark"),
("vim", "vscode_dark"),
("paraiso-dark", "vscode_dark"),
]
)

language_map = {
"py": "python",
"pyi": "python",
"pyw": "python",
"md": "markdown",
"markdown": "markdown",
"json": "json",
"toml": "toml",
"yaml": "yaml",
"yml": "yaml",
"html": "html",
"htm": "html",
"css": "css",
"js": "javascript",
"mjs": "javascript",
"cjs": "javascript",
"rs": "rust",
"go": "go",
"sql": "sql",
"java": "java",
"sh": "bash",
"bash": "bash",
"zsh": "bash",
"xml": "xml",
"rss": "xml",
"svg": "xml",
"xsd": "xml",
"xslt": "xml",
}

filename_map = {
"uv.lock": "toml",
"pyproject.toml": "toml",
"cargo.lock": "toml",
"cargo.toml": "toml",
"makefile": "bash",
"dockerfile": "bash",
"procfile": "yaml",
".gitignore": "bash",
".env": "bash",
}
72 changes: 54 additions & 18 deletions browsr/screens/code_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,55 @@
from textual.containers import Horizontal
from textual.events import Mount
from textual.widget import Widget
from textual.widgets import Footer, Header
from textual.widgets import DataTable, Footer, Header
from textual_universal_directorytree import UPath

from browsr.base import SortedBindingsScreen, TextualAppContext
from browsr.utils import get_file_info
from browsr.widgets.code_browser import CodeBrowser
from browsr.widgets.files import CurrentFileInfoBar
from browsr.widgets.shortcuts import ShortcutsPopUp, ShortcutsWindow


class CodeBrowserScreen(SortedBindingsScreen):
"""
Code Browser Screen
"""

LAYERS: ClassVar[list[str]] = ["default", "overlay"]

BINDINGS: ClassVar[list[BindingType]] = [
Binding(key="f", action="toggle_files", description="Files"),
Binding(key="t", action="theme", description="Theme"),
Binding(key="n", action="linenos", description="Line Numbers"),
Binding(key="r", action="reload", description="Reload"),
Binding(key=".", action="parent_dir", description="Parent Directory"),
Binding(key="f", action="toggle_files", description="File Browser"),
Binding(key="t", action="theme", description="Toggle Theme"),
Binding(key="n", action="linenos", description="Toggle Line Numbers"),
Binding(key="r", action="reload", description="Reload", show=False),
Binding(
key=".",
action="parent_dir",
description="Parent Directory",
key_display=".",
show=False,
),
Binding(key="w", action="toggle_wrap", description="Toggle Wrap", show=False),
Binding(
key="?", action="toggle_shortcuts", description="Shortcuts", key_display="?"
),
]

BINDING_WEIGHTS: ClassVar[dict[str, int]] = {
"ctrl+c": 1,
"q": 2,
"f": 3,
"t": 4,
"n": 5,
"d": 6,
"r": 995,
".": 996,
"c": 997,
"x": 998,
"ctrl+c": 5,
"q": 10,
"f": 15,
"t": 20,
"n": 25,
"d": 30,
"r": 905,
".": 910,
"c": 920,
"x": 925,
"w": 930,
"C": 935,
"?": 940,
}

def __init__(
Expand Down Expand Up @@ -78,6 +94,7 @@ def __init__(
else:
self.file_information.file_info = get_file_info(self.config_object.path)
self.footer = Footer()
self.shortcuts_window = ShortcutsWindow(id="shortcuts-container")

def compose(self) -> Iterable[Widget]:
"""
Expand All @@ -87,6 +104,7 @@ def compose(self) -> Iterable[Widget]:
yield self.code_browser
yield self.info_bar
yield self.footer
yield self.shortcuts_window

@on(Mount)
def start_up_app(self) -> None:
Expand Down Expand Up @@ -157,8 +175,8 @@ def action_linenos(self) -> None:
"""
if self.code_browser.selected_file_path is None:
return
self.code_browser.static_window.linenos = (
not self.code_browser.static_window.linenos
self.code_browser.window_switcher.linenos = (
not self.code_browser.window_switcher.linenos
)

def action_reload(self) -> None:
Expand Down Expand Up @@ -189,3 +207,21 @@ def action_reload(self) -> None:
severity="information",
timeout=1,
)

def action_toggle_wrap(self) -> None:
"""
Toggle soft wrap for the text area.
"""
self.code_browser.window_switcher.text_window.soft_wrap = (
not self.code_browser.window_switcher.text_window.soft_wrap
)

def action_toggle_shortcuts(self) -> None:
"""
Toggle the shortcuts window
"""
self.shortcuts_window.display = not self.shortcuts_window.display
if self.shortcuts_window.display:
popup = self.shortcuts_window.query_one(ShortcutsPopUp)
popup.update_shortcuts()
popup.query_one(DataTable).focus()
4 changes: 2 additions & 2 deletions browsr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def open_image(document: UPath, screen_width: float) -> Pixels:
image_width = image.width
image_height = image.height
size_ratio = image_width / screen_width
new_width = min(int(image_width / size_ratio), image_width)
new_height = min(int(image_height / size_ratio), image_height)
new_width = int(image_width / size_ratio)
new_height = int(image_height / size_ratio)
resized = image.resize((new_width, new_height))
return Pixels.from_image(resized)

Expand Down
80 changes: 80 additions & 0 deletions browsr/widgets/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Base classes for widgets
"""

from __future__ import annotations

from typing import ClassVar

from textual import on
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message


class BasePopUp(Container):
"""
Base class for popup widgets
"""

can_focus = True

BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
]

class Toggle(Message):
"""
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a Toggle message instead of calling the overlay's method directly?

BasePopUp is always a child of BaseOverlay, but the popup shouldn't hold a reference to its parent. Posting a Toggle message lets BaseOverlay handle visibility in its own on(BasePopUp.Toggle) handler — keeping the popup and overlay decoupled.

Toggle the popup visibility
"""

def __init__(self, display: bool | None = None) -> None:
self.display = display
super().__init__()

def action_close(self) -> None:
"""
Close the popup
"""
self.post_message(self.Toggle(display=False))


class BaseOverlay(Container):
"""
Base class for overlay containers
"""

can_focus = True

BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
]

def action_close(self) -> None:
"""
Close the overlay
"""
self.display = False

def on_mount(self) -> None:
"""
On Mount
"""
self.display = False

def watch_display(self, display: bool) -> None:
"""
Focus the overlay when it is displayed
"""
if display:
self.focus()

@on(BasePopUp.Toggle)
def handle_toggle(self, message: BasePopUp.Toggle) -> None:
"""
Handle the toggle message from the popup
"""
if message.display is not None:
self.display = message.display
else:
self.display = not self.display
Loading
Loading