Input

User input components

Input components handle user input in your terminal app. This guide covers text input and keyboard event handling.

TextField

TextField provides a text input field:

class MyForm extends StatefulComponent {
  const MyForm({super.key});

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Component build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: BoxDecoration(
        border: BoxBorder.all(color: Colors.gray),
      ),
    );
  }
}

TextEditingController

Use TextEditingController to read and control text:

final controller = TextEditingController();

// Get current text
String text = controller.text;

// Set text
controller.text = 'New text';

// Clear text
controller.clear();

// Listen to changes
controller.addListener(() {
  print('Text changed: ${controller.text}');
});

// Always dispose when done
controller.dispose();

Multi-line input

TextField supports multi-line input:

TextField(
  controller: controller,
  maxLines: 5,  // Allow 5 lines
)

Styling TextField

Style the text field:

TextField(
  controller: controller,
  style: TextStyle(color: Colors.green),
  decoration: BoxDecoration(
    border: BoxBorder.all(color: Colors.blue),
    color: Colors.black,
  ),
  padding: EdgeInsets.all(1),
)

TextField callbacks

React to text changes:

TextField(
  controller: controller,
  onChanged: (text) {
    print('Text is now: $text');
  },
  onSubmitted: (text) {
    print('User pressed Enter with: $text');
  },
)

Focusable

Focusable makes any component respond to keyboard input:

class Counter extends StatefulComponent {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Component build(BuildContext context) {
    return Focusable(
      focused: true,
      onKeyEvent: (event) {
        if (event.logicalKey == LogicalKey.arrowUp) {
          setState(() => _count++);
          return true;  // Event handled
        }
        if (event.logicalKey == LogicalKey.arrowDown) {
          setState(() => _count--);
          return true;
        }
        return false;  // Not handled, pass to parent
      },
      child: Text('Count: $_count (use arrows)'),
    );
  }
}

KeyboardEvent

The onKeyEvent callback receives a KeyboardEvent:

onKeyEvent: (event) {
  // Check specific keys
  if (event.logicalKey == LogicalKey.enter) {
    // Handle Enter
  }

  // Check for character input
  if (event.character != null) {
    print('User typed: ${event.character}');
  }

  // Check modifiers
  if (event.isControlPressed && event.logicalKey == LogicalKey.keyC) {
    // Handle Ctrl+C
  }

  return true;  // Return true if handled
}

Common keys

Use LogicalKey constants:

LogicalKey.enter
LogicalKey.escape
LogicalKey.space
LogicalKey.arrowUp
LogicalKey.arrowDown
LogicalKey.arrowLeft
LogicalKey.arrowRight
LogicalKey.backspace
LogicalKey.delete
LogicalKey.tab

// Letter keys
LogicalKey.keyA
LogicalKey.keyB
// ... etc

// Number keys
LogicalKey.digit1
LogicalKey.digit2
// ... etc

Event handling return value

Return true if you handled the event, false otherwise:

onKeyEvent: (event) {
  if (event.logicalKey == LogicalKey.space) {
    doSomething();
    return true;   // Handled - stops propagation
  }
  return false;    // Not handled - bubbles to parent
}

Events bubble up the component tree until a handler returns true.

Focus management

Control which component receives keyboard input:

class App extends StatefulComponent {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  int _focusedIndex = 0;

  @override
  Component build(BuildContext context) {
    return Column(
      children: [
        Focusable(
          focused: _focusedIndex == 0,
          onKeyEvent: (event) {
            if (event.logicalKey == LogicalKey.tab) {
              setState(() => _focusedIndex = 1);
              return true;
            }
            // Handle input for first field
            return false;
          },
          child: Text('Field 1 ${_focusedIndex == 0 ? '(focused)' : ''}'),
        ),
        Focusable(
          focused: _focusedIndex == 1,
          onKeyEvent: (event) {
            if (event.logicalKey == LogicalKey.tab) {
              setState(() => _focusedIndex = 0);
              return true;
            }
            // Handle input for second field
            return false;
          },
          child: Text('Field 2 ${_focusedIndex == 1 ? '(focused)' : ''}'),
        ),
      ],
    );
  }
}

Only the Focusable with focused: true receives keyboard events.

Form patterns

Simple form

class LoginForm extends StatefulComponent {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();
  int _focusedField = 0;

  @override
  void dispose() {
    _usernameController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    print('Username: ${_usernameController.text}');
    print('Password: ${_passwordController.text}');
  }

  @override
  Component build(BuildContext context) {
    return Column(
      children: [
        Focusable(
          focused: _focusedField == 0,
          onKeyEvent: (event) {
            if (event.logicalKey == LogicalKey.tab) {
              setState(() => _focusedField = 1);
              return true;
            }
            if (event.logicalKey == LogicalKey.enter) {
              _submit();
              return true;
            }
            return false;
          },
          child: TextField(
            controller: _usernameController,
            decoration: BoxDecoration(
              border: _focusedField == 0
                  ? BoxBorder.all(color: Colors.blue)
                  : BoxBorder.all(color: Colors.gray),
            ),
          ),
        ),
        SizedBox(height: 1),
        Focusable(
          focused: _focusedField == 1,
          onKeyEvent: (event) {
            if (event.logicalKey == LogicalKey.tab) {
              setState(() => _focusedField = 0);
              return true;
            }
            if (event.logicalKey == LogicalKey.enter) {
              _submit();
              return true;
            }
            return false;
          },
          child: TextField(
            controller: _passwordController,
            obscureText: true,
            decoration: BoxDecoration(
              border: _focusedField == 1
                  ? BoxBorder.all(color: Colors.blue)
                  : BoxBorder.all(color: Colors.gray),
            ),
          ),
        ),
      ],
    );
  }
}

Validation

Validate input before submission:

void _submit() {
  if (_usernameController.text.isEmpty) {
    setState(() => _error = 'Username required');
    return;
  }

  if (_passwordController.text.length < 8) {
    setState(() => _error = 'Password must be at least 8 characters');
    return;
  }

  // Process valid input
  _login(_usernameController.text, _passwordController.text);
}

Best practices

Always dispose controllers

TextEditingController must be disposed:

@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

Provide visual focus indicators

Show users which field is focused:

TextField(
  decoration: BoxDecoration(
    border: BoxBorder.all(
      color: isFocused ? Colors.blue : Colors.gray,
    ),
  ),
)

Handle keyboard shortcuts consistently

Use standard shortcuts:

  • Tab: Move to next field
  • Shift+Tab: Move to previous field
  • Enter: Submit form or activate
  • Escape: Cancel or close

Test on different terminals

Keyboard handling varies between terminals. Test your input on multiple terminals to ensure compatibility.

Next steps