Files
pdfcraft/crates/ui-egui/src/forms_ui.rs
T
alehelandClaude Opus 5.5 9f2e1e3293 fix(ui): a tap on an open field or paragraph editor is not a click away
The form field editors and the Edit text paragraph editor are drawn over the pages,
outside the page pointer, and close on a click away. With a finger, or a pen on
Windows (winit reports it as touch), they closed on taps inside them:
- A tap on a combo box opened its list and closed it in the same frame, so a choice
  could not be picked by touch.
- A tap on a list box row picked it, but the list lost focus, so the arrow keys no
  longer moved the choice.
- A tap on a date field's calendar arrows closed the field.
- With a paragraph open in Edit text, a tap on a Format text control that only
  opens a list, such as Line spacing, applied and closed the paragraph, and the list
  went with it.

Two things were wrong. The combo and list boxes judged the click by hover_pos, and
the paragraph's "outside the document" by latest_pos. Both are None on the frame egui
reports a tap, since egui-winit sends PointerGone with every touch release. They now
read interact_pos, which keeps the tap's position on that frame (egui's own
clicked_elsewhere does the same). And egui 0.36 reports a lost focus for two frames.
The text field editor and the paragraph editor judged their exceptions again on the
second frame, when a tap has no position left and a mouse may have moved on. They now
judge a loss once, on its own frame (edit_text_ui::lost_focus_once, with a flag kept
per editor). A focus taken later in a frame is still seen on the next one, as before.

Tests: tests/it/editor_taps.rs taps with support::input::touch_tap, which sends
the release and PointerGone in one frame as egui-winit does, and pairs each tap with a
mouse click as the control. That click leaves the pointer in the window: kittest's
drop_at sends PointerGone a frame later, which the second-frame judging also turned
into a click away. With the source reverted, the four tap tests fail at their bug
assertion and the controls pass. Reverting one part at a time fails only its own
tests: hover_pos in the combo and list check, egui's lost_focus in the field editor
(the calendar) or in the paragraph editor, and latest_pos for the paragraph's outside.
A tap inside an open text field already worked and has a guard test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:54:28 +02:00

948 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode).
//!
//! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click, or on
//! Space once Tab or a click has focused them. List boxes are directly selectable in-place on the
//! page (clicking picks an option; Ctrl-click toggles on multi-select lists). Combo boxes open a
//! list of their options; an editable one (Edit flag) also takes typed text in a box over the
//! field. Every change is one undoable engine edit.
use std::collections::HashSet;
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// Background of the in-place text field editor, the same in every theme.
const FIELD_FILL: Color32 = Color32::from_rgb(0xFF, 0xFF, 0xF4);
/// The field being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct Focus {
pub name: String,
/// Which of the field's widgets (a field can appear on several pages).
pub widget: usize,
/// Text being typed (text fields).
pub text: String,
/// Choices picked so far (list boxes with multiple selection).
pub picked: Vec<String>,
pub request_focus: bool,
/// Select the whole value when the editor opens (entered with Tab), so typing replaces it.
pub select_all: bool,
/// Where the press that opened the editor landed: the editor puts its caret there and takes
/// over the drag, so press-and-drag selects text in a field that was not focused (#2213).
pub press: Option<egui::Pos2>,
/// Date fields: the month the calendar shows (year, month 1–12), and where it was drawn.
pub calendar: Option<(i32, u32)>,
pub calendar_rect: Option<egui::Rect>,
/// 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>,
/// Whether the text box had the keyboard at the end of the editor's last frame
/// ([`crate::edit_text_ui::lost_focus_once`]).
pub(crate) had_focus: bool,
}
#[derive(Clone, Debug, Default)]
pub struct FormView {
pub focus: Option<Focus>,
/// The draft whose edit is queued (`DocView::pending_edit`): if the field refuses the value,
/// the editor reopens with it rather than losing the typing.
pub(crate) committed: Option<Focus>,
/// A press on a text field committed the field before it (#2213). Until the button is
/// released, that field losing focus to the same gesture must not commit it again: a
/// refused value would be validated (and alerted) twice.
pub(crate) pressed: bool,
/// Fields modified by the user whose appearance stream has not been rasterized by the render worker yet.
/// Tracked as (field_name, page_index) so committed edits stay immediately visible without flicker.
pub unbaked: HashSet<(String, usize)>,
/// A message for the app to show (e.g. "buttons run JavaScript").
pub notice: Option<FormNotice>,
/// A push button was clicked: (its field name, what it does).
pub button: Option<(String, pdfcraft_engine::form_scripts::ButtonAction)>,
/// The focused check box or radio button was clicked. It toggles once no other edit is
/// queued: the text the click committed goes first, and the toggle is dropped if that text
/// is refused and its field reopens.
pub(crate) toggle: bool,
/// The widget holding egui's keyboard focus for the focused check box or radio button, and
/// whether it takes the arrows (radio buttons). The page keys still work around it.
pub(crate) keys: Option<(egui::Id, bool)>,
}
/// A form message for the app to show, with document data kept separate so the visible
/// text follows the UI language.
#[derive(Clone, Debug)]
pub enum FormNotice {
/// Filling is blocked by the document's security settings.
Security,
/// A read-only field was clicked (its name).
ReadOnly(String),
/// A push button with no action was clicked (its name).
NoAction(String),
}
fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32])
}
fn fillable(f: &FormField) -> bool {
!f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature)
}
/// Whether a combo box takes typed text as well as a choice from its list: the Edit flag
/// (ISO 32000-2 Table 233), which only means something with Combo set.
fn editable_combo(f: &FormField) -> bool {
f.kind == FormFieldKind::Combo && f.has(field_flags::EDIT)
}
/// The text a field's editor starts with: its value, or for a combo box the shown text of the
/// option it picks (a free value typed into an editable one has no option, and shows as is).
pub(crate) fn draft_text(f: &FormField) -> String {
let value = f.value.first().cloned().unwrap_or_default();
if f.kind == FormFieldKind::Combo
&& let Some((_, shown)) = f.options.iter().find(|(export, _)| *export == value)
{
return shown.clone();
}
value
}
fn open_focus(f: &FormField, widget: usize) -> Focus {
Focus {
name: f.name.clone(),
widget,
text: draft_text(f),
picked: f.value.clone(),
request_focus: true,
select_all: false,
press: None,
calendar: None,
calendar_rect: None,
calendar_text: None,
had_focus: false,
}
}
/// Select a radio button, if it isn't selected already.
fn select_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
(w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) })
}
/// What clicking a check box or radio button does.
fn toggle_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
let value = match f.kind {
FormFieldKind::CheckBox => FieldValue::Check(f.value.is_empty()),
FormFieldKind::Radio => {
let on = w.on_state.clone();
// Clicking the selected button again turns it off unless the group forbids it.
let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on };
if value.as_ref() == f.value.first() {
return None;
}
FieldValue::Radio(value)
}
_ => return None,
};
Some(Edit::SetFieldValue { name: f.name.clone(), value })
}
/// Where the pointer is relative to one page's form widgets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FieldPointer {
/// Not over a field.
Away,
/// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see
/// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set
/// on printed text, is a button, not text to select.
Over,
/// The field owns the press or click.
Owned,
}
/// Clicks on form widgets of one page.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
form: &[FormField],
allowed: bool,
view: &mut DocView,
) -> FieldPointer {
let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away };
let hit = form.iter().find_map(|f| {
f.widgets
.iter()
.enumerate()
.find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p))
.map(|(i, w)| (f, i, w))
});
let Some((f, wi, w)) = hit else { return FieldPointer::Away };
// The field's tooltip (`/TU`), as Acrobat shows it on hover (#3854). ISO 32000-2 Table 226
// makes it the name the user interface identifies the field by.
if let Some(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) {
resp.clone().on_hover_text_at_pointer(tip);
}
// Empty signature fields are signed by clicking them (Use a certificate); push buttons run
// their action.
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some();
// A radio button of a group can be read-only on its own.
let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature);
// Only where the page shows: the mode bar and side panels over a field scrolled under them
// keep their own cursor (#2755).
if resp.contains_pointer() {
ui.ctx().set_cursor_icon(match (usable, f.kind) {
(false, _) => egui::CursorIcon::NotAllowed,
(true, FormFieldKind::Text) => egui::CursorIcon::Text,
_ => egui::CursorIcon::PointingHand,
});
}
// A text field opens on the press rather than the click, so a drag that starts on it
// selects its text (#2213).
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some();
if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) });
view.forms.pressed = true;
}
if !resp.clicked() {
return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over };
}
if !allowed {
view.forms.notice = Some(FormNotice::Security);
return FieldPointer::Owned;
}
match f.kind {
_ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
FormFieldKind::PushButton => match &f.button {
Some(a) => view.forms.button = Some((f.name.clone(), a.clone())),
None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())),
},
FormFieldKind::Signature => view.sign.field = Some(f.name.clone()),
FormFieldKind::CheckBox | FormFieldKind::Radio => {
// The box takes the focus, as in Acrobat, so Tab goes on from it (#2223). The overlay
// toggles it, after the edit for any text this commits.
if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
}
view.forms.toggle = true;
}
// The press already committed the previous field and opened this one (above), unless
// press and release came in one frame. Committing again would re-run a validation that
// refused the value on the press.
FormFieldKind::Text if ptr.pressed().is_none() => {
// An editor opened by the press cannot be hit until a frame has laid it out: the
// release lands on the page and would take its focus away.
if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) {
x.request_focus = true;
}
}
FormFieldKind::Text | FormFieldKind::Combo => {
if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
}
}
FormFieldKind::List => {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
let r = widget_rect(xf, info, page, w.rect);
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let zoom = (r.height() as f64) / height_pt;
let y_pt = (p.y - r.min.y) as f64 / zoom;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) {
let export = &f.options[opt_idx].0;
let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
let shift = ui.input(|i| i.modifiers.shift);
let multi = f.has(field_flags::MULTI_SELECT);
let new_value = if multi && ctrl {
let mut picked = f.value.clone();
if let Some(pos) = picked.iter().position(|x| x == export) {
picked.remove(pos);
} else {
picked.push(export.clone());
}
FieldValue::Choice(picked)
} else if multi && shift && !f.value.is_empty() {
let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx);
let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx));
let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect();
FieldValue::Choice(picked)
} else {
FieldValue::Choice(vec![export.clone()])
};
if new_value != FieldValue::Choice(f.value.clone()) {
view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value });
}
}
}
}
FieldPointer::Owned
}
/// The edit the focused field's draft makes, if it differs from the field's value.
pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option<Edit> {
let f = form.iter().find(|f| f.name == focus.name)?;
let value = match f.kind {
FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()),
// Typed into an editable combo box: a shown text picks its option, anything else is the
// value itself (the engine maps it, and refuses free text when Edit is clear).
FormFieldKind::Combo if editable_combo(f) && focus.text != draft_text(f) && f.value.first() != Some(&focus.text) => {
FieldValue::Choice(if focus.text.is_empty() { Vec::new() } else { vec![focus.text.clone()] })
}
_ => return None,
};
Some(Edit::SetFieldValue { name: f.name.clone(), value })
}
/// Close the focused field's editor, queueing the edit for its draft if it differs from the
/// field's value.
pub(crate) fn commit(view: &mut DocView, form: &[FormField]) {
let Some(focus) = view.forms.focus.take() else { return };
if let Some(edit) = draft_edit(&focus, form) {
view.pending_edit = Some(edit);
view.forms.committed = Some(focus);
}
}
/// The text box of an editable combo box, over the field. What is typed becomes the value when
/// Enter, Tab or a click away commits it; a pick from the list still sets an option.
#[allow(clippy::too_many_arguments)]
fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) {
let mut enter = false;
egui::Area::new(egui::Id::new(("form-editor", view.id.0)))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(rect.min)
.constrain_to(view.viewport_rect())
.constrain(false)
.show(ctx, |ui| {
ui.set_min_size(rect.size());
let Some(fx) = view.forms.focus.as_mut() else { return };
let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
// The box is pale yellow in every theme, as a text field's is (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
let te = egui::TextEdit::singleline(&mut fx.text)
.desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0))
.font(egui::FontId::proportional(font))
.background_color(FIELD_FILL)
.text_color(Color32::BLACK)
.margin(egui::Margin::symmetric(3, 1))
.id(egui::Id::new(("form-field", view.id.0, &f.name, widget)));
let r = ui.add_sized(rect.size(), te);
if std::mem::take(&mut fx.request_focus) {
r.request_focus();
// Entered with Tab: typing replaces the value.
if std::mem::take(&mut fx.select_all)
&& let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
{
let n = fx.text.chars().count();
st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
st.store(ui.ctx(), r.id);
}
}
enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
});
if enter {
commit(view, form);
}
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// The date picker under a focused date field: a month of days, and a Today button under them.
/// Returns the picked date, formatted with the field's own format.
fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option<String> {
use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date};
let fx = view.forms.focus.as_mut()?;
let current = parse_date(&fx.text, fmt);
// A whole date typed since the last frame moves the calendar to it; half a date, or text
// that hasn't changed, leaves it on the month it shows (the one the arrows turned to).
let changed = fx.calendar_text.as_deref() != Some(fx.text.as_str());
let (mut y, mut m) = match (fx.calendar, current) {
(Some(_), Some(d)) if changed => (d.y, d.m),
(Some(shown), _) => shown,
(None, Some(d)) => (d.y, d.m),
(None, None) => (today.0 as i32, today.1),
};
if changed {
fx.calendar_text = Some(fx.text.clone());
}
let mut picked = None;
let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0)))
.order(egui::Order::Foreground)
.fixed_pos(field.left_bottom() + vec2(0.0, 2.0))
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
if ui.small_button("‹").on_hover_text(tl!("Previous month")).clicked() {
(y, m) = if m == 1 { (y - 1, 12) } else { (y, m - 1) };
}
ui.label(
egui::RichText::new(crate::i18n::fmt(
tl!("{month} {y}"),
&[("month", tl!(MONTHS[(m.clamp(1, 12) - 1) as usize])), ("y", &y.to_string())],
))
.strong(),
);
if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() {
// `y + 1` would overflow at `i32::MAX` (a date parsed or persisted with
// that year); stay put on December of the last year instead of panicking.
(y, m) = if m == 12 { y.checked_add(1).map_or((y, m), |next| (next, 1)) } else { (y, m + 1) };
}
});
// Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday).
use chrono::Datelike;
// The month the calendar shows: the weekday of its 1st (0 = Sunday) and its
// length, both from chrono. A year chrono can't represent falls back to a plain
// 31-day month starting on Sunday, which only the arrows can reach.
let first_of = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
let first = first_of(y, m).map_or(0, |d| d.weekday().num_days_from_sunday() as usize);
// `y + 1` for December would overflow at `i32::MAX`; a year chrono can't represent
// (or that overflows here) falls back to the plain 31-day month.
let next_y = if m == 12 { y.checked_add(1) } else { Some(y) };
let len = match (first_of(y, m), next_y.and_then(|next| first_of(next, if m == 12 { 1 } else { m + 1 }))) {
(Some(a), Some(b)) => (b - a).num_days() as usize,
_ => 31,
};
let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| {
for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small());
}
ui.end_row();
for cell in 0..(first + len).div_ceil(7) * 7 {
if cell >= first && cell < first + len {
let day = (cell - first + 1) as u32;
let selected = current.is_some_and(|c| (c.y, c.m, c.d) == (y, m, day));
let is_today = (today.0 as i32, today.1, today.2) == (y, m, day);
let mut text = egui::RichText::new(day.to_string());
if is_today {
text = text.strong();
}
if ui
.selectable_label(selected, text)
.on_hover_text(crate::i18n::fmt(
tl!("{month} {day}, {y}"),
&[
("month", tl_ctx!("calendar date", MONTHS[(m - 1) as usize])),
("day", &day.to_string()),
("y", &y.to_string()),
],
))
.clicked()
{
picked = Some(format_date(DateTime { y, m, d: day, hh: 0, mm: 0, ss: 0 }, fmt));
}
} else {
ui.label("");
}
if cell % 7 == 6 {
ui.end_row();
}
}
});
// Today, whichever month the calendar shows: picked as a click on its day would
// pick it. A year the field's date can't hold offers no button.
if let Ok(ty) = i32::try_from(today.0) {
ui.add_space(4.0);
// As wide as the grid, so the button sits centred under it without widening the popup.
ui.allocate_ui_with_layout(vec2(grid.response.rect.width(), 0.0), egui::Layout::top_down(egui::Align::Center), |ui| {
let mut button = ui.small_button(tl!("Today"));
if let Some(month) = today.1.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) {
button = button.on_hover_text(crate::i18n::fmt(
tl!("{month} {day}, {y}"),
&[("month", tl_ctx!("calendar date", month)), ("day", &today.2.to_string()), ("y", &ty.to_string())],
));
}
if button.clicked() {
picked = Some(format_date(DateTime { y: ty, m: today.1, d: today.2, hh: 0, mm: 0, ss: 0 }, fmt));
}
});
}
});
});
if let Some(fx) = view.forms.focus.as_mut() {
fx.calendar = Some((y, m));
fx.calendar_rect = Some(area.response.rect);
}
picked
}
/// Paint focus and hover frames on a page, the pages' `pointer` over it.
pub(crate) fn paint_page(
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
form: &[FormField],
view: &DocView,
pointer: Option<Pos2>,
) {
for f in form {
for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) {
let r = widget_rect(xf, info, page, w.rect);
let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi);
if focused {
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside);
}
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
}
}
/// The size, in points, a text field shows `text` at on screen: its `/DA` size, or for auto size
/// (`0 Tf`) the page appearance's fit, measured in the font the screen draws with so the text
/// stays inside the box (the interface font runs wider than the page's Helvetica).
pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, text: &str) -> f32 {
let da = f.da.split_whitespace().collect::<Vec<_>>();
let fixed = da
.iter()
.position(|x| *x == "Tf")
.and_then(|i| i.checked_sub(1))
.and_then(|i| da.get(i))
.and_then(|s| s.parse::<f64>().ok())
.filter(|s| s.is_finite() && *s > 0.0);
let size = fixed.unwrap_or_else(|| {
// The appearance's padding: 2 pt inside a 1 pt border.
let pad = 3.0;
let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad);
// Comb cells hold one character each, so only the height limits them.
let comb = f.has(field_flags::COMB) && f.max_len.is_some_and(|m| m > 0);
let fit = if comb { "" } else { text };
let font = |size: f64| egui::FontId::proportional(size as f32);
let line_width = |size: f64| f64::from(painter.layout_no_wrap(fit.to_string(), font(size), Color32::BLACK).size().x);
// Wrapped as the editor and the page preview wrap it.
let wrap = width.max(1.0) as f32;
let wrapped_height = |size: f64| f64::from(painter.layout(fit.to_string(), font(size), Color32::BLACK, wrap).size().y);
pdfcraft_engine::field_auto_text_size_by(width, height, f.has(field_flags::MULTILINE), line_width, wrapped_height)
});
size.min(300.0) as f32
}
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo => {
let text = f.value.first().map(String::as_str).unwrap_or("");
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
if !text.is_empty() {
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
let zoom = xf.rect.width() / unrotated_w.max(1.0);
let font_size = (field_text_size(painter, f, w, text) * zoom).clamp(3.0, 64.0);
let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() };
let p = painter.with_clip_rect(r);
let (align, pos) = match f.quadding {
1 => (egui::Align2::CENTER_TOP, egui::pos2(r.center().x, r.min.y + 1.0)),
2 => (egui::Align2::RIGHT_TOP, egui::pos2(r.max.x - 3.0, r.min.y + 1.0)),
_ => (egui::Align2::LEFT_TOP, egui::pos2(r.min.x + 3.0, r.min.y + 1.0)),
};
// Multiline fields wrap at the box, as their page appearance does.
// At the width the size was fitted to: the box less 3 pt each side, scaled.
let wrap = if f.has(field_flags::MULTILINE) { (r.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY };
let galley = p.layout(display_text, egui::FontId::proportional(font_size), Color32::BLACK, wrap);
p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK);
}
}
FormFieldKind::List => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32;
let row_h = (rows.row * zoom) as f32;
let inset = (rows.inset * zoom) as f32;
let p = painter.with_clip_rect(r);
let mut y = r.min.y + inset;
for (export, display) in f.options.iter().skip(top) {
if y + row_h > r.max.y {
break;
}
let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h));
if f.value.contains(export) {
p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC));
}
p.text(
egui::pos2(r.min.x + 3.0, y + font_size * 0.15),
egui::Align2::LEFT_TOP,
display,
egui::FontId::proportional(font_size),
Color32::BLACK,
);
y += row_h;
}
}
FormFieldKind::CheckBox => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let checked = f.value.first().is_some_and(|v| v != "Off" && !v.is_empty());
if checked {
let sz = (r.height() * 0.75).clamp(8.0, 32.0);
painter.text(r.center(), egui::Align2::CENTER_CENTER, "✔", egui::FontId::proportional(sz), Color32::BLACK);
}
}
FormFieldKind::Radio => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let selected = f.value.first().map(String::as_str) == w.on_state.as_deref();
if selected {
let radius = (r.height().min(r.width()) * 0.25).clamp(2.0, 16.0);
painter.circle_filled(r.center(), radius, Color32::BLACK);
}
}
_ => {}
}
}
/// How the focused text field lays text out, when it is one that doesn't scroll (`DoNotScroll`):
/// its editor then takes no more than fits ([`overlay`]).
pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[FormField]) -> Option<form_appearance::TextBox> {
let fx = view.forms.focus.as_ref()?;
let f = form.iter().find(|f| f.name == fx.name)?;
let no_scroll = f.kind == FormFieldKind::Text && f.has(field_flags::DO_NOT_SCROLL);
no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten()
}
/// The in-place editor or option list for the focused field. Returns an edit to apply.
/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor
/// then takes no more than fits.
pub(crate) fn overlay(
ctx: &egui::Context,
view: &mut DocView,
info: &DocInfo,
form: &[FormField],
text_box: Option<&form_appearance::TextBox>,
today: (i64, u32, u32),
) -> Option<Edit> {
let pressed = view.forms.pressed;
if !ctx.input(|i| i.pointer.primary_down()) {
view.forms.pressed = false;
}
let toggle = std::mem::take(&mut view.forms.toggle);
view.forms.keys = None;
let focus = view.forms.focus.clone()?;
let Some(f) = form.iter().find(|f| f.name == focus.name) else {
view.forms.focus = None;
return None;
};
let w = f.widgets.get(focus.widget)?;
let page = w.page?;
let Some(xf) = view.page_xform(page) else {
// A check box or radio button scrolled out of view lets go (it has no draft to keep):
// taking the keyboard back when its page returns would turn scrolling arrows into
// choices, or pull the keyboard from wherever the user went meanwhile.
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
view.forms.focus = None;
}
return None;
};
let rect = widget_rect(&xf, info, page, w.rect);
// Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a
// tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and
// winit reports Windows pen input as touch), which clears `hover_pos`.
let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
let zoom = xf.zoom();
let t = Tokens::get(ctx);
let mut edit = None;
let mut close = false;
let mut next: Option<bool> = None; // Tab / Shift+Tab
match f.kind {
FormFieldKind::Text => {
let multiline = f.has(field_flags::MULTILINE);
// Shown at once, like the paragraph editor: a fade-in shows the field's value through it.
// Over the page, so only where the document shows: a field scrolled under the mode bar
// or a side panel must leave them their pointer (#2755). It stays on its field, cut off
// as the page is, rather than pushed back into the window.
egui::Area::new(egui::Id::new(("form-editor", view.id.0)))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(rect.min)
.constrain_to(view.viewport_rect())
.constrain(false)
.show(ctx, |ui| {
ui.set_min_size(rect.size());
let Some(fx) = view.forms.focus.as_mut() else { return };
// Sized from the text being typed, so an auto-sized field shrinks to keep it in view.
let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
// The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
// A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228).
let before = text_box.map(|_| fx.text.clone());
let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) };
te = te
// Wraps (inside the 3 px side margins) at the width the size was fitted to:
// the box less 3 pt each side, scaled.
.desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0))
.font(egui::FontId::proportional(font))
.background_color(FIELD_FILL)
.text_color(Color32::BLACK)
.margin(egui::Margin::symmetric(3, 1))
.password(f.has(field_flags::PASSWORD))
.id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)));
if let Some(max) = f.max_len {
te = te.char_limit(max);
}
if multiline {
te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1));
}
let mut laid_out = None;
let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| {
let out = te.show(ui);
laid_out = Some((out.galley, out.galley_pos));
out.response.response
});
if let (Some(tb), Some(before)) = (text_box, before)
&& fx.text != before
{
let (kept, caret) = tb.fitted_edit(&before, &fx.text);
if kept != fx.text {
fx.text = kept;
if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) {
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret))));
st.store(ui.ctx(), r.id);
}
ui.ctx().request_repaint();
}
}
if let Some(press) = fx.press.take()
&& let Some((galley, pos)) = laid_out
&& let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
{
// The press went to the page, not to this editor, which did not exist yet.
let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0));
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at)));
st.store(ui.ctx(), r.id);
if ui.input(|i| i.pointer.primary_down()) {
ui.ctx().set_dragged_id(r.id);
}
}
if fx.request_focus {
r.request_focus();
fx.request_focus = false;
if std::mem::take(&mut fx.select_all)
&& let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
{
let n = fx.text.chars().count();
st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
st.store(ui.ctx(), r.id);
}
}
let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus);
let (enter, esc, tab, shift) = ui.input(|i| {
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)
});
if esc {
close = true;
} else if tab {
next = Some(!shift);
} else if lost
&& (!multiline || !enter)
&& !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
{
if pressed {
// The field reopened with a refused value; keep it open and focused.
fx.request_focus = true;
} else {
// Enter in a single-line field, or clicking elsewhere, commits.
commit(view, form);
}
}
});
// Date fields: a calendar under the field (Acrobat's date picker).
if let pdfcraft_engine::form_scripts::Format::Date(fmt) = &f.actions.format
&& let Some(picked) = calendar(ctx, view, rect, fmt, today)
{
if let Some(fx) = view.forms.focus.as_mut() {
fx.text = picked;
}
commit(view, form);
return view.pending_edit.take();
}
}
FormFieldKind::Combo => {
// An editable combo box has a text box over the field as well as its list (ISO
// 32000-2 Table 233, Edit).
if editable_combo(f) {
combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget);
}
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0)))
.order(egui::Order::Foreground)
.fixed_pos(rect.left_bottom() + vec2(0.0, 2.0))
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(rect.width().max(160.0));
ui.label(egui::RichText::new(&f.name).small().color(t.text_faint));
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (export, display) in &f.options {
let on = f.value.first() == Some(export);
if ui.selectable_label(on, display).clicked() {
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
close = true;
}
}
});
});
});
let clicked_away = !resp.response.contains_pointer() && clicked_outside();
let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift));
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
} else if tab {
next = Some(!shift);
} else if clicked_away {
commit(view, form);
close = true;
}
}
FormFieldKind::List => {
let (up, down, esc, tab, shift) = ctx.input_mut(|i| {
(
// Consumed, so the arrows move the selection without also scrolling the page.
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::Tab),
i.modifiers.shift,
)
});
if esc {
close = true;
} else if tab {
next = Some(!shift);
} else if (up || down) && !f.options.is_empty() {
let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0);
let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) };
if next_idx != current_idx && next_idx < f.options.len() {
let export = f.options[next_idx].0.clone();
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
}
}
if clicked_outside() {
close = true;
}
}
FormFieldKind::CheckBox | FormFieldKind::Radio => {
// Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so
// Space, Tab and the arrows come here instead of turning or scrolling the page.
let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget));
let mut has_focus = false;
// Not interactable, so clicks still reach the page under it.
let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false);
area.fixed_pos(rect.min).show(ctx, |ui| {
let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive());
if let Some(fx) = view.forms.focus.as_mut()
&& std::mem::take(&mut fx.request_focus)
{
r.request_focus();
}
let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: false };
ui.memory_mut(|m| m.set_focus_lock_filter(id, filter));
has_focus = r.has_focus();
});
view.forms.keys = Some((id, f.kind == FormFieldKind::Radio));
let (esc, tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift));
let space = has_focus && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Space));
let arrow = has_focus && f.kind == FormFieldKind::Radio;
let step = ctx.input(|i| {
if !arrow {
None
} else if i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowDown) {
Some(true)
} else if i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowUp) {
Some(false)
} else {
None
}
});
let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p)));
if esc || clicked_away {
close = true;
} else if tab {
next = Some(!shift);
} else if toggle && view.pending_edit.is_some() {
view.forms.toggle = true;
} else if toggle {
edit = toggle_edit(f, w);
} else if space {
// Space toggles a check box and selects a radio button, as in browsers: it never
// turns the selected button off.
edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) };
} else if let Some(forward) = step {
// The arrows move to the group's next or previous button and select it, wrapping.
let stops: Vec<usize> = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect();
if let Some(pos) = stops.iter().position(|&i| i == focus.widget) {
let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() };
if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) {
view.forms.focus = Some(open_focus(f, wi));
edit = select_edit(f, target);
if let Some(p) = target.page
&& view.page_screen_rect(p).is_none()
{
view.go_to_page(p);
}
}
}
}
}
_ => close = true,
}
if close {
view.forms.focus = None;
}
if let Some(forward) = next {
commit(view, form);
let pending = view.pending_edit.take();
// The next field that can be filled in, check boxes and radio buttons included (#2223), in
// the document's tab order (each page's /Tabs: rows, columns or structure), wrapping.
let mut order: Vec<(usize, &FormField, usize)> = form
.iter()
.filter(|x| fillable(x))
.flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi)))
.collect();
order.sort_by_key(|o| o.0);
if let Some(pos) = order
.iter()
.position(|(_, x, wi)| x.name == f.name && *wi == focus.widget)
.or_else(|| order.iter().position(|(_, x, _)| x.name == f.name))
{
let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() };
let (_, target, wi) = order[k];
view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) });
if let Some(p) = target.widgets[wi].page
&& view.page_screen_rect(p).is_none()
{
view.go_to_page(p);
}
}
edit = edit.or(pending);
}
edit.or_else(|| view.pending_edit.take())
}
/// 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)?;
let page = w.page?;
let xf = view.page_xform(page)?;
Some(widget_rect(&xf, info, page, w.rect))
}