feat: Add interactive dropdown selection for forms

- Parse and store OPTION elements in SELECT fields
- Display selected option text in dropdown UI
- Add SELECT_OPTION input mode for dropdown navigation
- Support Enter on SELECT to enter selection mode
- Use j/k or arrow keys to navigate through options
- Enter to confirm selection, Esc to cancel
- Auto-select first option or option marked with 'selected'
- Real-time option preview in status bar
- Status bar shows "-- SELECT --" mode

Data structure:
- Added options vector to DomNode (value, text pairs)
- Added selected_option index to track current selection

Keyboard shortcuts in SELECT mode:
- j/Down: Next option
- k/Up: Previous option
- Enter: Select current option
- Esc: Cancel selection
This commit is contained in:
m1ngsama 2025-12-28 00:03:39 +08:00
parent 7e55ade793
commit 58b7607074
7 changed files with 120 additions and 6 deletions

View file

@ -457,6 +457,7 @@ public:
case InputMode::COMMAND: case InputMode::COMMAND:
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break; case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
case InputMode::FORM_EDIT: mode_str = "-- INSERT -- " + input_handler.get_buffer(); break; case InputMode::FORM_EDIT: mode_str = "-- INSERT -- " + input_handler.get_buffer(); break;
case InputMode::SELECT_OPTION: mode_str = "-- SELECT --"; break;
default: mode_str = ""; break; default: mode_str = ""; break;
} }
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG); framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
@ -554,6 +555,10 @@ public:
// Toggle checkbox // Toggle checkbox
field->checked = !field->checked; field->checked = !field->checked;
status_message = field->checked ? "☑ Checked" : "☐ Unchecked"; status_message = field->checked ? "☑ Checked" : "☐ Unchecked";
} else if (field->input_type == "select") {
// Enter dropdown selection mode
input_handler.set_mode(InputMode::SELECT_OPTION);
status_message = "-- SELECT -- (j/k to navigate, Enter to select, Esc to cancel)";
} else if (field->input_type == "submit" || field->element_type == ElementType::BUTTON) { } else if (field->input_type == "submit" || field->element_type == ElementType::BUTTON) {
// TODO: Submit form // TODO: Submit form
status_message = "Form submit (not yet implemented)"; status_message = "Form submit (not yet implemented)";
@ -688,6 +693,37 @@ public:
} }
break; break;
case Action::NEXT_OPTION:
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
auto* field = current_tree.form_fields[active_field];
if (field && field->input_type == "select" && !field->options.empty()) {
field->selected_option = (field->selected_option + 1) % field->options.size();
status_message = "Option: " + field->options[field->selected_option].second;
}
}
break;
case Action::PREV_OPTION:
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
auto* field = current_tree.form_fields[active_field];
if (field && field->input_type == "select" && !field->options.empty()) {
field->selected_option = (field->selected_option - 1 + field->options.size()) %
field->options.size();
status_message = "Option: " + field->options[field->selected_option].second;
}
}
break;
case Action::SELECT_CURRENT_OPTION:
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
auto* field = current_tree.form_fields[active_field];
if (field && field->input_type == "select" && !field->options.empty()) {
field->value = field->options[field->selected_option].first;
status_message = "Selected: " + field->options[field->selected_option].second;
}
}
break;
case Action::QUIT: case Action::QUIT:
break; // 在main loop处理 break; // 在main loop处理

View file

@ -370,6 +370,29 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
} }
} }
// For SELECT, collect all OPTION children
if (element.tag == GUMBO_TAG_SELECT) {
for (const auto& child : node->children) {
if (child->element_type == ElementType::OPTION) {
std::string option_value = child->value.empty() ? child->get_all_text() : child->value;
std::string option_text = child->get_all_text();
node->options.push_back({option_value, option_text});
// Set selected option if marked
if (child->checked) {
node->selected_option = node->options.size() - 1;
node->value = option_value;
}
}
}
// Set default value to first option if no option is selected
if (!node->options.empty() && node->value.empty()) {
node->value = node->options[0].first;
node->selected_option = 0;
}
}
// Reset form ID if we are exiting a form // Reset form ID if we are exiting a form
if (element.tag == GUMBO_TAG_FORM) { if (element.tag == GUMBO_TAG_FORM) {
g_current_form_id = -1; // Assuming no nested forms g_current_form_id = -1; // Assuming no nested forms

View file

@ -58,6 +58,10 @@ struct DomNode {
bool checked = false; bool checked = false;
int form_id = -1; int form_id = -1;
// SELECT元素的选项
std::vector<std::pair<std::string, std::string>> options; // (value, text) pairs
int selected_option = 0; // 当前选中的选项索引
// 辅助方法 // 辅助方法
bool is_block_element() const; bool is_block_element() const;
bool is_inline_element() const; bool is_inline_element() const;

View file

@ -386,6 +386,32 @@ public:
return result; return result;
} }
InputResult process_select_option_mode(int ch) {
InputResult result;
result.action = Action::NONE;
if (ch == 27) {
// ESC cancels selection
mode = InputMode::NORMAL;
return result;
} else if (ch == '\n' || ch == '\r') {
// Enter selects current option
result.action = Action::SELECT_CURRENT_OPTION;
mode = InputMode::NORMAL;
return result;
} else if (ch == 'j' || ch == KEY_DOWN) {
// Next option
result.action = Action::NEXT_OPTION;
return result;
} else if (ch == 'k' || ch == KEY_UP) {
// Previous option
result.action = Action::PREV_OPTION;
return result;
}
return result;
}
}; };
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {} InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
@ -406,6 +432,8 @@ InputResult InputHandler::handle_key(int ch) {
return pImpl->process_link_hints_mode(ch); return pImpl->process_link_hints_mode(ch);
case InputMode::FORM_EDIT: case InputMode::FORM_EDIT:
return pImpl->process_form_edit_mode(ch); return pImpl->process_form_edit_mode(ch);
case InputMode::SELECT_OPTION:
return pImpl->process_select_option_mode(ch);
default: default:
break; break;
} }

View file

@ -10,7 +10,8 @@ enum class InputMode {
SEARCH, SEARCH,
LINK, LINK,
LINK_HINTS, // Vimium-style 'f' mode LINK_HINTS, // Vimium-style 'f' mode
FORM_EDIT // Form field editing mode FORM_EDIT, // Form field editing mode
SELECT_OPTION // Dropdown selection mode
}; };
enum class Action { enum class Action {
@ -49,7 +50,10 @@ enum class Action {
ACTIVATE_FIELD, // Activate current field for editing (Enter) ACTIVATE_FIELD, // Activate current field for editing (Enter)
TOGGLE_CHECKBOX, // Toggle checkbox state TOGGLE_CHECKBOX, // Toggle checkbox state
EDIT_TEXT, // Edit text input (updates text buffer) EDIT_TEXT, // Edit text input (updates text buffer)
SUBMIT_FORM // Submit form (Enter on submit button) SUBMIT_FORM, // Submit form (Enter on submit button)
NEXT_OPTION, // Move to next dropdown option (j/down)
PREV_OPTION, // Move to previous dropdown option (k/up)
SELECT_CURRENT_OPTION // Select current dropdown option (Enter)
}; };
struct InputResult { struct InputResult {

View file

@ -432,9 +432,13 @@ void LayoutEngine::layout_form_element(const DomNode* node, Context& /*ctx*/, st
span.field_index = node->field_index; span.field_index = node->field_index;
line.spans.push_back(span); line.spans.push_back(span);
} else if (node->element_type == ElementType::SELECT) { } else if (node->element_type == ElementType::SELECT) {
// 下拉选择 // 下拉选择 - 显示当前选中的选项
StyledSpan span; StyledSpan span;
span.text = "[▼ Select]"; std::string selected_text = "Select";
if (node->selected_option >= 0 && node->selected_option < static_cast<int>(node->options.size())) {
selected_text = node->options[node->selected_option].second;
}
span.text = "[▼ " + selected_text + "]";
span.fg = colors::INPUT_FOCUS; span.fg = colors::INPUT_FOCUS;
span.bg = colors::INPUT_BG; span.bg = colors::INPUT_BG;
span.field_index = node->field_index; span.field_index = node->field_index;

View file

@ -11,6 +11,21 @@
<p>Email: <input type="text" name="email" placeholder="email@example.com"></p> <p>Email: <input type="text" name="email" placeholder="email@example.com"></p>
<p>Password: <input type="password" name="password"></p> <p>Password: <input type="password" name="password"></p>
<p>Country: <select name="country">
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select></p>
<p>Age Group: <select name="age">
<option value="18-25" selected>18-25</option>
<option value="26-35">26-35</option>
<option value="36-50">36-50</option>
<option value="51+">51+</option>
</select></p>
<p><input type="checkbox" name="subscribe"> Subscribe to newsletter</p> <p><input type="checkbox" name="subscribe"> Subscribe to newsletter</p>
<p><input type="checkbox" name="agree"> I agree to terms</p> <p><input type="checkbox" name="agree"> I agree to terms</p>