use crate::semantic::{SemanticComponent, SemanticKind, SemanticRef};
use crate::tui::content::ContentLine;
use std::collections::HashMap;
const HINT_CHARS: &[u8] = b"asdfgqwertzxcvb";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkHint {
pub label: String,
pub semantic_ref: SemanticRef,
}
pub fn is_hintable_component(component: &SemanticComponent) -> bool {
if component.attrs.disabled.unwrap_or(false) {
return false;
}
match component.kind {
SemanticKind::Link
| SemanticKind::Textarea
| SemanticKind::Select
| SemanticKind::Button => true,
SemanticKind::Input => {
let t = component
.attrs
.input_type
.as_deref()
.unwrap_or("text")
.to_ascii_lowercase();
!matches!(t.as_str(), "hidden" | "file")
}
_ => false,
}
}
pub fn assign_hints(
lines: &[ContentLine],
scroll_y: usize,
viewport_height: usize,
targets: &[SemanticComponent],
) -> Vec<LinkHint> {
let visible_end = scroll_y.saturating_add(viewport_height.max(1));
let mut visible_refs = Vec::new();
for line in lines
.iter()
.skip(scroll_y)
.take(visible_end.saturating_sub(scroll_y))
{
if let Some(r) = &line.semantic_ref
&& !visible_refs.contains(r)
{
visible_refs.push(r.clone());
}
}
let mut target_by_ref: HashMap<&SemanticRef, &SemanticComponent> = HashMap::new();
for target in targets {
if is_hintable_component(target) {
target_by_ref.insert(&target.semantic_ref, target);
}
}
let mut ordered: Vec<SemanticRef> = visible_refs
.into_iter()
.filter(|r| target_by_ref.contains_key(r))
.collect();
ordered.sort_by_key(|r| {
targets
.iter()
.position(|t| &t.semantic_ref == r)
.unwrap_or(usize::MAX)
});
let alphabet: Vec<char> = HINT_CHARS.iter().map(|&b| b as char).collect();
let max = alphabet.len() * alphabet.len();
ordered
.into_iter()
.take(max)
.enumerate()
.map(|(i, semantic_ref)| {
let a = alphabet[i / alphabet.len()];
let b = alphabet[i % alphabet.len()];
LinkHint {
label: format!("{a}{b}"),
semantic_ref,
}
})
.collect()
}
pub fn match_hint<'a>(hints: &'a [LinkHint], buffer: &str) -> HintMatch<'a> {
if buffer.is_empty() {
return HintMatch::Partial;
}
let exact: Vec<_> = hints.iter().filter(|h| h.label == buffer).collect();
if exact.len() == 1 {
return HintMatch::Exact(&exact[0].semantic_ref);
}
if exact.len() > 1 {
return HintMatch::None;
}
if hints.iter().any(|h| h.label.starts_with(buffer)) {
return HintMatch::Partial;
}
HintMatch::None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HintMatch<'a> {
Exact(&'a SemanticRef),
Partial,
None,
}
#[allow(dead_code)]
pub fn label_for_index(index: usize) -> Option<String> {
let n = HINT_CHARS.len();
if index >= n * n {
return None;
}
let a = HINT_CHARS[index / n] as char;
let b = HINT_CHARS[index % n] as char;
Some(format!("{a}{b}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::DocumentMetadata;
use crate::semantic::normalize::{RawSemanticNode, normalize_fixture};
use crate::tui::content::build_content_lines;
use std::collections::HashSet;
fn link_node(id: &str, href: &str, text: &str) -> RawSemanticNode {
RawSemanticNode {
kind: "link".into(),
tag: Some("a".into()),
id: Some(id.into()),
unique_id: true,
selector: None,
text: Some(text.into()),
href: Some(href.into()),
landmark: None,
heading_level: None,
ordered: None,
label: None,
src: None,
alt: None,
name: None,
value: None,
input_type: None,
placeholder: None,
checked: None,
disabled: None,
required: None,
readonly: None,
multiple: None,
button_type: None,
options: vec![],
children: vec![],
}
}
#[test]
fn two_key_labels_are_deterministic() {
assert_eq!(label_for_index(0).as_deref(), Some("aa"));
assert_eq!(label_for_index(1).as_deref(), Some("as"));
assert_eq!(
label_for_index(0),
label_for_index(0),
"stable across calls"
);
}
fn input_node(id: &str, name: &str) -> RawSemanticNode {
RawSemanticNode {
kind: "input".into(),
tag: Some("input".into()),
id: Some(id.into()),
unique_id: true,
selector: None,
text: None,
href: None,
landmark: None,
heading_level: None,
ordered: None,
label: None,
src: None,
alt: None,
name: Some(name.into()),
value: None,
input_type: Some("text".into()),
placeholder: None,
checked: None,
disabled: None,
required: None,
readonly: None,
multiple: None,
button_type: None,
options: vec![],
children: vec![],
}
}
#[test]
fn assign_and_match_exact_ref() {
let doc = normalize_fixture(
DocumentMetadata {
document_id: "d".into(),
revision: "1".into(),
url: "https://example.com/".into(),
title: "T".into(),
ready_state: "complete".into(),
frames: vec![],
},
vec![link_node("a", "/a", "A"), link_node("b", "/b", "B")],
)
.expect("doc");
let lines = build_content_lines(&doc, &HashSet::new());
let targets: Vec<_> = doc
.components()
.filter(|c| is_hintable_component(c))
.cloned()
.collect();
let hints = assign_hints(&lines, 0, 50, &targets);
assert_eq!(hints.len(), 2);
assert_eq!(hints[0].label, "aa");
assert_eq!(hints[1].label, "as");
match match_hint(&hints, "aa") {
HintMatch::Exact(r) => {
assert_eq!(r, &hints[0].semantic_ref);
assert!(doc.resolve(r).is_ok());
}
other => panic!("expected exact, got {other:?}"),
}
assert_eq!(match_hint(&hints, "a"), HintMatch::Partial);
assert_eq!(match_hint(&hints, "zz"), HintMatch::None);
}
#[test]
fn assign_includes_form_controls_once_per_target() {
let doc = normalize_fixture(
DocumentMetadata {
document_id: "d".into(),
revision: "1".into(),
url: "https://example.com/".into(),
title: "T".into(),
ready_state: "complete".into(),
frames: vec![],
},
vec![
link_node("a", "/a", "A very long link label that will wrap"),
input_node("email", "email"),
],
)
.expect("doc");
let lines = build_content_lines(&doc, &HashSet::new());
let wrapped = crate::tui::content::wrap_content_lines(&lines, 12);
let link_ref = doc
.components()
.find(|c| c.attrs.element_id.as_deref() == Some("a"))
.unwrap()
.semantic_ref
.clone();
let link_rows = wrapped
.iter()
.filter(|l| l.semantic_ref.as_ref() == Some(&link_ref))
.count();
assert!(
link_rows >= 2,
"expected wrapped multi-line link, got {link_rows} rows"
);
let targets: Vec<_> = doc
.components()
.filter(|c| is_hintable_component(c))
.cloned()
.collect();
let hints = assign_hints(&wrapped, 0, 50, &targets);
assert_eq!(hints.len(), 2, "link + input, not one hint per wrap row");
assert_eq!(
hints.iter().filter(|h| h.semantic_ref == link_ref).count(),
1,
"multi-line link must get a single hint"
);
}
}