Move gr.Textbox lines logic to frontend (#10785)

* changes

* add changeset

* test

* fix max lines

* fix e2e test

* add changeset

* changes

* format

* warnings textbox

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
Abubakar Abid 2025-03-11 16:10:35 -07:00 committed by GitHub
parent 6fd7fe8b2c
commit fb8c1cb6d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 48 additions and 32 deletions

View File

@ -0,0 +1,6 @@
---
"@gradio/textbox": patch
"gradio": patch
---
fix:Move `gr.Textbox` lines logic to frontend

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import warnings
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any, Literal
@ -38,8 +39,9 @@ class Textbox(FormComponent):
self,
value: str | Callable | None = None,
*,
type: Literal["text", "password", "email"] = "text",
lines: int = 1,
max_lines: int = 20,
max_lines: int | None = None,
placeholder: str | None = None,
label: str | None = None,
info: str | None = None,
@ -57,7 +59,6 @@ class Textbox(FormComponent):
elem_classes: list[str] | str | None = None,
render: bool = True,
key: int | str | None = None,
type: Literal["text", "password", "email"] = "text",
text_align: Literal["left", "right"] | None = None,
rtl: bool = False,
show_copy_button: bool = False,
@ -68,8 +69,9 @@ class Textbox(FormComponent):
"""
Parameters:
value: text to show in textbox. If a function is provided, the function will be called each time the app loads to set the initial value of this component.
type: The type of textbox. One of: 'text' (which allows users to enter any text), 'password' (which masks text entered by the user), 'email' (which suggests email input to the browser). For "password" and "email" types, `lines` must be 1 and `max_lines` must be None or 1.
lines: minimum number of line rows to provide in textarea.
max_lines: maximum number of line rows to provide in textarea.
max_lines: maximum number of line rows to provide in textarea. Must be at least `lines`. If not provided, the maximum number of lines is max(lines, 20) for "text" type, and 1 for "password" and "email" types.
placeholder: placeholder hint to provide behind textarea.
label: the label for this component, displayed above the component if `show_label` is `True` and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component corresponds to.
info: additional component description, appears below the label in smaller font. Supports markdown / HTML syntax.
@ -86,7 +88,6 @@ class Textbox(FormComponent):
elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
render: If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.
key: if assigned, will be used to assume identity across a re-render. Components that have the same key across a re-render will have their value preserved.
type: The type of textbox. One of: 'text', 'password', 'email', Default is 'text'.
text_align: How to align the text in the textbox, can be: "left", "right", or None (default). If None, the alignment is left if `rtl` is False, or right if `rtl` is True. Can only be changed if `type` is "text".
rtl: If True and `type` is "text", sets the direction of the text to right-to-left (cursor appears on the left of the text). Default is False, which renders cursor on the right.
show_copy_button: If True, includes a copy button to copy the text in the textbox. Only applies if show_label is True.
@ -96,12 +97,20 @@ class Textbox(FormComponent):
"""
if type not in ["text", "password", "email"]:
raise ValueError('`type` must be one of "text", "password", or "email".')
if type in ["password", "email"]:
if lines != 1:
warnings.warn(
"The `lines` parameter must be 1 for `type` of 'password' or 'email'. Setting `lines` to 1."
)
lines = 1
if max_lines not in [None, 1]:
warnings.warn(
"The `max_lines` parameter must be None or 1 for `type` of 'password' or 'email'. Setting `max_lines` to 1."
)
max_lines = 1
self.lines = lines
if type == "text":
self.max_lines = max(lines, max_lines)
else:
self.max_lines = 1
self.max_lines = max_lines
self.placeholder = placeholder
self.show_copy_button = show_copy_button
self.submit_btn = submit_btn

View File

@ -12,23 +12,21 @@ test("clicking through tabs shows correct content", async ({ page }) => {
await expect(page.locator("body")).not.toContainText("Incomplete Tasks (0)");
await expect(page.locator("body")).toContainText("Incomplete Tasks (1)");
await expect(page.locator("body")).toContainText("Complete Tasks (0)");
await expect(page.locator("textarea").nth(1)).toHaveValue("eat");
await expect(page.locator("input").nth(0)).toHaveValue("eat");
await input_text.fill("pray");
await input_text.press("Enter");
await expect(page.locator("body")).toContainText("Incomplete Tasks (2)");
await expect(page.locator("body")).toContainText("Complete Tasks (0)");
await expect(page.locator("textarea").nth(2)).toHaveValue("pray");
await expect(page.locator("input").nth(1)).toHaveValue("pray");
await input_text.fill("love");
await input_text.press("Enter");
await expect(page.locator("body")).toContainText("Incomplete Tasks (3)");
await expect(page.locator("body")).toContainText("Complete Tasks (0)");
await expect(page.locator("textarea").nth(1)).toHaveValue("eat");
await expect(page.locator("textarea").nth(2)).toHaveValue("pray");
await expect(page.locator("textarea").nth(3)).toHaveValue("love");
await expect(page.locator("input").nth(2)).toHaveValue("love");
const done_btn_for_eat = page
.locator("button")

View File

@ -32,7 +32,7 @@
export let lines: number;
export let placeholder = "";
export let show_label: boolean;
export let max_lines: number;
export let max_lines: number | undefined = undefined;
export let type: "text" | "password" | "email" = "text";
export let container = true;
export let scale: number | null = null;
@ -80,7 +80,7 @@
{type}
{rtl}
{text_align}
max_lines={!max_lines ? lines + 1 : max_lines}
{max_lines}
{placeholder}
{submit_btn}
{stop_btn}

View File

@ -19,7 +19,7 @@
export let disabled = false;
export let show_label = true;
export let container = true;
export let max_lines: number;
export let max_lines: number | undefined = undefined;
export let type: "text" | "password" | "email" = "text";
export let show_copy_button = false;
export let submit_btn: string | boolean | null = null;
@ -37,10 +37,21 @@
let can_scroll: boolean;
let previous_scroll_top = 0;
let user_has_scrolled_up = false;
let _max_lines: number;
const show_textbox_border = !submit_btn;
$: value, el && lines !== max_lines && resize({ target: el });
$: if (max_lines === undefined) {
if (type === "text") {
_max_lines = Math.max(lines, 20);
} else {
_max_lines = 1;
}
} else {
_max_lines = Math.max(max_lines, lines);
}
$: value, el && lines !== _max_lines && resize({ target: el });
$: if (value === null) value = "";
@ -119,7 +130,7 @@
e.key === "Enter" &&
!e.shiftKey &&
lines === 1 &&
max_lines >= 1
_max_lines >= 1
) {
e.preventDefault();
dispatch("submit");
@ -153,7 +164,7 @@
event: Event | { target: HTMLTextAreaElement | HTMLInputElement }
): Promise<void> {
await tick();
if (lines === max_lines) return;
if (lines === _max_lines) return;
const target = event.target as HTMLTextAreaElement;
const computed_styles = window.getComputedStyle(target);
@ -162,9 +173,9 @@
const line_height = parseFloat(computed_styles.lineHeight);
let max =
max_lines === undefined
_max_lines === undefined
? false
: padding_top + padding_bottom + line_height * max_lines;
: padding_top + padding_bottom + line_height * _max_lines;
let min = padding_top + padding_bottom + lines * line_height;
target.style.height = "1px";
@ -185,7 +196,7 @@
_el: HTMLTextAreaElement,
_value: string
): any | undefined {
if (lines === max_lines) return;
if (lines === _max_lines) return;
_el.style.overflowY = "scroll";
_el.addEventListener("input", resize);
@ -220,7 +231,7 @@
<BlockTitle {root} {show_label} {info}>{label}</BlockTitle>
<div class="input-container">
{#if lines === 1 && max_lines === 1}
{#if lines === 1 && _max_lines === 1}
{#if type === "text"}
<input
data-testid="textbox"

View File

@ -17,7 +17,7 @@ class TestTextbox:
assert text_input.postprocess(2.14) == "2.14" # type: ignore
assert text_input.get_config() == {
"lines": 1,
"max_lines": 20,
"max_lines": None,
"placeholder": None,
"value": None,
"name": "textbox",
@ -86,9 +86,3 @@ class TestTextbox:
ValueError, match='`type` must be one of "text", "password", or "email".'
):
gr.Textbox(type="boo") # type: ignore
def test_max_lines(self):
assert gr.Textbox(type="password").get_config().get("max_lines") == 1
assert gr.Textbox(type="email").get_config().get("max_lines") == 1
assert gr.Textbox(type="text").get_config().get("max_lines") == 20
assert gr.Textbox().get_config().get("max_lines") == 20

View File

@ -556,7 +556,6 @@ class TestProcessExamples:
assert response.json()["data"] == [
{
"lines": 1,
"max_lines": 20,
"show_label": True,
"container": True,
"min_width": 160,
@ -578,7 +577,6 @@ class TestProcessExamples:
assert response.json()["data"] == [
{
"lines": 1,
"max_lines": 20,
"show_label": True,
"container": True,
"min_width": 160,