diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 0000000..b84fa80 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,64 @@ +Third-Party Licenses +===================== + +This project includes code derived from the following third-party projects: + +Click +----- +Copyright 2014 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The following files contain code derived from Click: +- src/rich_toolkit/_getchar.py + + +Textual +------- +Copyright (c) 2023 Textualize Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The following files contain code derived from Textual: +- src/rich_toolkit/_getchar_textual.py \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..b9886c5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,7 @@ +--- +release type: minor +--- + +This release increases the paste buffer from 32 to 4096 characters, enabling users to paste longer text into input fields. + +It also adds full Windows compatibility with proper special key handling and fixes how password fields to always show asterisks. diff --git a/examples/create-astro.py b/examples/create-astro.py index 0b86617..371c747 100644 --- a/examples/create-astro.py +++ b/examples/create-astro.py @@ -1,7 +1,7 @@ import random import time -from rich_toolkit import RichToolkit, RichToolkitTheme +from rich_toolkit import RichToolkit from rich_toolkit.styles.border import BorderedStyle from rich_toolkit.styles.fancy import FancyStyle from rich_toolkit.styles.tagged import TaggedStyle diff --git a/examples/inputs.py b/examples/inputs.py index 13fe477..9317ff3 100644 --- a/examples/inputs.py +++ b/examples/inputs.py @@ -36,7 +36,7 @@ ) app.print_line() - app.input("What is the name of your project?", tag="name") + app.input("What is the name of your project?", tag="name", required=True) app.print_line() app.input("What's your password?", tag="password", password=True) diff --git a/examples/progress_log.py b/examples/progress_log.py index ddf296c..8025f05 100644 --- a/examples/progress_log.py +++ b/examples/progress_log.py @@ -1,9 +1,8 @@ import random import time -from typing import Any, Dict, Generator, List, Tuple +from typing import Generator import httpx -from rich.segment import Segment from rich.text import Text from rich_toolkit import RichToolkit diff --git a/src/rich_toolkit/_getchar.py b/src/rich_toolkit/_getchar.py new file mode 100644 index 0000000..834ea17 --- /dev/null +++ b/src/rich_toolkit/_getchar.py @@ -0,0 +1,157 @@ +""" +Unified getchar implementation for all platforms. + +Combines approaches from: +- Textual (Unix/Linux): Copyright (c) 2023 Textualize Inc., MIT License +- Click (Windows fallback): Copyright 2014 Pallets, BSD-3-Clause License +""" + +import os +import sys +from codecs import getincrementaldecoder +from typing import Optional, TextIO + + +def getchar() -> str: + """ + Read input from stdin with support for longer pasted text. + + On Windows: + - Uses msvcrt for native Windows console input + - Handles special keys that send two-byte sequences + - Reads up to 4096 characters for paste support + + On Unix/Linux: + - Uses Textual's approach with manual termios configuration + - Reads up to 4096 bytes with proper UTF-8 decoding + - Provides fine-grained terminal control + + Returns: + str: The input character(s) read from stdin + + Raises: + KeyboardInterrupt: When CTRL+C is pressed + """ + if sys.platform == "win32": + # Windows implementation + try: + import msvcrt + except ImportError: + # Fallback if msvcrt is not available + return sys.stdin.read(1) + + # Use getwch for Unicode support + func = msvcrt.getwch # type: ignore + + # Read first character + rv = func() + + # Check for special keys (they send two characters) + if rv in ("\x00", "\xe0"): + # Special key, read the second character + rv += func() + return rv + + # Check if more input is available (for paste support) + chars = [rv] + max_chars = 4096 + + # Keep reading while characters are available + while len(chars) < max_chars and msvcrt.kbhit(): # type: ignore + next_char = func() + + # Handle special keys during paste + if next_char in ("\x00", "\xe0"): + # Stop here, let this be handled in next call + break + + chars.append(next_char) + + # Check for CTRL+C + if next_char == "\x03": + raise KeyboardInterrupt() + + result = "".join(chars) + + # Check for CTRL+C in the full result + if "\x03" in result: + raise KeyboardInterrupt() + + return result + + else: + # Unix/Linux implementation (Textual approach) + import termios + import tty + + f: Optional[TextIO] = None + fd: int + + # Get the file descriptor + if not sys.stdin.isatty(): + f = open("/dev/tty") + fd = f.fileno() + else: + fd = sys.stdin.fileno() + + try: + # Save current terminal settings + attrs_before = termios.tcgetattr(fd) + + try: + # Configure terminal settings (Textual-style) + newattr = termios.tcgetattr(fd) + + # Patch LFLAG (local flags) + # Disable: + # - ECHO: Don't echo input characters + # - ICANON: Disable canonical mode (line-by-line input) + # - IEXTEN: Disable extended processing + # - ISIG: Disable signal generation + newattr[tty.LFLAG] &= ~( + termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG + ) + + # Patch IFLAG (input flags) + # Disable: + # - IXON/IXOFF: XON/XOFF flow control + # - ICRNL/INLCR/IGNCR: Various newline translations + newattr[tty.IFLAG] &= ~( + termios.IXON + | termios.IXOFF + | termios.ICRNL + | termios.INLCR + | termios.IGNCR + ) + + # Set VMIN to 1 (minimum number of characters to read) + # This ensures we get at least 1 character + newattr[tty.CC][termios.VMIN] = 1 + + # Apply the new terminal settings + termios.tcsetattr(fd, termios.TCSANOW, newattr) + + # Read up to 4096 bytes (same as Textual) + raw_data = os.read(fd, 1024 * 4) + + # Use incremental UTF-8 decoder for proper Unicode handling + decoder = getincrementaldecoder("utf-8")() + result = decoder.decode(raw_data, final=True) + + # Check for CTRL+C (ASCII 3) + if "\x03" in result: + raise KeyboardInterrupt() + + return result + + finally: + # Restore original terminal settings + termios.tcsetattr(fd, termios.TCSANOW, attrs_before) + sys.stdout.flush() + + if f is not None: + f.close() + + except termios.error: + # If we can't control the terminal, fall back to simple read + return sys.stdin.read(1) diff --git a/src/rich_toolkit/_input_handler.py b/src/rich_toolkit/_input_handler.py index fea924a..3d9daf2 100644 --- a/src/rich_toolkit/_input_handler.py +++ b/src/rich_toolkit/_input_handler.py @@ -1,16 +1,43 @@ +"""Unified input handler for all platforms.""" + import string +import sys class TextInputHandler: - DOWN_KEY = "\x1b[B" - UP_KEY = "\x1b[A" - LEFT_KEY = "\x1b[D" - RIGHT_KEY = "\x1b[C" - BACKSPACE_KEY = "\x7f" - DELETE_KEY = "\x1b[3~" - TAB_KEY = "\t" - SHIFT_TAB_KEY = "\x1b[Z" - ENTER_KEY = "\r" + """Input handler with platform-specific key code support.""" + + # Platform-specific key codes + if sys.platform == "win32": + # Windows uses \xe0 prefix for special keys when using msvcrt.getwch + DOWN_KEY = "\xe0P" # Down arrow + UP_KEY = "\xe0H" # Up arrow + LEFT_KEY = "\xe0K" # Left arrow + RIGHT_KEY = "\xe0M" # Right arrow + DELETE_KEY = "\xe0S" # Delete key + BACKSPACE_KEY = "\x08" # Backspace + TAB_KEY = "\t" + SHIFT_TAB_KEY = "\x00\x0f" # Shift+Tab + ENTER_KEY = "\r" + + # Alternative codes that might be sent + ALT_BACKSPACE = "\x7f" + ALT_DELETE = "\x00S" + else: + # Unix/Linux key codes (ANSI escape sequences) + DOWN_KEY = "\x1b[B" + UP_KEY = "\x1b[A" + LEFT_KEY = "\x1b[D" + RIGHT_KEY = "\x1b[C" + BACKSPACE_KEY = "\x7f" + DELETE_KEY = "\x1b[3~" + TAB_KEY = "\t" + SHIFT_TAB_KEY = "\x1b[Z" + ENTER_KEY = "\r" + + # Alternative codes + ALT_BACKSPACE = "\x08" + ALT_DELETE = None def __init__(self): self.text = "" @@ -27,6 +54,7 @@ def _insert_char(self, char: str) -> None: self._move_cursor_right() def _delete_char(self) -> None: + """Delete character before cursor (backspace).""" if self.cursor_left == 0: return @@ -34,15 +62,20 @@ def _delete_char(self) -> None: self._move_cursor_left() def _delete_forward(self) -> None: + """Delete character at cursor (delete key).""" if self.cursor_left == len(self.text): return self.text = self.text[: self.cursor_left] + self.text[self.cursor_left + 1 :] def handle_key(self, key: str) -> None: - if key == self.BACKSPACE_KEY: + # Handle backspace (both possible codes) + if key == self.BACKSPACE_KEY or ( + self.ALT_BACKSPACE and key == self.ALT_BACKSPACE + ): self._delete_char() - elif key == self.DELETE_KEY: + # Handle delete key + elif key == self.DELETE_KEY or (self.ALT_DELETE and key == self.ALT_DELETE): self._delete_forward() elif key == self.LEFT_KEY: self._move_cursor_left() @@ -57,8 +90,14 @@ def handle_key(self, key: str) -> None: ): pass else: - # even if we call this handle key, in some cases we might receive multiple keys - # at once + # Handle regular text input + # Special keys on Windows start with \x00 or \xe0 + if sys.platform == "win32" and key and key[0] in ("\x00", "\xe0"): + # Skip special key sequences + return + + # Even if we call this handle_key, in some cases we might receive + # multiple keys at once (e.g., during paste operations) for char in key: if char in string.printable: self._insert_char(char) diff --git a/src/rich_toolkit/container.py b/src/rich_toolkit/container.py index c716332..adb0758 100644 --- a/src/rich_toolkit/container.py +++ b/src/rich_toolkit/container.py @@ -2,12 +2,13 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -import click from rich.control import Control, ControlType from rich.live_render import LiveRender from rich.segment import Segment +from ._getchar import getchar from ._input_handler import TextInputHandler + from .element import Element if TYPE_CHECKING: @@ -169,7 +170,7 @@ def run(self): while True: try: - key = click.getchar() + key = getchar() self.previous_element_index = self.active_element_index diff --git a/src/rich_toolkit/input.py b/src/rich_toolkit/input.py index 8f38a38..eecfe82 100644 --- a/src/rich_toolkit/input.py +++ b/src/rich_toolkit/input.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Optional from ._input_handler import TextInputHandler + from .element import CursorOffset, Element if TYPE_CHECKING: diff --git a/src/rich_toolkit/menu.py b/src/rich_toolkit/menu.py index c56c916..812e413 100644 --- a/src/rich_toolkit/menu.py +++ b/src/rich_toolkit/menu.py @@ -8,6 +8,7 @@ from typing_extensions import Any, Literal, TypedDict from ._input_handler import TextInputHandler + from .element import CursorOffset, Element if TYPE_CHECKING: diff --git a/src/rich_toolkit/progress.py b/src/rich_toolkit/progress.py index 2308409..0f4d528 100644 --- a/src/rich_toolkit/progress.py +++ b/src/rich_toolkit/progress.py @@ -5,7 +5,6 @@ from rich.console import Console, RenderableType from rich.live import Live from rich.text import Text -from typing_extensions import Literal from .element import Element diff --git a/src/rich_toolkit/styles/base.py b/src/rich_toolkit/styles/base.py index 588db07..e2adab1 100644 --- a/src/rich_toolkit/styles/base.py +++ b/src/rich_toolkit/styles/base.py @@ -246,17 +246,18 @@ def render_input_value( ) -> RenderableType: text = input.text + # Check if this is a password field and mask it + if isinstance(input, Input) and input.password and text: + text = "*" * len(text) + if not text: placeholder = "" if isinstance(input, Input): placeholder = input.placeholder - if input.password: - text = "*" * len(input.text) - else: - if input.default_as_placeholder and input.default: - return f"[placeholder]{input.default}[/]" + if input.default_as_placeholder and input.default: + return f"[placeholder]{input.default}[/]" if input._cancelled: return f"[placeholder.cancelled]{placeholder}[/]" diff --git a/src/rich_toolkit/styles/border.py b/src/rich_toolkit/styles/border.py index ada0c19..2dc7dcb 100644 --- a/src/rich_toolkit/styles/border.py +++ b/src/rich_toolkit/styles/border.py @@ -74,16 +74,23 @@ def render_input( if message := self.render_validation_message(element): validation_message = (message,) - if element.valid is False: - border_color = self.console.get_style("error").color or Color.parse("red") - title = self.render_input_label( element, is_active=is_active, parent=parent, ) - border_color = Color.parse("white") + # Determine border color based on validation state + if element.valid is False: + try: + border_color = self.console.get_style("error").color or Color.parse( + "red" + ) + except Exception: + # Fallback if error style is not defined + border_color = Color.parse("red") + else: + border_color = Color.parse("white") return self._box( self.render_input_value(element, is_active=is_active, parent=parent), diff --git a/uv.lock b/uv.lock index 2d3edd5..c0520b2 100644 --- a/uv.lock +++ b/uv.lock @@ -902,7 +902,7 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.14.8" +version = "0.14.9" source = { editable = "." } dependencies = [ { name = "click" },