use pdfrum_doc::ap::{self, TextFont};
use pdfrum_doc::vt;
use pdfrum_object::{Dict, Resolve};
use crate::cascade::{Cascade, FieldRef, Keystroke, KeystrokeOutcome, PointerTrigger};
use crate::commit;
use crate::edit::ops::{self, TextEdit};
use crate::event::{Button, Event, Key, Modifiers, Point};
use crate::field::text::{Disposition, Motion, TextAction};
use crate::field::{self, ChoiceState, FieldState, ToggleKind, ToggleState};
use crate::hit::{self, Permissions};
use crate::page::{PageForm, WidgetInfo};
const ACTION: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"A");
use crate::session::{AnnotId, DragAnchor, FieldId, FocusTarget, FormSession};
use crate::update::{AppearanceUpdate, Response, UpdateKind};
use crate::{focus, tab};
pub struct Context<'a, R: Resolve> {
pub page: &'a PageForm,
pub catalog: &'a Dict,
pub resolve: &'a R,
pub fonts: &'a ap::FormFonts,
pub permissions: Permissions,
}
impl<R: Resolve> Context<'_, R> {
fn widget(&self, id: AnnotId) -> Option<&WidgetInfo> {
self.page.widgets.iter().find(|w| w.id == id)
}
fn widget_of_field(&self, field: FieldId) -> Option<&WidgetInfo> {
self.page.widgets.iter().find(|w| w.field == field)
}
fn field_of_index(&self, index: u32) -> Option<FieldId> {
self.page.field_of_index(index)
}
}
pub fn apply<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
event: Event,
) -> Response {
let mut response = route(session, ctx, cascade, event);
response.absorb(honour_focus_requests(session, ctx, cascade));
response
}
fn honour_focus_requests<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
) -> Response {
let mut response = Response::ignored();
for _ in 0..MAX_SCRIPTED_FOCUS_MOVES {
let Some(index) = cascade.take_focus_request() else {
return response;
};
response.absorb(focus_field(session, ctx, cascade, index));
}
cascade.take_focus_request();
response
}
const MAX_SCRIPTED_FOCUS_MOVES: u32 = 8;
fn route<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
event: Event,
) -> Response {
match event {
Event::MouseMove { at, modifiers } => {
mouse_move(session, ctx, cascade, Point::narrow(at), modifiers)
}
Event::MouseDown {
button: Button::Left,
at,
modifiers,
} => mouse_down(session, ctx, cascade, Point::narrow(at), modifiers),
Event::MouseUp {
button: Button::Left,
at,
modifiers,
} => mouse_up(session, ctx, cascade, Point::narrow(at), modifiers),
Event::MouseDown {
button: Button::Right,
..
}
| Event::MouseUp {
button: Button::Right,
..
} => Response::ignored(),
Event::DoubleClick { at, modifiers } => {
double_click(session, ctx, cascade, Point::narrow(at), modifiers)
}
Event::MouseWheel {
at,
delta,
modifiers,
} => wheel(session, ctx, Point::narrow(at), delta, modifiers),
Event::Focus { at, modifiers } => {
focus_at(session, ctx, cascade, Point::narrow(at), modifiers)
}
Event::KeyDown { key, modifiers } => key_down(session, ctx, cascade, key, modifiers),
Event::Char { ch, modifiers } => char_typed(session, ctx, cascade, ch, modifiers),
}
}
fn mouse_move<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
at: Point,
modifiers: Modifiers,
) -> Response {
let over = hit::annot_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
at.x,
at.y,
);
let moved = session.hover != over;
let left = session.hover;
session.hover = over;
if moved {
if let Some(annot) = left {
fire_pointer(
session,
ctx,
cascade,
annot,
PointerTrigger::Exit,
modifiers,
);
}
if let Some(annot) = over {
fire_pointer(
session,
ctx,
cascade,
annot,
PointerTrigger::Enter,
modifiers,
);
}
}
if let Some(response) = hover_in_popup(session, ctx, at) {
return response;
}
if let Some(anchor) = session.drag {
return match drag_to(session, ctx, anchor, at) {
Some(update) => Response::with(vec![update]),
None => Response::consumed(),
};
}
if moved && over.is_some() {
return Response::consumed();
}
Response::ignored()
}
fn mouse_down<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
at: Point,
modifiers: Modifiers,
) -> Response {
if let Some((field, annot, index)) = popup_hit(session, ctx, at) {
return press_in_popup(session, ctx, field, annot, index);
}
let hit = hit::widget_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
ctx.permissions,
at.x,
at.y,
);
let Some(id) = hit else {
return if focus::miss_drops_focus(Button::Left) {
kill_focus(session, ctx, cascade)
} else {
Response::ignored()
};
};
let Some(widget) = ctx.widget(id) else {
return Response::ignored();
};
let field = widget.field;
fire_pointer(session, ctx, cascade, id, PointerTrigger::Down, modifiers);
let mut response = take_focus(session, ctx, cascade, FocusTarget::Widget(field, id));
ensure_state(session, ctx, field);
match session.fields.get(&field) {
Some(FieldState::Text(_)) => {
let point = to_plate(widget, at);
with_edit(session, ctx, field, |edit, config, metrics| {
ops::click_at(edit, config, metrics, point);
});
session.drag = caret_anchor(session, field);
}
Some(FieldState::Choice(_)) => {
if toggle_popup_at(session, ctx, field, id, at) {
response.absorb(redraw(session, ctx, field, id));
return response;
}
if let Some(update) = choice_click(session, ctx, field, id, at, modifiers) {
response.push(update);
return response;
}
}
Some(FieldState::Toggle(_) | FieldState::Button(_)) | None => {}
}
response.absorb(redraw(session, ctx, field, id));
response
}
fn mouse_up<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
at: Point,
modifiers: Modifiers,
) -> Response {
let finished = session
.drag
.and_then(|anchor| drag_to(session, ctx, anchor, at));
session.drag = None;
if let Some(id) = hit::widget_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
ctx.permissions,
at.x,
at.y,
) {
fire_pointer(session, ctx, cascade, id, PointerTrigger::Focus, modifiers);
fire_pointer(session, ctx, cascade, id, PointerTrigger::Up, modifiers);
}
if let Some(update) = finished {
return Response::with(vec![update]);
}
if let Some((field, annot, index)) = popup_hit(session, ctx, at) {
return release_in_popup(session, ctx, field, annot, index);
}
let hit = hit::widget_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
ctx.permissions,
at.x,
at.y,
);
let Some(id) = hit else {
return Response::ignored();
};
let Some(widget) = ctx.widget(id) else {
return Response::ignored();
};
let field = widget.field;
let read_only = widget.flags.is_read_only();
let kind = toggle_kind(widget);
if let (Some(kind), Some(FieldState::Toggle(state))) = (kind, session.fields.get_mut(&field)) {
let moved = field::activate(state, kind, read_only);
if moved {
clear_siblings(session, ctx, field, id);
session.dirty.insert(field);
let mut response = Response::consumed();
for other in ctx.page.widgets.iter().filter(|w| w.field == field) {
response.absorb(redraw(session, ctx, field, other.id));
}
return response;
}
return Response::consumed();
}
Response::consumed()
}
fn double_click<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
at: Point,
modifiers: Modifiers,
) -> Response {
if let Some(annot) = session.focus.map(FocusTarget::annot) {
fire_pointer(
session,
ctx,
cascade,
annot,
PointerTrigger::Focus,
modifiers,
);
}
let Some(field) = session.focused_field() else {
return Response::ignored();
};
if !matches!(session.fields.get(&field), Some(FieldState::Text(_))) {
return Response::ignored();
}
let Some(widget) = ctx.widget_of_field(field) else {
return Response::consumed();
};
let point = to_plate(widget, at);
let client =
pdfrum_doc::geom::normalize(ap::field_body::client_rect(&widget.dict, ctx.resolve));
let inside = point.x >= client.x0
&& point.x <= client.x1
&& point.y >= client.y0
&& point.y <= client.y1;
if !inside {
return Response::consumed();
}
with_edit(session, ctx, field, |edit, _config, _metrics| {
edit.select_all();
});
let Some(id) = session.focus.map(FocusTarget::annot) else {
return Response::consumed();
};
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, id));
response
}
fn wheel<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
at: Point,
delta: (i32, i32),
modifiers: Modifiers,
) -> Response {
let hit = hit::widget_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
ctx.permissions,
at.x,
at.y,
);
let Some(id) = hit else {
return Response::ignored();
};
let Some(widget) = ctx.widget(id) else {
return Response::ignored();
};
let field = widget.field;
ensure_state(session, ctx, field);
let rows = match session.fields.get(&field) {
Some(FieldState::Choice(choice)) => visible_rows(ctx, widget, choice),
_ => 0,
};
let moved = match session.fields.get_mut(&field) {
Some(FieldState::Choice(state)) if state.config.combo => false,
Some(FieldState::Choice(state)) => scroll_choice(state, delta.1, rows, modifiers),
Some(FieldState::Text(_)) => scroll_text(session, ctx, field, delta.1),
Some(FieldState::Toggle(_) | FieldState::Button(_)) | None => false,
};
if !moved {
return Response::consumed();
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, id));
response
}
fn focus_at<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
at: Point,
modifiers: Modifiers,
) -> Response {
let hit = hit::widget_at_point(
&ctx.page.candidates,
session.focus.map(FocusTarget::annot),
ctx.permissions,
at.x,
at.y,
);
let Some(id) = hit else {
return Response::ignored();
};
let Some(widget) = ctx.widget(id) else {
return Response::ignored();
};
let field = widget.field;
fire_pointer(session, ctx, cascade, id, PointerTrigger::Focus, modifiers);
let mut response = take_focus(session, ctx, cascade, FocusTarget::Widget(field, id));
ensure_state(session, ctx, field);
response.absorb(redraw(session, ctx, field, id));
response
}
fn key_down<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
key: Key,
modifiers: Modifiers,
) -> Response {
if key == Key::Tab {
return tab_to_next(session, ctx, cascade, modifiers);
}
let Some(target) = session.focus else {
return Response::ignored();
};
let Some(field) = target.field() else {
return annot_key(session, ctx, target.annot(), key, modifiers);
};
let annot = target.annot();
match session.fields.get(&field) {
Some(FieldState::Text(_)) => text_key(session, ctx, cascade, field, annot, key, modifiers),
Some(FieldState::Choice(_)) => choice_key(session, ctx, field, annot, key, modifiers),
Some(FieldState::Toggle(_)) => {
if matches!(key, Key::Return | Key::Space) {
Response::consumed()
} else {
Response::ignored()
}
}
Some(FieldState::Button(_)) => annot_key(session, ctx, annot, key, modifiers),
None => Response::ignored(),
}
}
fn char_typed<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
ch: char,
modifiers: Modifiers,
) -> Response {
let Some(target) = session.focus else {
return Response::ignored();
};
let Some(field) = target.field() else {
return Response::ignored();
};
let annot = target.annot();
let accelerator = session.config.accelerator;
match session.fields.get(&field) {
Some(FieldState::Text(state)) => {
let (read_only, multi_line) = (state.config.read_only, state.config.multi_line);
let action = field::text::route_char(ch, modifiers, accelerator, read_only, multi_line);
perform_text(session, ctx, cascade, field, annot, action)
}
Some(FieldState::Choice(_)) => choice_char(session, ctx, cascade, field, annot, ch),
Some(FieldState::Toggle(_)) => {
if matches!(ch, '\r' | ' ') {
let widget = ctx.widget(annot);
let read_only = widget.is_some_and(|w| w.flags.is_read_only());
let kind = widget.and_then(toggle_kind);
if let (Some(kind), Some(FieldState::Toggle(state))) =
(kind, session.fields.get_mut(&field))
&& field::activate(state, kind, read_only)
{
clear_siblings(session, ctx, field, annot);
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
return response;
}
return Response::consumed();
}
Response::ignored()
}
Some(FieldState::Button(_)) if ch == '\r' => {
annot_key(session, ctx, annot, Key::Return, modifiers)
}
Some(FieldState::Button(_)) | None => Response::ignored(),
}
}
fn annot_key<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
annot: AnnotId,
key: Key,
modifiers: Modifiers,
) -> Response {
let _ = session;
if key != Key::Return {
return Response::ignored();
}
let Some(action) = action_of(ctx, annot) else {
return Response::ignored();
};
Response::with(vec![AppearanceUpdate::new(
annot,
UpdateKind::ActionRequested {
action: Box::new(action),
modifiers,
},
)])
}
fn action_of<R: Resolve>(ctx: &Context<'_, R>, annot: AnnotId) -> Option<pdfrum_doc::nav::Action> {
let dict = ctx.page.dicts.get(&annot.index)?;
let action = dict.dict(ACTION, ctx.resolve)?;
Some(pdfrum_doc::nav::Action::new(action))
}
fn text_key<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
field: FieldId,
annot: AnnotId,
key: Key,
modifiers: Modifiers,
) -> Response {
let has_selection = match session.fields.get(&field) {
Some(FieldState::Text(state)) => state.edit.has_selection(),
_ => false,
};
let action = field::text::route_key(
key,
modifiers,
session.config.accelerator,
session.config.redo_on_ctrl_y,
has_selection,
);
perform_text(session, ctx, cascade, field, annot, action)
}
fn perform_text<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
field: FieldId,
annot: AnnotId,
action: Disposition,
) -> Response {
let action = match action {
Disposition::Do(action) => action,
Disposition::Consume => return Response::consumed(),
Disposition::Ignore => return Response::ignored(),
};
match action {
TextAction::Commit | TextAction::Escape => {
let mut response = Response::consumed();
response.absorb(kill_focus(session, ctx, cascade));
return response;
}
_ => {}
}
let action = match keystroke_hook(session, ctx, cascade, field, action) {
Keyed::Perform(action) => action,
Keyed::Refused => return Response::consumed(),
Keyed::Rewrote => {
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
return response;
}
};
let max_len = match session.fields.get(&field) {
Some(FieldState::Text(state)) => state.config.max_len.map(std::num::NonZeroU32::get),
_ => None,
};
let mut changed = false;
with_edit(session, ctx, field, |edit, config, metrics| {
changed = perform_on_edit(edit, config, metrics, action, max_len);
});
if changed {
session.dirty.insert(field);
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
enum Keyed {
Perform(TextAction),
Rewrote,
Refused,
}
fn keystroke_hook<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
field: FieldId,
action: TextAction,
) -> Keyed {
let change = match action {
TextAction::Insert(ch) => ch.to_string(),
TextAction::InsertReturn => "\n".to_string(),
TextAction::Backspace | TextAction::Delete => String::new(),
_ => return Keyed::Perform(action),
};
let Some(reference) = field_ref(ctx, field) else {
return Keyed::Perform(action);
};
let Some(FieldState::Text(state)) = session.fields.get(&field) else {
return Keyed::Perform(action);
};
let offered = Keystroke::of(&state.edit, change);
match cascade.keystroke(&reference, offered.clone()) {
KeystrokeOutcome::Reject => Keyed::Refused,
KeystrokeOutcome::Accept(back) if back == offered => Keyed::Perform(action),
KeystrokeOutcome::Accept(back) => {
set_field_text(session, field, &back.applied());
Keyed::Rewrote
}
}
}
fn perform_on_edit(
edit: &mut TextEdit,
config: &vt::Config,
metrics: &vt::Metrics<'_>,
action: TextAction,
max_len: Option<u32>,
) -> bool {
match action {
TextAction::Insert(ch) => ops::insert_char(edit, config, metrics, ch, max_len),
TextAction::InsertReturn => ops::insert_char(edit, config, metrics, '\n', max_len),
TextAction::Backspace => ops::backspace(edit, config, metrics),
TextAction::Delete => ops::delete(edit, config, metrics),
TextAction::ClearSelection => {
edit.select_none();
true
}
TextAction::SelectAll => {
edit.select_all();
true
}
TextAction::Undo => ops::undo(edit, config, metrics),
TextAction::Redo => ops::redo(edit, config, metrics),
TextAction::Move { motion, extend } => move_caret(edit, config, metrics, motion, extend),
TextAction::Commit | TextAction::Escape => false,
}
}
fn move_caret(
edit: &mut TextEdit,
config: &vt::Config,
metrics: &vt::Metrics<'_>,
motion: Motion,
extend: bool,
) -> bool {
let len = edit.len_chars();
let at = edit.caret_index();
let to = match motion {
Motion::Left => at.saturating_sub(1),
Motion::Right => (at + 1).min(len),
Motion::DocStart | Motion::LineStart => 0,
Motion::DocEnd | Motion::LineEnd => len,
Motion::Up => line_step(edit, config, metrics, at, -1),
Motion::Down => line_step(edit, config, metrics, at, 1),
};
if extend {
edit.move_caret_keeping_selection(to);
} else {
edit.set_caret_index(to);
}
ops::scroll_to_caret(edit, config, metrics);
true
}
fn line_step(
edit: &TextEdit,
config: &vt::Config,
metrics: &vt::Metrics<'_>,
at: usize,
direction: i32,
) -> usize {
let place = vt::hit::place_of_word_index(&edit.layout, at);
let line = i64::from(place.line) + i64::from(direction);
if line < 0 {
return 0;
}
let target = vt::hit::place_at_point(
&edit.layout,
config.plate,
config,
metrics,
edit.offset,
kurbo::Point::new(
f64::from(edit.sticky_x),
f64::from(line_y(edit, place.section, line)),
),
);
let _ = metrics;
vt::hit::word_index_of_place(&edit.layout, target)
}
fn line_y(edit: &TextEdit, section: u32, line: i64) -> f32 {
edit.layout
.sections
.get(usize::try_from(section).unwrap_or(0))
.and_then(|section| section.lines.get(usize::try_from(line).unwrap_or(0)))
.map_or(0.0, |line| line.y)
}
fn choice_key<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
annot: AnnotId,
key: Key,
modifiers: Modifiers,
) -> Response {
let rows = match (ctx.widget(annot), session.fields.get(&field)) {
(Some(widget), Some(FieldState::Choice(choice))) => visible_rows(ctx, widget, choice),
_ => 0,
};
let moved = match session.fields.get_mut(&field) {
Some(FieldState::Choice(state)) => {
let (shift, ctrl) = (
modifiers.contains(Modifiers::SHIFT),
modifiers.contains(Modifiers::CONTROL),
);
let moved = match key {
Key::Up => field::choice::move_caret_by(state, -1, shift, ctrl),
Key::Down => field::choice::move_caret_by(state, 1, shift, ctrl),
Key::Return | Key::Space => return Response::consumed(),
_ => return Response::ignored(),
};
let caret = state.caret_index.unwrap_or(0);
moved | field::choice::scroll_into_view(state, caret, rows)
}
_ => return Response::ignored(),
};
if !moved {
return Response::consumed();
}
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn choice_char<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
field: FieldId,
annot: AnnotId,
ch: char,
) -> Response {
let (combo, editable) = match session.fields.get(&field) {
Some(FieldState::Choice(state)) => (state.config.combo, state.config.editable),
_ => (false, false),
};
if combo && matches!(ch, '\r' | '\n') {
return toggle_popup_by_key(session, ctx, field, annot);
}
if combo && !editable && ch == ' ' {
let shut = matches!(
session.fields.get(&field),
Some(FieldState::Choice(state)) if !state.popup_open
);
if shut {
return toggle_popup_by_key(session, ctx, field, annot);
}
return Response::consumed();
}
if editable {
if let Some(reference) = field_ref(ctx, field)
&& let Some(FieldState::Choice(state)) = session.fields.get(&field)
&& let Some(edit) = state.edit.as_ref()
{
let offered = Keystroke::of(edit, ch.to_string());
match cascade.keystroke(&reference, offered.clone()) {
KeystrokeOutcome::Reject => return Response::consumed(),
KeystrokeOutcome::Accept(back) if back != offered => {
set_field_text(session, field, &back.applied());
if let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) {
state.selected.clear();
state.caret_index = None;
state.edit = None;
}
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
return response;
}
KeystrokeOutcome::Accept(_) => {}
}
}
let mut changed = false;
with_combo_edit(session, ctx, field, |edit, config, metrics| {
changed = ops::insert_char(edit, config, metrics, ch, None);
});
if changed {
if let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) {
state.selected.clear();
state.caret_index = None;
}
session.dirty.insert(field);
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
return response;
}
let moved = match session.fields.get_mut(&field) {
Some(FieldState::Choice(state)) => field::choice::type_ahead(state, ch),
_ => false,
};
if !moved {
return Response::consumed();
}
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn choice_click<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
id: AnnotId,
at: Point,
modifiers: Modifiers,
) -> Option<AppearanceUpdate> {
let widget = ctx.widget(id)?;
let row = row_at(ctx, widget, session.fields.get(&field), at)?;
let multi = match session.fields.get(&field) {
Some(FieldState::Choice(state)) => state.config.multi_select,
_ => false,
};
let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) else {
return None;
};
let moved = if multi && modifiers.contains(Modifiers::SHIFT) {
field::choice::select_range_to(state, row)
} else if multi && modifiers.contains(Modifiers::CONTROL) {
field::choice::toggle_index(state, row)
} else {
field::choice::select_only(state, row)
};
if moved {
session.dirty.insert(field);
}
appearance_of(session, ctx, field, id)
}
fn row_at<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
state: Option<&FieldState>,
at: Point,
) -> Option<usize> {
let FieldState::Choice(choice) = state? else {
return None;
};
if choice.config.combo {
return None;
}
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let height = row_height(ctx, widget, choice);
if height <= 0.0 {
return None;
}
let point = to_plate(widget, at);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the quotient is bounded by the option count immediately below"
)]
let offset = ((client.y1 - point.y) / f64::from(height)) as usize;
let row = choice.top_visible.checked_add(offset)?;
(row < choice.options.len()).then_some(row)
}
fn visible_rows<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
choice: &ChoiceState,
) -> usize {
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let height = row_height(ctx, widget, choice);
if height <= 0.0 {
return 0;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a row count is bounded by the widget's height in points"
)]
let rows = (pdfrum_doc::geom::height(client) / height) as usize;
rows
}
const DROP_BUTTON_WIDTH: f32 = 13.0;
fn on_drop_button<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo, at: Point) -> bool {
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let (left, right) = (
pdfrum_doc::geom::left(client),
pdfrum_doc::geom::right(client),
);
let edge = (right - DROP_BUTTON_WIDTH).max(left);
let point = to_plate(widget, at);
#[expect(
clippy::cast_possible_truncation,
reason = "a plate coordinate is a widget-sized number"
)]
let (x, y) = (point.x as f32, point.y as f32);
x >= edge
&& x <= right
&& y >= pdfrum_doc::geom::bottom(client)
&& y <= pdfrum_doc::geom::top(client)
}
fn popup_geometry<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
choice: &ChoiceState,
) -> Option<crate::popup::PopupGeometry> {
if !choice.config.combo {
return None;
}
crate::popup::place(
widget.rect,
ctx.page.page_height,
choice.options.len(),
row_height(ctx, widget, choice),
)
}
fn set_popup<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
choice: &mut ChoiceState,
open: bool,
) -> bool {
if !choice.config.combo || open == choice.popup_open {
return false;
}
if !open {
choice.popup_open = false;
choice.hovered = None;
return true;
}
if popup_geometry(ctx, widget, choice).is_none() {
return false;
}
choice.popup_open = true;
if let Some(selected) = choice.selected.iter().next().copied() {
let rows =
popup_geometry(ctx, widget, choice).map_or(0, |geometry| geometry.visible_rows());
field::choice::scroll_into_view(choice, selected, rows);
}
true
}
fn close_all_popups(session: &mut FormSession) -> bool {
let mut closed = false;
for state in session.fields.values_mut() {
if let FieldState::Choice(choice) = state
&& choice.popup_open
{
choice.popup_open = false;
choice.hovered = None;
closed = true;
}
}
closed
}
fn open_popup_of(session: &FormSession, page: &PageForm) -> Option<(FieldId, AnnotId)> {
page.widgets
.iter()
.find_map(|widget| match session.fields.get(&widget.field) {
Some(FieldState::Choice(choice)) if choice.popup_open => {
Some((widget.field, widget.id))
}
_ => None,
})
}
fn popup_hit<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
at: Point,
) -> Option<(FieldId, AnnotId, usize)> {
let (field, annot) = open_popup_of(session, ctx.page)?;
let widget = ctx.widget(annot)?;
let FieldState::Choice(choice) = session.fields.get(&field)? else {
return None;
};
let geometry = popup_geometry(ctx, widget, choice)?;
let offset = geometry.row_at(at.x, at.y)?;
let index = crate::popup::option_at(choice, offset)?;
Some((field, annot, index))
}
fn hover_in_popup<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
at: Point,
) -> Option<Response> {
let (field, annot, index) = popup_hit(session, ctx, at)?;
let moved = match session.fields.get_mut(&field) {
Some(FieldState::Choice(choice)) => {
let moved = choice.hovered != Some(index);
choice.hovered = Some(index);
moved
}
_ => false,
};
if !moved {
return Some(Response::consumed());
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
Some(response)
}
fn toggle_popup_at<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
id: AnnotId,
at: Point,
) -> bool {
let Some(widget) = ctx.widget(id) else {
return false;
};
let combo = matches!(
session.fields.get(&field),
Some(FieldState::Choice(choice)) if choice.config.combo
);
if !combo || !on_drop_button(ctx, widget, at) {
return false;
}
let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) else {
return false;
};
let open = !choice.popup_open;
let mut taken = std::mem::take(choice);
let moved = set_popup(ctx, widget, &mut taken, open);
if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
*choice = taken;
}
moved
}
fn toggle_popup_by_key<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
annot: AnnotId,
) -> Response {
let Some(widget) = ctx.widget(annot) else {
return Response::consumed();
};
let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) else {
return Response::consumed();
};
let open = !choice.popup_open;
let mut taken = std::mem::take(choice);
let moved = set_popup(ctx, widget, &mut taken, open);
if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
*choice = taken;
}
if !moved {
return Response::consumed();
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn press_in_popup<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
annot: AnnotId,
index: usize,
) -> Response {
if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
choice.hovered = Some(index);
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn release_in_popup<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
annot: AnnotId,
index: usize,
) -> Response {
let label = match session.fields.get_mut(&field) {
Some(FieldState::Choice(choice)) => {
field::choice::select_only(choice, index);
choice.popup_open = false;
choice.hovered = None;
choice
.options
.get(index)
.map(|option| option.label.clone())
.unwrap_or_default()
}
_ => return Response::consumed(),
};
set_combo_text(session, ctx, field, label);
session.dirty.insert(field);
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn set_combo_text<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
label: String,
) {
let editable = matches!(
session.fields.get(&field),
Some(FieldState::Choice(choice)) if choice.config.editable
);
if !editable {
return;
}
if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
choice.edit_text = label;
choice.edit = None;
}
with_combo_edit(session, ctx, field, |edit, _config, _metrics| {
edit.select_all();
});
}
#[must_use]
pub fn popup_view<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
) -> Option<crate::popup::PopupView> {
let (field, annot) = open_popup_of(session, ctx.page)?;
let widget = ctx.widget(annot)?;
let FieldState::Choice(choice) = session.fields.get(&field)? else {
return None;
};
let geometry = popup_geometry(ctx, widget, choice)?;
Some(crate::popup::PopupView {
annot,
anchor: crate::popup::widen(widget.rect),
geometry,
options: choice
.options
.iter()
.map(|option| option.label.clone())
.collect(),
selected: choice.selected.iter().next().copied(),
hovered: choice.hovered,
top_visible: choice.top_visible,
edit_text: choice.config.editable.then(|| choice.edit_text.clone()),
})
}
#[must_use]
pub fn scroll_view<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
annot: AnnotId,
) -> Option<crate::popup::ScrollView> {
let widget = ctx.widget(annot)?;
let FieldState::Choice(choice) = session.fields.get(&widget.field)? else {
return None;
};
let visible = match popup_geometry(ctx, widget, choice).filter(|_| choice.popup_open) {
Some(geometry) => geometry.visible_rows(),
None => visible_rows(ctx, widget, choice),
};
Some(crate::popup::ScrollView {
top_visible: choice.top_visible,
visible_rows: visible,
total: choice.options.len(),
})
}
pub fn choose<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
annot: AnnotId,
index: usize,
) -> Response {
let _ = &cascade;
let Some(widget) = ctx.widget(annot) else {
return Response::ignored();
};
let field = widget.field;
let in_range = matches!(
session.fields.get(&field),
Some(FieldState::Choice(choice)) if index < choice.options.len()
);
if !in_range {
return Response::ignored();
}
release_in_popup(session, ctx, field, annot, index)
}
pub fn close_popup<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
annot: AnnotId,
) -> Response {
let Some(widget) = ctx.widget(annot) else {
return Response::ignored();
};
let field = widget.field;
let closed = match session.fields.get_mut(&field) {
Some(FieldState::Choice(choice)) if choice.popup_open => {
choice.popup_open = false;
choice.hovered = None;
true
}
_ => false,
};
if !closed {
return Response::ignored();
}
let mut response = Response::consumed();
response.absorb(redraw(session, ctx, field, annot));
response
}
fn row_height<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo, choice: &ChoiceState) -> f32 {
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let plate = pdfrum_doc::geom::rect(
pdfrum_doc::geom::left(client),
0.0,
pdfrum_doc::geom::right(client),
0.0,
);
let size = font_size(ctx, widget);
let config = vt::Config {
plate,
font_size: if size > 0.0 {
size
} else {
LIST_ROW_DEFAULT_SIZE
},
..vt::Config::default()
};
let label = choice
.options
.first()
.map_or("", |option| option.label.as_str());
let measured = with_font(ctx, widget, |font, _substitute| {
let layout = vt::layout(label, &config, &font.metrics);
pdfrum_doc::geom::height(layout.content_rect_pdf(plate))
});
match measured {
Some(height) if height > 0.0 => height,
_ => config.font_size,
}
}
const LIST_ROW_DEFAULT_SIZE: f32 = 12.0;
fn font_size<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> f32 {
let form = ctx
.catalog
.dict(pdfrum_object::names::ACRO_FORM, ctx.resolve)
.unwrap_or_default();
ap::freetext::default_appearance(&widget.dict, &form, ctx.resolve)
.map_or(0.0, |appearance| appearance.size)
}
fn scroll_choice(
state: &mut ChoiceState,
delta_y: i32,
visible_rows: usize,
modifiers: Modifiers,
) -> bool {
if delta_y == 0 || state.options.is_empty() {
return false;
}
let moved = field::choice::move_caret_by(
state,
if delta_y < 0 { 1 } else { -1 },
modifiers.contains(Modifiers::SHIFT),
modifiers.contains(Modifiers::CONTROL),
);
let caret = state.caret_index.unwrap_or(0);
let scrolled = field::choice::scroll_into_view(state, caret, visible_rows);
moved || scrolled
}
fn scroll_text<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
delta_y: i32,
) -> bool {
if delta_y == 0 {
return false;
}
let mut moved = false;
with_edit(session, ctx, field, |edit, config, _metrics| {
moved = ops::scroll_by(edit, config, delta_y);
});
moved
}
fn tab_to_next<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
modifiers: Modifiers,
) -> Response {
if modifiers.contains(Modifiers::CONTROL)
|| modifiers.contains(Modifiers::ALT)
|| modifiers.contains(Modifiers::META)
{
return Response::ignored();
}
let backward = modifiers.contains(Modifiers::SHIFT);
let ring = focus_ring(session, ctx);
if ring.order.is_empty() {
return Response::ignored();
}
let next = match (session.focus.map(FocusTarget::annot), backward) {
(Some(current), false) => ring.next(current),
(Some(current), true) => ring.prev(current),
(None, false) => ring.first(),
(None, true) => ring.last(),
};
let Some(next) = next else {
return Response::ignored();
};
let target = match ctx.widget(next) {
Some(widget) => FocusTarget::Widget(widget.field, next),
None => FocusTarget::Annot(next),
};
let mut response = take_focus(session, ctx, cascade, target);
if let Some(field) = target.field() {
ensure_state(session, ctx, field);
response.absorb(redraw(session, ctx, field, next));
}
response
}
fn focus_ring<R: Resolve>(session: &FormSession, ctx: &Context<'_, R>) -> tab::FocusRing {
let focusables: Vec<tab::Focusable> = ctx
.page
.focusables
.iter()
.filter(|(subtype, _)| session.config.focusable.contains(subtype))
.map(|(_, focusable)| *focusable)
.collect();
tab::FocusRing::build(&focusables, ctx.page.tab_order)
}
fn fire_pointer<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
annot: AnnotId,
trigger: PointerTrigger,
modifiers: Modifiers,
) {
let _ = session;
let Some(widget) = ctx.page.widgets.iter().find(|w| w.id == annot) else {
return;
};
let Some(field) = field_ref(ctx, widget.field) else {
return;
};
cascade.pointer(&field, trigger, modifiers);
}
fn field_ref<R: Resolve>(ctx: &Context<'_, R>, field: FieldId) -> Option<FieldRef> {
let widget = ctx.widget_of_field(field)?;
Some(FieldRef {
name: widget.name.clone(),
index: widget.field_index,
})
}
fn edited_text(session: &FormSession, field: FieldId) -> Option<String> {
match session.fields.get(&field)? {
FieldState::Text(text) => Some(text.edit.text.clone()),
FieldState::Choice(choice) if choice.config.editable => Some(choice.edit_text.clone()),
FieldState::Choice(_) | FieldState::Toggle(_) | FieldState::Button(_) => None,
}
}
fn commit_field<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
field: FieldId,
) -> Option<commit::CommitOutcome> {
let reference = field_ref(ctx, field)?;
let edited = edited_text(session, field)?;
let stored = ctx.widget_of_field(field)?.value(ctx.resolve);
let outcome = commit::run(
&reference,
&stored,
&edited,
cascade,
session.config.max_calculate_depth,
);
if outcome.reverted {
set_field_text(session, field, &stored);
session.formatted.remove(&field);
return Some(outcome);
}
for (index, value) in &outcome.writes {
let written = ctx.field_of_index(*index).unwrap_or(FieldId(*index));
set_field_text(session, written, value);
session.dirty.insert(written);
let display = field_ref(ctx, written)
.and_then(|reference| cascade.format(&reference, value))
.filter(|display| display != value);
record_display(session, written, display);
}
if outcome.formats() {
record_display(session, field, outcome.display.clone());
}
Some(outcome)
}
fn record_display(session: &mut FormSession, field: FieldId, display: Option<String>) {
match display {
Some(display) => {
session.formatted.insert(field, display);
}
None => {
session.formatted.remove(&field);
}
}
}
fn set_field_text(session: &mut FormSession, field: FieldId, value: &str) {
match session.fields.get_mut(&field) {
Some(FieldState::Text(text)) => {
text.edit.text = value.to_string();
text.edit.undo = crate::edit::UndoStack::default();
}
Some(FieldState::Choice(choice)) if choice.config.editable => {
choice.edit_text = value.to_string();
}
_ => {}
}
}
fn take_focus<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
target: FocusTarget,
) -> Response {
if let Some(previous) = session.focus.and_then(FocusTarget::field)
&& session.focus != Some(target)
&& let Some(outcome) = commit_field(session, ctx, cascade, previous)
&& outcome.keeps_focus()
{
let annot = session.focus.map(FocusTarget::annot);
let mut response = Response::consumed();
if let Some(annot) = annot {
response.absorb(redraw(session, ctx, previous, annot));
}
return response;
}
let change = focus::set(session, target);
if !change.moved() {
return Response::consumed();
}
let mut response = Response::consumed();
if let Some(previous) = change.from {
if change.clear_undo
&& let Some(field) = previous.field()
{
clear_undo(session, field);
}
if let Some(field) = previous.field() {
response.absorb(redraw(session, ctx, field, previous.annot()));
}
}
response.push(AppearanceUpdate::new(
target.annot(),
UpdateKind::FocusChanged {
from: change.from.map(FocusTarget::annot),
to: Some(target.annot()),
},
));
response
}
pub fn focus_field<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
index: u32,
) -> Response {
let Some(field) = ctx.field_of_index(index) else {
return Response::ignored();
};
let Some(id) = ctx.widget_of_field(field).map(|widget| widget.id) else {
return Response::ignored();
};
let target = FocusTarget::Widget(field, id);
if session.focus == Some(target) {
return Response::consumed();
}
if let Some(previous) = session.focus.map(FocusTarget::annot) {
fire_pointer(
session,
ctx,
cascade,
previous,
PointerTrigger::Blur,
Modifiers::NONE,
);
}
fire_pointer(
session,
ctx,
cascade,
id,
PointerTrigger::Focus,
Modifiers::NONE,
);
let mut response = take_focus(session, ctx, cascade, target);
ensure_state(session, ctx, field);
response.absorb(redraw(session, ctx, field, id));
response
}
pub fn kill_focus<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
) -> Response {
let mut response = drop_focus(session, ctx, cascade);
response.absorb(honour_focus_requests(session, ctx, cascade));
response
}
fn drop_focus<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
cascade: &mut dyn Cascade,
) -> Response {
let closed = close_all_popups(session);
if let Some(previous) = session.focus.and_then(FocusTarget::field)
&& let Some(outcome) = commit_field(session, ctx, cascade, previous)
&& outcome.keeps_focus()
{
let annot = session.focus.map(FocusTarget::annot);
let mut response = Response::consumed();
if let Some(annot) = annot {
response.absorb(redraw(session, ctx, previous, annot));
}
return response;
}
let change = focus::kill(session);
let Some(was) = change.from else {
return if closed {
Response::consumed()
} else {
Response::ignored()
};
};
let mut response = Response::consumed();
if let Some(field) = was.field() {
if change.clear_undo {
clear_undo(session, field);
}
response.absorb(redraw(session, ctx, field, was.annot()));
}
response.push(AppearanceUpdate::new(
was.annot(),
UpdateKind::FocusChanged {
from: Some(was.annot()),
to: None,
},
));
response
}
fn clear_undo(session: &mut FormSession, field: FieldId) {
if let Some(FieldState::Text(state)) = session.fields.get_mut(&field) {
state.edit.undo = crate::edit::UndoStack::default();
}
}
fn clear_siblings<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
chosen: AnnotId,
) {
let Some(widget) = ctx.widget(chosen) else {
return;
};
if toggle_kind(widget) != Some(ToggleKind::Radio) {
return;
}
if let Some(FieldState::Toggle(state)) = session.fields.get_mut(&field) {
state.checked_control = Some(chosen);
}
}
fn toggle_kind(widget: &WidgetInfo) -> Option<ToggleKind> {
match widget.kind {
Some(pdfrum_doc::form::FieldKind::Check) => Some(ToggleKind::Check),
Some(pdfrum_doc::form::FieldKind::Radio) => Some(ToggleKind::Radio),
_ => None,
}
}
fn caret_anchor(session: &FormSession, field: FieldId) -> Option<DragAnchor> {
match session.fields.get(&field) {
Some(FieldState::Text(state)) => Some(DragAnchor {
field,
start: state.edit.caret,
}),
_ => None,
}
}
fn drag_to<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
anchor: DragAnchor,
at: Point,
) -> Option<AppearanceUpdate> {
let field = anchor.field;
let point = ctx
.widget_of_field(field)
.map(|widget| to_plate(widget, at))?;
with_edit(session, ctx, field, |edit, config, metrics| {
ops::drag_to(edit, config, metrics, point);
});
let id = session.focus.map(FocusTarget::annot)?;
appearance_of(session, ctx, field, id)
}
fn ensure_state<R: Resolve>(session: &mut FormSession, ctx: &Context<'_, R>, field: FieldId) {
if session.fields.contains_key(&field) {
return;
}
let Some(widget) = ctx.widget_of_field(field) else {
return;
};
let Some(state) = build_state(ctx, widget) else {
return;
};
session.fields.insert(field, state);
}
fn build_state<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> Option<FieldState> {
let family = field::family_of(widget.kind?)?;
Some(match family {
field::Family::Text => {
let config = widget.text_config(ctx.resolve);
let value = widget.value(ctx.resolve);
FieldState::Text(field::TextState {
edit: build_edit(ctx, widget, &value, &config),
config,
})
}
field::Family::Choice => {
let config = widget.choice_config();
let options = widget.options(ctx.resolve);
let selected = widget.selected(ctx.resolve);
let mut state = ChoiceState::new(options, config);
for index in selected {
state.selected.insert(index);
}
state.caret_index = state.selected.iter().next().copied();
state.top_visible = widget.top_index(ctx.resolve);
FieldState::Choice(state)
}
field::Family::Toggle => {
let on_state = on_state_of(ctx, widget);
let checked = !widget.value(ctx.resolve).is_empty()
&& widget.value(ctx.resolve) != field::toggle::OFF_STATE;
let mut state = ToggleState::new(field::toggle::OFF_STATE, on_state);
state.set_checked(checked);
FieldState::Toggle(state)
}
field::Family::Button => FieldState::Button(field::ButtonState::default()),
})
}
fn on_state_of<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> String {
widget
.dict
.dict(pdfrum_object::names::AP, ctx.resolve)
.and_then(|ap| ap.dict(pdfrum_object::names::N, ctx.resolve))
.and_then(|normal| {
normal
.keys()
.find(|key| key.as_bytes() != field::toggle::OFF_STATE.as_bytes())
.map(|key| String::from_utf8_lossy(key.as_bytes()).into_owned())
})
.unwrap_or_default()
}
fn build_edit<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
value: &str,
config: &crate::field::TextConfig,
) -> TextEdit {
let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let vt_config = text_config(ctx, widget, plate, config);
let mut edit = with_font(ctx, widget, |font, _substitute| {
TextEdit::new(value, &vt_config, &font.metrics, !config.multi_line)
})
.unwrap_or_else(|| {
let width = |_code: u32| 0;
let metrics = vt::Metrics {
width: &width,
ascent: 0,
descent: 0,
};
TextEdit::new(value, &vt_config, &metrics, !config.multi_line)
});
edit.auto_scroll = config.auto_scroll;
edit
}
fn text_config<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
plate: kurbo::Rect,
config: &crate::field::TextConfig,
) -> vt::Config {
let mut vt_config = vt::Config {
plate,
alignment: ap::field_body::alignment(&widget.dict, ctx.resolve),
font_size: font_size(ctx, widget),
multi_line: config.multi_line,
auto_return: config.multi_line,
sub_word: config.password.then_some('*'),
..vt::Config::default()
};
if let Some(max) = config.max_len {
let cells = usize::try_from(max.get()).unwrap_or(0);
if config.comb {
vt_config.char_array = cells;
} else {
vt_config.limit_char = cells;
}
}
vt_config
}
fn with_font<R: Resolve, T>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
body: impl FnOnce(&TextFont<'_>, Option<ap::Substitute<'_>>) -> T,
) -> Option<T> {
let form = ctx
.catalog
.dict(pdfrum_object::names::ACRO_FORM, ctx.resolve)
.unwrap_or_default();
let name = ap::freetext::default_appearance(&widget.dict, &form, ctx.resolve)
.map(|appearance| appearance.font_name)
.unwrap_or_default();
let font = ctx.fonts.face(&name)?;
let da_charset = ap::font_map::font_charset(font);
let substitute = ap::font_map::SUBSTITUTABLE_CHARSETS
.iter()
.find(|charset| **charset != da_charset)
.and_then(|charset| ctx.fonts.substitute(*charset));
let width = move |code: u32| match substitute {
Some(sub) if !ap::font_map::da_font_writes(font, da_charset, code) => {
ap::font_map::substitute_width(sub.font, code)
}
_ => TextFont::char_width(font, code),
};
let text_font = TextFont {
metrics: TextFont::metrics_of(font, &width),
font,
};
Some(body(&text_font, substitute))
}
pub fn replace_selection<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
text: &str,
) -> bool {
let max_len = match session.fields.get(&field) {
Some(FieldState::Text(state)) if !state.config.read_only => {
state.config.max_len.map(std::num::NonZeroU32::get)
}
_ => return false,
};
let mut changed = false;
with_edit(session, ctx, field, |edit, config, metrics| {
if text.is_empty() && !edit.has_selection() {
return;
}
changed = ops::replace_selection(edit, config, metrics, text, max_len);
});
if changed {
session.dirty.insert(field);
}
changed
}
fn to_plate(widget: &WidgetInfo, at: Point) -> kurbo::Point {
let point = plate_of(widget).to_widget(at);
kurbo::Point::new(f64::from(point.x), f64::from(point.y))
}
fn plate_of(widget: &WidgetInfo) -> crate::geom::Plate {
crate::geom::Plate::new(widget.rect, widget.rotation)
}
fn with_edit<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
body: impl FnOnce(&mut TextEdit, &vt::Config, &vt::Metrics<'_>),
) {
let Some(widget) = ctx.widget_of_field(field).cloned() else {
return;
};
let Some(FieldState::Text(state)) = session.fields.get_mut(&field) else {
return;
};
let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let config = text_config(ctx, &widget, plate, &state.config);
with_font(ctx, &widget, |font, _substitute| {
body(&mut state.edit, &config, &font.metrics);
});
}
fn with_combo_edit<R: Resolve>(
session: &mut FormSession,
ctx: &Context<'_, R>,
field: FieldId,
body: impl FnOnce(&mut TextEdit, &vt::Config, &vt::Metrics<'_>),
) {
let Some(widget) = ctx.widget_of_field(field).cloned() else {
return;
};
let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) else {
return;
};
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let plate = kurbo::Rect::new(client.x0, client.y0, client.x1 - 13.0, client.y1);
let config = vt::Config {
plate,
font_size: font_size(ctx, &widget),
..vt::Config::default()
};
let editable = state.config.editable;
if !editable {
return;
}
let text = state.edit_text.clone();
with_font(ctx, &widget, |font, _substitute| {
let mut edit = state
.edit
.take()
.unwrap_or_else(|| Box::new(TextEdit::new(text, &config, &font.metrics, true)));
body(&mut edit, &config, &font.metrics);
state.edit_text.clone_from(&edit.text);
state.edit = Some(edit);
});
}
fn redraw<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
field: FieldId,
id: AnnotId,
) -> Response {
match appearance_of(session, ctx, field, id) {
Some(update) => Response::with(vec![update]),
None => Response::consumed(),
}
}
fn appearance_of<R: Resolve>(
session: &FormSession,
ctx: &Context<'_, R>,
field: FieldId,
id: AnnotId,
) -> Option<AppearanceUpdate> {
let widget = ctx.widget(id)?;
let state = session.fields.get(&field)?;
let focused = session.focus.map(FocusTarget::annot) == Some(id);
let display = (!focused)
.then(|| session.formatted.get(&field))
.flatten()
.map(String::as_str);
let generated = generate(ctx, widget, state, focused, display)?;
let kind = if focused {
UpdateKind::LiveEdit(Box::new(generated))
} else {
UpdateKind::Regenerated(Box::new(generated))
};
Some(AppearanceUpdate::new(id, kind))
}
fn generate<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
state: &FieldState,
focused: bool,
display: Option<&str>,
) -> Option<pdfrum_doc::GeneratedAp> {
let selected = selected_rows(state);
let live = live_state(state, &selected, display);
let highlight = focused.then(|| highlight_of(ctx, widget, state)).flatten();
let as_override = match state {
FieldState::Toggle(toggle) => toggle.state_for_control(widget.id),
FieldState::Text(_) | FieldState::Choice(_) | FieldState::Button(_) => None,
};
with_font(ctx, widget, |font, substitute| {
ap::widget::generate_with_live_faces(
&widget.dict,
ctx.catalog,
font,
ctx.resolve,
ap::widget::LiveInput {
caret_and_selection: highlight.as_ref(),
live: live.as_ref(),
substitute,
appearance_state: as_override.map(str::as_bytes),
},
)
})
.flatten()
}
#[must_use]
pub fn focus_of<R: Resolve>(session: &FormSession, ctx: &Context<'_, R>) -> Option<ap::Focus> {
let target = session.focus?;
let annot = target.annot();
if annot.page != ctx.page.page {
return None;
}
let index = usize::try_from(annot.index).unwrap_or(0);
let Some(field) = target.field() else {
return Some(ap::Focus::at(index));
};
let box_ = match session.fields.get(&field) {
Some(FieldState::Text(_)) | None => ap::FocusBox::None,
Some(FieldState::Choice(choice)) if choice.config.combo => ap::FocusBox::None,
Some(FieldState::Choice(choice)) if !choice.config.multi_select => ap::FocusBox::Inflated,
Some(FieldState::Choice(choice)) => caret_row_box(ctx, annot, choice),
Some(FieldState::Toggle(_)) => ap::FocusBox::Inflated,
Some(FieldState::Button(_)) => push_button_box(ctx, annot),
};
Some(ap::Focus { annot: index, box_ })
}
fn push_button_box<R: Resolve>(ctx: &Context<'_, R>, annot: AnnotId) -> ap::FocusBox {
let Some(widget) = ctx.widget(annot) else {
return ap::FocusBox::None;
};
let width = f64::from(ap::widget::widget_border(&widget.dict, ctx.resolve).width);
let rect = pdfrum_doc::geom::normalize(widget_rect(widget));
ap::FocusBox::Rect(pdfrum_doc::geom::normalize(kurbo::Rect::new(
rect.x0 + width,
rect.y0 + width,
rect.x1 - width,
rect.y1 - width,
)))
}
fn caret_row_box<R: Resolve>(
ctx: &Context<'_, R>,
annot: AnnotId,
choice: &ChoiceState,
) -> ap::FocusBox {
let Some(widget) = ctx.widget(annot) else {
return ap::FocusBox::None;
};
let Some(caret) = choice.caret_index else {
return ap::FocusBox::None;
};
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let height = f64::from(row_height(ctx, widget, choice));
if height <= 0.0 {
return ap::FocusBox::None;
}
let Some(offset) = caret.checked_sub(choice.top_visible) else {
return ap::FocusBox::None;
};
#[expect(
clippy::cast_precision_loss,
reason = "a row offset is bounded by the option count, which a file \
cannot make large enough to lose a mantissa bit"
)]
let top = client.y1 - height * offset as f64;
let bottom = top - height;
if bottom >= client.y1 || top <= client.y0 {
return ap::FocusBox::None;
}
let origin = pdfrum_doc::geom::normalize(widget_rect(widget));
ap::FocusBox::Rect(kurbo::Rect::new(
client.x0 + origin.x0,
bottom.max(client.y0) + origin.y0,
client.x1 + origin.x0,
top.min(client.y1) + origin.y0,
))
}
fn widget_rect(widget: &WidgetInfo) -> kurbo::Rect {
kurbo::Rect::new(
f64::from(widget.rect.left),
f64::from(widget.rect.bottom),
f64::from(widget.rect.right),
f64::from(widget.rect.top),
)
}
fn selected_rows(state: &FieldState) -> Vec<usize> {
match state {
FieldState::Choice(choice) => choice.selected.iter().copied().collect(),
FieldState::Text(_) | FieldState::Toggle(_) | FieldState::Button(_) => Vec::new(),
}
}
fn live_state<'a>(
state: &'a FieldState,
selected: &'a [usize],
display: Option<&'a str>,
) -> Option<ap::field_body::LiveState<'a>> {
match state {
FieldState::Text(text) => Some(ap::field_body::LiveState {
text: display.unwrap_or(&text.edit.text),
scroll: text.edit.scroll,
..ap::field_body::LiveState::default()
}),
FieldState::Choice(choice) => Some(ap::field_body::LiveState {
text: match (display, choice.config.editable) {
(Some(display), true) => display,
(_, true) => &choice.edit_text,
(_, false) => "",
},
selected,
top_visible: choice.top_visible,
scroll: (0.0, 0.0),
}),
FieldState::Toggle(_) | FieldState::Button(_) => None,
}
}
fn highlight_of<R: Resolve>(
ctx: &Context<'_, R>,
widget: &WidgetInfo,
state: &FieldState,
) -> Option<ap::field_body::Highlight> {
match state {
FieldState::Text(text) => {
let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let config = text_config(ctx, widget, plate, &text.config);
with_font(ctx, widget, |font, _substitute| {
ops::highlight(
&text.edit,
&config,
&font.metrics,
ap::field_body::CARET_WIDTH,
)
})
}
FieldState::Choice(choice) if choice.config.editable => {
let edit = choice.edit.as_deref()?;
let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
let plate = kurbo::Rect::new(
client.x0,
client.y0,
client.x1 - f64::from(DROP_BUTTON_WIDTH),
client.y1,
);
let config = vt::Config {
plate,
font_size: font_size(ctx, widget),
..vt::Config::default()
};
with_font(ctx, widget, |font, _substitute| {
ops::highlight(edit, &config, &font.metrics, ap::field_body::CARET_WIDTH)
})
}
FieldState::Choice(_) | FieldState::Toggle(_) | FieldState::Button(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdfrum_object::{Name, NoResolve, Object, PdfString};
fn page_and_catalog() -> (Dict, Dict) {
let font = Dict::from_pairs([
(
pdfrum_object::names::TYPE.clone(),
Object::Name(Name::from_static(b"Font").clone()),
),
(
pdfrum_object::names::SUBTYPE.clone(),
Object::Name(Name::from_static(b"TrueType").clone()),
),
(
Name::from_static(b"BaseFont").clone(),
Object::Name(Name::from_static(b"Arial").clone()),
),
]);
let catalog = Dict::from_pairs([(
Name::from_static(b"AcroForm").clone(),
Object::Dict(Dict::from_pairs([(
Name::from_static(b"DR").clone(),
Object::Dict(Dict::from_pairs([(
Name::from_static(b"Font").clone(),
Object::Dict(Dict::from_pairs([(
Name::from_static(b"Arial").clone(),
Object::Dict(font),
)])),
)])),
)])),
)]);
let widget = Dict::from_pairs([
(
pdfrum_object::names::TYPE.clone(),
Object::Name(Name::from_static(b"Annot").clone()),
),
(
pdfrum_object::names::SUBTYPE.clone(),
Object::Name(Name::from_static(b"Widget").clone()),
),
(
Name::from_static(b"FT").clone(),
Object::Name(Name::from_static(b"Tx").clone()),
),
(
Name::from_static(b"T").clone(),
Object::Str(PdfString::literal(*b"Text Box")),
),
(
Name::from_static(b"Rect").clone(),
Object::Array(
[
Object::Int(50),
Object::Int(40),
Object::Int(150),
Object::Int(70),
]
.into_iter()
.collect(),
),
),
(
Name::from_static(b"DA").clone(),
Object::Str(PdfString::literal(*b"/Arial 12 Tf 0 0 0 rg")),
),
]);
let page = Dict::from_pairs([(
Name::from_static(b"Annots").clone(),
Object::Array([Object::Dict(widget)].into_iter().collect()),
)]);
(page, catalog)
}
#[test]
fn the_width_closure_measures_an_unwritable_character_in_the_second_face() {
const BET: u32 = 0x05D1;
let (page, catalog) = page_and_catalog();
let resolve = NoResolve;
let form = crate::page::read(0, &page, &catalog, &resolve);
let widget = form.widgets.first().expect("one /Tx widget");
let mut build = pdfrum_page::BuildContext::new();
let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
let ctx = Context {
page: &form,
catalog: &catalog,
resolve: &resolve,
fonts: &fonts,
permissions: hit::Permissions::ALL,
};
let da = fonts.face(b"Arial").expect("the /DR declares one face");
let substitute = fonts
.substitute(pdfrum_font::Charset::Hebrew)
.expect("the Hebrew second face loads with no font directory at all");
assert!(
!ap::font_map::da_font_writes(da, ap::font_map::font_charset(da), BET),
"the fixture's own font must be unable to write the character, \
or the substitution never arises"
);
let (da_width, substitute_width) = (
TextFont::char_width(da, BET),
ap::font_map::substitute_width(substitute.font, BET),
);
assert_ne!(
da_width, substitute_width,
"the two faces must disagree, or this test cannot tell them apart"
);
let measured = with_font(&ctx, widget, |font, _substitute| {
(
(font.metrics.width)(BET),
(font.metrics.width)(u32::from(b'a')),
)
})
.expect("the widget's /DA resolves to a face");
assert_eq!(
measured.0, substitute_width,
"a character the /DA font cannot write is measured in the second face"
);
assert_eq!(
measured.1,
TextFont::char_width(da, u32::from(b'a')),
"and one it can is still measured in the /DA font"
);
}
}