mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
text-input-1
This commit is contained in:
@@ -55,4 +55,5 @@ class BordersApp(App):
|
||||
self.mount(borders=borders_view)
|
||||
|
||||
|
||||
BordersApp.run(css_path="borders.css", log_path="textual.log")
|
||||
app = BordersApp(css_path="borders.css", log_path="textual.log")
|
||||
app.run()
|
||||
|
||||
@@ -18,7 +18,9 @@ class ButtonsApp(App[str]):
|
||||
self.exit(event.button.id)
|
||||
|
||||
|
||||
app = ButtonsApp(log_path="textual.log", log_verbosity=2)
|
||||
app = ButtonsApp(
|
||||
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
|
||||
42
sandbox/input.py
Normal file
42
sandbox/input.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
from textual.widgets.text_input import TextInput, TextInputBase
|
||||
|
||||
|
||||
def celsius_to_fahrenheit(celsius: float) -> float:
|
||||
return celsius * 1.8 + 32
|
||||
|
||||
|
||||
def fahrenheit_to_celsius(fahrenheit: float) -> float:
|
||||
return (fahrenheit - 32) / 1.8
|
||||
|
||||
|
||||
class InputApp(App[str]):
|
||||
def on_mount(self) -> None:
|
||||
self.fahrenheit = TextInput(placeholder="Fahrenheit", id="fahrenheit")
|
||||
self.celsius = TextInput(placeholder="Celsius", id="celsius")
|
||||
self.fahrenheit.focus()
|
||||
text_boxes = Widget(self.fahrenheit, self.celsius)
|
||||
self.mount(inputs=text_boxes)
|
||||
|
||||
def handle_changed(self, event: TextInputBase.Changed) -> None:
|
||||
try:
|
||||
value = float(event.value)
|
||||
except ValueError:
|
||||
return
|
||||
if event.sender == self.celsius:
|
||||
fahrenheit = celsius_to_fahrenheit(value)
|
||||
self.fahrenheit.current_text = f"{fahrenheit:.1f}"
|
||||
elif event.sender == self.fahrenheit:
|
||||
celsius = fahrenheit_to_celsius(value)
|
||||
self.celsius.current_text = f"{celsius:.1f}"
|
||||
|
||||
|
||||
app = InputApp(
|
||||
log_path="textual.log", css_path="input.scss", watch_css=True, log_verbosity=2
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
print(repr(result))
|
||||
31
sandbox/input.scss
Normal file
31
sandbox/input.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
App {
|
||||
layout: dock;
|
||||
docks: top=top bot=bottom;
|
||||
background: $secondary;
|
||||
}
|
||||
|
||||
Screen {
|
||||
background: $secondary;
|
||||
}
|
||||
|
||||
#fahrenheit {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
#celsius {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
#inputs {
|
||||
dock: top;
|
||||
layout: horizontal;
|
||||
background: $primary;
|
||||
height: 3;
|
||||
}
|
||||
|
||||
#body {
|
||||
dock: top;
|
||||
background: $secondary;
|
||||
height: 20;
|
||||
}
|
||||
@@ -199,6 +199,11 @@ class Keys(str, Enum):
|
||||
ShiftControlHome = ControlShiftHome
|
||||
ShiftControlEnd = ControlShiftEnd
|
||||
|
||||
@classmethod
|
||||
def values(cls):
|
||||
"""Returns a set of all the enum values."""
|
||||
return set(cls._value2member_map_.keys())
|
||||
|
||||
|
||||
@dataclass
|
||||
class Binding:
|
||||
|
||||
@@ -15,7 +15,7 @@ class Button(Widget, can_focus=True):
|
||||
"""A simple clickable button."""
|
||||
|
||||
CSS = """
|
||||
|
||||
|
||||
Button {
|
||||
width: auto;
|
||||
height: 3;
|
||||
@@ -23,22 +23,22 @@ class Button(Widget, can_focus=True):
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
content-align: center middle;
|
||||
border: tall $primary-lighten-3;
|
||||
|
||||
margin: 1 0;
|
||||
border: tall $primary-lighten-3;
|
||||
|
||||
margin: 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background:$primary-darken-2;
|
||||
color: $text-primary-darken-2;
|
||||
border: tall $primary-lighten-1;
|
||||
border: tall $primary-lighten-1;
|
||||
}
|
||||
|
||||
App.-show-focus Button:focus {
|
||||
tint: $accent 20%;
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
|
||||
class Pressed(Message, bubble=True):
|
||||
|
||||
172
src/textual/widgets/text_input.py
Normal file
172
src/textual/widgets/text_input.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from textual import events
|
||||
from textual._types import MessageTarget
|
||||
from textual.geometry import Size
|
||||
from textual.keys import Keys
|
||||
from textual.message import Message
|
||||
from textual.reactive import Reactive
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class TextInputBase(Widget):
|
||||
ALLOW_PROPAGATE = {"tab", "shift+tab"}
|
||||
|
||||
current_text = Reactive("", layout=True)
|
||||
cursor_index = Reactive(0)
|
||||
|
||||
class Changed(Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, value: str) -> None:
|
||||
"""Message posted when the user presses the 'enter' key while
|
||||
focused on a TextInput widget.
|
||||
|
||||
Args:
|
||||
sender (MessageTarget): Sender of the message
|
||||
value (str): The value in the TextInput
|
||||
"""
|
||||
super().__init__(sender)
|
||||
self.value = value
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
key = event.key
|
||||
|
||||
if key == "\x1b":
|
||||
return
|
||||
|
||||
if key not in self.ALLOW_PROPAGATE:
|
||||
event.stop()
|
||||
|
||||
changed = False
|
||||
if key == "ctrl+h" and self.cursor_index != 0:
|
||||
new_text = (
|
||||
self.current_text[: self.cursor_index - 1]
|
||||
+ self.current_text[self.cursor_index :]
|
||||
)
|
||||
self.current_text = new_text
|
||||
self.cursor_index = max(0, self.cursor_index - 1)
|
||||
changed = True
|
||||
elif key == "ctrl+d" and self.cursor_index != len(self.current_text):
|
||||
new_text = (
|
||||
self.current_text[: self.cursor_index]
|
||||
+ self.current_text[self.cursor_index + 1 :]
|
||||
)
|
||||
self.current_text = new_text
|
||||
changed = True
|
||||
elif key == "left":
|
||||
self.cursor_index = max(0, self.cursor_index - 1)
|
||||
elif key == "right":
|
||||
self.cursor_index = min(len(self.current_text), self.cursor_index + 1)
|
||||
elif key == "home":
|
||||
self.cursor_index = 0
|
||||
elif key == "end":
|
||||
self.cursor_index = len(self.current_text)
|
||||
elif key not in Keys.values():
|
||||
new_text = (
|
||||
self.current_text[: self.cursor_index]
|
||||
+ key
|
||||
+ self.current_text[self.cursor_index :]
|
||||
)
|
||||
self.current_text = new_text
|
||||
self.cursor_index = min(len(self.current_text), self.cursor_index + 1)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.post_message_no_wait(self.Changed(self, value=self.current_text))
|
||||
|
||||
|
||||
class TextInput(TextInputBase, can_focus=True):
|
||||
CSS = """
|
||||
TextInput {
|
||||
width: auto;
|
||||
background: $primary;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
content-align: left middle;
|
||||
}
|
||||
|
||||
TextInput:hover {
|
||||
background: $primary-darken-1;
|
||||
}
|
||||
|
||||
TextInput:focus {
|
||||
background: $primary-darken-2;
|
||||
border: solid $primary-lighten-1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
App.-show-focus TextInput:focus {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
"""
|
||||
|
||||
class Submitted(Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, value: str) -> None:
|
||||
"""Message posted when the user presses the 'enter' key while
|
||||
focused on a TextInput widget.
|
||||
|
||||
Args:
|
||||
sender (MessageTarget): Sender of the message
|
||||
value (str): The value in the TextInput
|
||||
"""
|
||||
super().__init__(sender)
|
||||
self.value = value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
placeholder: str = "",
|
||||
initial: str = "",
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.placeholder = placeholder
|
||||
self.current_text = initial if initial else ""
|
||||
|
||||
def get_content_width(self, container_size: Size, viewport_size: Size) -> int:
|
||||
return (
|
||||
max(len(self.current_text), len(self.placeholder))
|
||||
+ self.styles.gutter.width
|
||||
)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
# We only show the cursor if the widget has focus
|
||||
show_cursor = self.has_focus
|
||||
if self.current_text:
|
||||
display_text = Text(self.current_text, no_wrap=True)
|
||||
|
||||
if show_cursor:
|
||||
display_text = self._apply_cursor_to_text(
|
||||
display_text, self.cursor_index
|
||||
)
|
||||
return display_text
|
||||
else:
|
||||
# The user has not entered text - show the placeholder
|
||||
display_text = Text(self.placeholder, "dim")
|
||||
if show_cursor:
|
||||
display_text = self._apply_cursor_to_text(display_text, 0)
|
||||
return display_text
|
||||
|
||||
def _apply_cursor_to_text(self, display_text: Text, index: int):
|
||||
# Either write a cursor character or apply reverse style to cursor location
|
||||
if index == len(display_text):
|
||||
display_text += "█"
|
||||
else:
|
||||
display_text.stylize(
|
||||
"reverse",
|
||||
start=index,
|
||||
end=index + 1,
|
||||
)
|
||||
return display_text
|
||||
|
||||
def on_focus(self, event: events.Focus) -> None:
|
||||
self.refresh(layout=True)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
key = event.key
|
||||
if key == "enter" and self.current_text:
|
||||
self.post_message_no_wait(TextInput.Submitted(self, self.current_text))
|
||||
Reference in New Issue
Block a user