mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 20:02:10 +00:00
M6: a drop-down's list follows the pointer and the keys, with a classic scroll bar
This commit is contained in:
@@ -1421,6 +1421,7 @@ mod revert_draft_tests {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
list: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+211
-60
@@ -40,6 +40,24 @@ pub struct Focus {
|
||||
/// The text the calendar last saw, so it moves to a typed date once, when the typing becomes
|
||||
/// a whole date, and otherwise keeps the month its arrows turned to.
|
||||
pub calendar_text: Option<String>,
|
||||
/// Drop-downs: where the open list is scrolled to and which item is current.
|
||||
pub list: ChoiceList,
|
||||
}
|
||||
|
||||
/// A drop-down's open list: the first item shown, the item the keys or the pointer are on, and
|
||||
/// where on the scroll bar's thumb a drag took hold.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ChoiceList {
|
||||
pub first: usize,
|
||||
pub current: Option<usize>,
|
||||
pub grab: Option<f32>,
|
||||
/// Wheel movement not yet a whole row.
|
||||
pub wheel: f32,
|
||||
/// The item the pointer was last over: entering another makes that one current, so a
|
||||
/// resting pointer doesn't take the highlight back from the arrow keys.
|
||||
pub pointer_on: Option<usize>,
|
||||
/// Set on the first frame, from the field's value.
|
||||
pub placed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -93,6 +111,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
list: ChoiceList::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,66 +615,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
|
||||
}
|
||||
}
|
||||
FormFieldKind::Combo => {
|
||||
// The list opens under the field and looks like it, as other viewers draw it: as wide as
|
||||
// the box, in its text size, black on white, without the field's internal name.
|
||||
let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0);
|
||||
let row = (size * 1.3).ceil();
|
||||
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.style_mut().visuals = egui::Visuals::light();
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
// A scroll bar that stays shown beside the items, so a longer list says so and
|
||||
// where it is scrolled to; it replaces the fade at the bottom edge.
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle {
|
||||
bar_width: 8.0,
|
||||
dormant_background_opacity: 1.0,
|
||||
active_background_opacity: 1.0,
|
||||
interact_background_opacity: 1.0,
|
||||
dormant_handle_opacity: 1.0,
|
||||
active_handle_opacity: 1.0,
|
||||
interact_handle_opacity: 1.0,
|
||||
..egui::style::ScrollStyle::solid()
|
||||
};
|
||||
ui.spacing_mut().scroll.fade.strength = 0.0;
|
||||
// The bar's track and handle, gray on the white list.
|
||||
let v = ui.visuals_mut();
|
||||
v.extreme_bg_color = Color32::from_gray(236);
|
||||
v.widgets.inactive.bg_fill = Color32::from_gray(168);
|
||||
v.widgets.hovered.bg_fill = Color32::from_gray(132);
|
||||
v.widgets.active.bg_fill = Color32::from_gray(100);
|
||||
egui::Frame::new().fill(Color32::WHITE).stroke(Stroke::new(1.0, Color32::from_gray(64))).show(ui, |ui| {
|
||||
let width = rect.width().max(24.0);
|
||||
ui.set_width(width);
|
||||
egui::ScrollArea::vertical().max_height(row * 9.0).show(ui, |ui| {
|
||||
for (export, display) in &f.options {
|
||||
let on = f.value.first() == Some(export);
|
||||
let (r, item) = ui.allocate_exact_size(vec2(ui.available_width(), row), egui::Sense::click());
|
||||
item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, display));
|
||||
let (fill, ink) = if on {
|
||||
(Color32::from_rgb(0x2F, 0x6F, 0xE0), Color32::WHITE)
|
||||
} else if item.hovered() {
|
||||
(Color32::from_gray(222), Color32::BLACK)
|
||||
} else {
|
||||
(Color32::WHITE, Color32::BLACK)
|
||||
};
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, fill);
|
||||
ui.painter().with_clip_rect(r).text(
|
||||
egui::pos2(r.min.x + 3.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
display,
|
||||
egui::FontId::proportional(size),
|
||||
ink,
|
||||
);
|
||||
if item.clicked() {
|
||||
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default();
|
||||
let (chosen, resp) = choice_list(ctx, view.id.0, f, w, rect, zoom, &mut st);
|
||||
if let Some(fx) = view.forms.focus.as_mut() {
|
||||
fx.list = st;
|
||||
}
|
||||
if let Some((export, _)) = chosen.and_then(|i| f.options.get(i)) {
|
||||
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
|
||||
close = true;
|
||||
}
|
||||
let clicked_away = ctx.input(|i| i.pointer.any_click())
|
||||
&& !resp.response.contains_pointer()
|
||||
&& !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
|
||||
@@ -732,6 +700,189 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
|
||||
edit.or_else(|| view.pending_edit.take())
|
||||
}
|
||||
|
||||
/// Items a drop-down's list shows before it scrolls.
|
||||
const LIST_ROWS: usize = 9;
|
||||
|
||||
/// A drop-down's open list, under the field and drawn like it, as other viewers draw theirs: as
|
||||
/// wide as the box, its items in the field's text size and alignment, black on white with a blue
|
||||
/// highlight that follows the pointer and the arrow keys, and a scroll bar with step buttons when
|
||||
/// there are more than nine items. Returns the item chosen (a click, or Enter on the current one)
|
||||
/// and the list's response.
|
||||
fn choice_list(
|
||||
ctx: &egui::Context,
|
||||
view: u64,
|
||||
f: &FormField,
|
||||
w: &FormWidget,
|
||||
rect: Rect,
|
||||
zoom: f32,
|
||||
st: &mut ChoiceList,
|
||||
) -> (Option<usize>, egui::InnerResponse<()>) {
|
||||
let n = f.options.len();
|
||||
let shown = n.min(LIST_ROWS);
|
||||
let max_first = n.saturating_sub(shown);
|
||||
let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0);
|
||||
let row = (size * 1.45).round().max(8.0);
|
||||
let bar = if n > shown { (row * 0.7).round().clamp(12.0, 22.0) } else { 0.0 };
|
||||
let width = rect.width().max(24.0 + bar);
|
||||
if !st.placed {
|
||||
st.current = f.value.first().and_then(|v| f.options.iter().position(|(e, _)| e == v));
|
||||
st.first = st.current.map_or(0, |c| c.min(max_first));
|
||||
st.placed = true;
|
||||
}
|
||||
// Consumed, so the keys move through the list rather than the page.
|
||||
let key = |k| ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k));
|
||||
let moved = if n == 0 {
|
||||
None
|
||||
} else if key(egui::Key::ArrowUp) {
|
||||
Some(st.current.map_or(0, |c| c.saturating_sub(1)))
|
||||
} else if key(egui::Key::ArrowDown) {
|
||||
Some(st.current.map_or(0, |c| (c + 1).min(n - 1)))
|
||||
} else if key(egui::Key::PageUp) {
|
||||
Some(st.current.map_or(0, |c| c.saturating_sub(shown)))
|
||||
} else if key(egui::Key::PageDown) {
|
||||
Some(st.current.map_or(0, |c| (c + shown).min(n - 1)))
|
||||
} else if key(egui::Key::Home) {
|
||||
Some(0)
|
||||
} else if key(egui::Key::End) {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(m) = moved {
|
||||
st.current = Some(m);
|
||||
st.first = if m < st.first {
|
||||
m
|
||||
} else if m >= st.first + shown {
|
||||
m + 1 - shown
|
||||
} else {
|
||||
st.first
|
||||
};
|
||||
}
|
||||
let mut chosen = key(egui::Key::Enter).then_some(st.current).flatten();
|
||||
let align = match f.quadding {
|
||||
1 => egui::Align::Center,
|
||||
2 => egui::Align::Max,
|
||||
_ => egui::Align::Min,
|
||||
};
|
||||
let resp = egui::Area::new(egui::Id::new(("form-choices", view))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(ctx, |ui| {
|
||||
let (outer, area) = ui.allocate_exact_size(vec2(width, shown as f32 * row + 2.0), egui::Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE);
|
||||
// The wheel scrolls the list, a row at a time, and not the page under it.
|
||||
if area.hovered() && n > shown {
|
||||
st.wheel += ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
ctx.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
let steps = (st.wheel / row).trunc();
|
||||
if steps != 0.0 {
|
||||
st.wheel -= steps * row;
|
||||
st.first = (st.first as f32 - steps).clamp(0.0, max_first as f32) as usize;
|
||||
}
|
||||
}
|
||||
let mut pointer_on = None;
|
||||
let font = egui::FontId::proportional(size);
|
||||
for k in 0..shown {
|
||||
let i = st.first + k;
|
||||
let Some((_, display)) = f.options.get(i) else { break };
|
||||
let r = Rect::from_min_size(outer.min + vec2(1.0, 1.0 + k as f32 * row), vec2(width - 2.0 - bar, row));
|
||||
let item = ui.interact(r, ui.id().with(("choice", i)), egui::Sense::click());
|
||||
item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, st.current == Some(i), display));
|
||||
if item.hovered() {
|
||||
pointer_on = Some(i);
|
||||
if st.pointer_on != Some(i) {
|
||||
st.current = Some(i);
|
||||
}
|
||||
}
|
||||
if item.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
let on = st.current == Some(i);
|
||||
if on {
|
||||
p.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0x1F, 0x65, 0xD6));
|
||||
}
|
||||
let galley = p.layout_no_wrap(display.clone(), font.clone(), if on { Color32::WHITE } else { Color32::BLACK });
|
||||
let inner = r.shrink2(vec2(3.0, 0.0));
|
||||
let x = match align {
|
||||
egui::Align::Center => inner.center().x - galley.size().x / 2.0,
|
||||
egui::Align::Max => inner.max.x - galley.size().x,
|
||||
egui::Align::Min => inner.min.x,
|
||||
};
|
||||
p.with_clip_rect(r).galley(egui::pos2(x, r.center().y - galley.size().y / 2.0), galley, Color32::BLACK);
|
||||
}
|
||||
st.pointer_on = pointer_on;
|
||||
if bar > 0.0 {
|
||||
scroll_bar(ui, Rect::from_min_max(egui::pos2(outer.max.x - 1.0 - bar, outer.min.y + 1.0), outer.max - vec2(1.0, 1.0)), shown, n, st);
|
||||
}
|
||||
p.rect_stroke(outer, CornerRadius::ZERO, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Inside);
|
||||
});
|
||||
(chosen, resp)
|
||||
}
|
||||
|
||||
/// A list's classic scroll bar: step buttons at each end, a thumb sized to the share of the
|
||||
/// items shown that can be dragged, and a track that pages when clicked.
|
||||
fn scroll_bar(ui: &egui::Ui, track: Rect, shown: usize, n: usize, st: &mut ChoiceList) {
|
||||
let max_first = n.saturating_sub(shown);
|
||||
if max_first == 0 {
|
||||
return;
|
||||
}
|
||||
let side = track.width();
|
||||
let up = Rect::from_min_size(track.min, vec2(side, side));
|
||||
let down = Rect::from_min_max(egui::pos2(track.min.x, track.max.y - side), track.max);
|
||||
let trough = Rect::from_min_max(egui::pos2(track.min.x, up.max.y), egui::pos2(track.max.x, down.min.y));
|
||||
let thumb_len = (trough.height() * shown as f32 / n as f32).max(side * 0.75).min(trough.height());
|
||||
let travel = (trough.height() - thumb_len).max(1.0);
|
||||
let thumb_at = |first: usize| trough.min.y + travel * first as f32 / max_first as f32;
|
||||
let id = ui.id().with("choice-bar");
|
||||
let p = ui.painter();
|
||||
p.rect_filled(track, CornerRadius::ZERO, Color32::from_gray(240));
|
||||
for (r, step, pointing_up) in [(up, -1i64, true), (down, 1, false)] {
|
||||
let b = ui.interact(r, id.with(pointing_up), egui::Sense::click());
|
||||
if b.clicked() {
|
||||
st.first = (st.first as i64 + step).clamp(0, max_first as i64) as usize;
|
||||
}
|
||||
p.rect_filled(r.shrink(1.0), CornerRadius::ZERO, if b.hovered() { Color32::from_gray(205) } else { Color32::from_gray(222) });
|
||||
p.rect_stroke(r.shrink(1.0), CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(150)), egui::StrokeKind::Inside);
|
||||
let (c, h) = (r.center(), (side * 0.18).max(2.0));
|
||||
let tip = if pointing_up { -h } else { h };
|
||||
let points = vec![egui::pos2(c.x - h * 1.4, c.y - tip / 2.0), egui::pos2(c.x + h * 1.4, c.y - tip / 2.0), egui::pos2(c.x, c.y + tip / 2.0)];
|
||||
p.add(egui::Shape::convex_polygon(points, Color32::from_gray(40), Stroke::NONE));
|
||||
}
|
||||
let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len));
|
||||
let t = ui.interact(thumb, id.with("thumb"), egui::Sense::drag());
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
if t.drag_started() {
|
||||
st.grab = pointer.map(|q| q.y - thumb.min.y);
|
||||
}
|
||||
if t.dragged()
|
||||
&& let (Some(q), Some(grab)) = (pointer, st.grab)
|
||||
{
|
||||
let share = ((q.y - grab - trough.min.y) / travel).clamp(0.0, 1.0);
|
||||
st.first = (share * max_first as f32).round() as usize;
|
||||
}
|
||||
if t.drag_stopped() {
|
||||
st.grab = None;
|
||||
}
|
||||
let page = ui.interact(trough, id.with("trough"), egui::Sense::click());
|
||||
if page.clicked()
|
||||
&& let Some(q) = pointer
|
||||
&& !thumb.contains(q)
|
||||
{
|
||||
st.first = if q.y < thumb.min.y { st.first.saturating_sub(shown) } else { (st.first + shown).min(max_first) };
|
||||
}
|
||||
let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)).shrink2(vec2(1.0, 0.5));
|
||||
p.rect_filled(thumb, CornerRadius::ZERO, if t.dragged() || t.hovered() { Color32::from_gray(176) } else { Color32::from_gray(196) });
|
||||
p.rect_stroke(thumb, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(140)), egui::StrokeKind::Inside);
|
||||
// Grip lines across the middle of the thumb.
|
||||
if thumb.height() > side {
|
||||
for k in -1..=1 {
|
||||
let y = thumb.center().y + k as f32 * 3.0;
|
||||
p.line_segment(
|
||||
[egui::pos2(thumb.min.x + side * 0.3, y), egui::pos2(thumb.max.x - side * 0.3, y)],
|
||||
Stroke::new(1.0, Color32::from_gray(120)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a field's widget is on screen (tests and automation).
|
||||
pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: usize) -> Option<Rect> {
|
||||
let w = f.widgets.get(widget)?;
|
||||
|
||||
@@ -1138,3 +1138,87 @@ fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() {
|
||||
// A scroll bar that stays shown takes its own room, so people see there is more below.
|
||||
assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}");
|
||||
}
|
||||
|
||||
/// Where `text` is drawn in the last frame, if anywhere.
|
||||
fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<egui::Rect> {
|
||||
fn walk(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
|
||||
match shape {
|
||||
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)),
|
||||
egui::Shape::Text(t) if t.galley.text() == text => Some(egui::Rect::from_min_size(t.pos, t.galley.size())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
h.output().shapes.iter().find_map(|c| walk(&c.shape, text))
|
||||
}
|
||||
|
||||
fn long_country(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let options: Vec<String> = (0..30).map(|i| format!("{i:02}00")).collect();
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||||
name: "country".into(),
|
||||
props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }),
|
||||
}));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_downs_list_aligns_its_items_as_the_field_does() {
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||||
name: "country".into(),
|
||||
props: Box::new(pdfcraft_engine::FieldProps { quadding: Some(1), ..Default::default() }),
|
||||
}));
|
||||
h.run_steps(3);
|
||||
click_field(&mut h, "country", 0);
|
||||
let country = field_rect(&h, "country");
|
||||
let france = text_rect(&h, "France").expect("drawn");
|
||||
assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() {
|
||||
let mut h = harness();
|
||||
click_field(&mut h, "country", 0);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(1);
|
||||
assert!(value(&h, "country").is_empty(), "the arrows only move through the list");
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "country"), ["fr"]);
|
||||
assert!(h.state().views[0].forms.focus.is_none(), "Enter closes the list");
|
||||
// Reopened, the list starts from the field's value.
|
||||
click_field(&mut h, "country", 0);
|
||||
h.key_press(egui::Key::ArrowUp);
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "country"), ["ca"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() {
|
||||
let mut h = harness();
|
||||
long_country(&mut h);
|
||||
click_field(&mut h, "country", 0);
|
||||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_none(), "nine rows show");
|
||||
let country = field_rect(&h, "country");
|
||||
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
|
||||
// The bar's down button, at the bottom of the bar beside the ninth row.
|
||||
let bar = country.right() - first.right();
|
||||
let down = egui::pos2(country.right() - bar / 2.0, first.top() + first.height() * 9.0 - bar / 2.0);
|
||||
h.hover_at(down);
|
||||
h.run_steps(1);
|
||||
h.drag_at(down);
|
||||
h.run_steps(1);
|
||||
h.drop_at(down);
|
||||
h.run_steps(2);
|
||||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_some(), "one row on");
|
||||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none());
|
||||
h.key_press(egui::Key::End);
|
||||
h.run_steps(2);
|
||||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "2900").is_some(), "End goes to the last item");
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "country"), ["2900"]);
|
||||
}
|
||||
|
||||
@@ -4440,8 +4440,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.fields"]
|
||||
tools = ["form_fill"]
|
||||
evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/ui-egui/tests/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
|
||||
notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text. A combo box shows its arrow on screen and opens its list under the field, as wide as the box and in its text size, black on white, with a scroll bar shown beside a longer list; the list takes no arrow keys yet"
|
||||
evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/ui-egui/tests/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/ui-egui/tests/forms.rs::the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
|
||||
notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text. A combo box shows its arrow on screen and opens its list under the field, as wide as the box, in its text size and alignment, black on white with a highlight that follows the pointer; more than nine items get a scroll bar with step buttons, the wheel scrolls it, and the arrow keys, Page Up/Down, Home and End move through it, Enter choosing. The list is drawn in the interface font, not the field's (no serif interface font ships yet)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.push-buttons"
|
||||
|
||||
Reference in New Issue
Block a user