Skip to content

Commit b302b22

Browse files
committed
feat: add CSS selector queries and expose container state
1 parent d3a3c0d commit b302b22

9 files changed

Lines changed: 189 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## [0.2.2] - 2026-02-19
2+
3+
### Added
4+
- CSS selector queries on elements via `select_one()` and `css_escape_ident()`
5+
- Pending image tracking: `take_pending_images()` collects URLs discovered during layout
6+
- Anchor click and cursor state now exposed via `take_anchor_click()` and `cursor()`
7+
18
## [0.2.1] - 2026-02-18
29

310
### Added

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,11 @@ let prepared = prepare_email_html(raw_bytes, Some(&cid_resolver), None);
102102
```
103103

104104
`EMAIL_MASTER_CSS` provides an email user-agent stylesheet (body reset, responsive images, table normalization, MSO workarounds).
105+
106+
## Tips
107+
108+
A few things worth knowing when integrating `PixbufContainer` into a GUI:
109+
110+
- **Image loading is your job.** During layout, litehtml discovers `<img>` URLs and queues them. Call `take_pending_images()` to drain the queue, fetch the data yourself, then feed it back via `load_image_data()`. Stage multiple images before re-rendering to avoid one rebuild per image.
111+
- **Pixel data is premultiplied.** `container.pixels()` returns premultiplied RGBA. If your framework expects straight alpha (e.g. iced's `image::Handle::from_rgba`), you'll need to unpremultiply first.
112+
- **Anchor clicks and cursor state are pull-based.** After mouse events, call `take_anchor_click()` to check if a link was clicked and `cursor()` to read the current CSS cursor value. Discard anchor clicks during active text selections to avoid accidental navigation.

litehtml-sys/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "litehtml-sys"
3-
version = "0.2.0"
3+
version = "0.2.2"
44
edition = "2021"
55
license = "MIT AND BSD-3-Clause AND Apache-2.0"
66
authors = ["Franz Geffke <mail@gofranz.com>"]

litehtml-sys/csrc/litehtml_c.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,15 @@ float lh_element_get_font_size(lh_element_t* el)
11771177
return elem->css().get_font_size();
11781178
}
11791179

1180+
lh_element_t* lh_element_select_one(lh_element_t* el, const char* selector)
1181+
{
1182+
if (!el || !selector) return nullptr;
1183+
auto* elem = reinterpret_cast<litehtml::element*>(el);
1184+
auto found = elem->select_one(selector);
1185+
if (!found) return nullptr;
1186+
return reinterpret_cast<lh_element_t*>(found.get());
1187+
}
1188+
11801189
void lh_element_get_placement(lh_element_t* el, lh_position_t* pos)
11811190
{
11821191
if (!el || !pos) return;

litehtml-sys/csrc/litehtml_c.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,10 @@ void lh_element_get_inline_box_at(lh_element_t* el, int index, lh_position_t* po
381381
typedef void (*lh_inline_box_callback)(const lh_position_t* pos, void* ctx);
382382
void lh_element_get_inline_boxes(lh_element_t* el, lh_inline_box_callback cb, void* ctx);
383383

384+
/* Find the first descendant matching a CSS selector (e.g. "#id", "[name=foo]").
385+
Returns NULL if no match or if el/selector is NULL. */
386+
lh_element_t* lh_element_select_one(lh_element_t* el, const char* selector);
387+
384388
/* Get the computed text-align value (0=left, 1=right, 2=center, 3=justify). */
385389
int lh_element_get_text_align(lh_element_t* el);
386390

litehtml/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "litehtml"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
edition = "2021"
55
license = "MIT"
66
authors = ["Franz Geffke <mail@gofranz.com>"]

litehtml/src/lib.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,53 @@ static CONTAINER_VTABLE: sys::lh_container_vtable_t = sys::lh_container_vtable_t
16571657
// Document
16581658
// ---------------------------------------------------------------------------
16591659

1660+
/// Escape CSS meta-characters in an identifier for use in selectors.
1661+
///
1662+
/// Without escaping, `select_one("#foo.bar")` is parsed as "id=foo AND class=bar".
1663+
/// With escaping, `select_one(&format!("#{}", css_escape_ident("foo.bar")))` correctly
1664+
/// matches an element with `id="foo.bar"`.
1665+
pub fn css_escape_ident(s: &str) -> String {
1666+
let mut out = String::with_capacity(s.len() + 8);
1667+
for (i, c) in s.chars().enumerate() {
1668+
if matches!(
1669+
c,
1670+
'.' | ':'
1671+
| '['
1672+
| ']'
1673+
| '('
1674+
| ')'
1675+
| '#'
1676+
| '>'
1677+
| '+'
1678+
| '~'
1679+
| ','
1680+
| ' '
1681+
| '{'
1682+
| '}'
1683+
| '!'
1684+
| '"'
1685+
| '\''
1686+
| '\\'
1687+
| '/'
1688+
| '='
1689+
| '^'
1690+
| '$'
1691+
| '*'
1692+
| '|'
1693+
| '%'
1694+
| '&'
1695+
| '@'
1696+
) {
1697+
out.push('\\');
1698+
}
1699+
if i == 0 && c.is_ascii_digit() {
1700+
out.push('\\');
1701+
}
1702+
out.push(c);
1703+
}
1704+
out
1705+
}
1706+
16601707
/// Opaque handle to a litehtml element. Borrows from the parent [`Document`].
16611708
pub struct Element<'a> {
16621709
ptr: *mut sys::lh_element_t,
@@ -1710,6 +1757,23 @@ impl<'a> Element<'a> {
17101757
unsafe { sys::lh_element_get_font_size(self.ptr) }
17111758
}
17121759

1760+
/// Find the first descendant matching a CSS selector.
1761+
///
1762+
/// Useful for locating elements by ID (`"#myid"`) or attribute
1763+
/// (`"[name=anchor]"`). Returns `None` if no match is found.
1764+
pub fn select_one(&self, selector: &str) -> Option<Element<'a>> {
1765+
let c_sel = CString::new(selector).ok()?;
1766+
let ptr = unsafe { sys::lh_element_select_one(self.ptr, c_sel.as_ptr()) };
1767+
if ptr.is_null() {
1768+
None
1769+
} else {
1770+
Some(Element {
1771+
ptr,
1772+
_phantom: PhantomData,
1773+
})
1774+
}
1775+
}
1776+
17131777
/// Absolute pixel bounding box after layout.
17141778
pub fn placement(&self) -> Position {
17151779
let mut pos = sys::lh_position_t {

litehtml/src/pixbuf.rs

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@ pub struct PixbufContainer {
4646
cached_clip_mask: Option<tiny_skia::Mask>,
4747
clip_mask_dirty: bool,
4848
images: HashMap<String, tiny_skia::Pixmap>,
49+
pending_images: Vec<(String, bool)>,
50+
/// URLs that have already been handed off for fetching. Prevents the same
51+
/// URL from being re-added to `pending_images` across document rebuilds.
52+
requested_images: std::collections::HashSet<String>,
4953
viewport: Position,
5054
base_url: String,
5155
caption: String,
5256
scale_factor: f32,
5357
ignore_overflow_clips: bool,
58+
last_anchor_click: Option<String>,
59+
current_cursor: String,
5460
}
5561

5662
impl PixbufContainer {
@@ -82,6 +88,8 @@ impl PixbufContainer {
8288
cached_clip_mask: None,
8389
clip_mask_dirty: false,
8490
images: HashMap::new(),
91+
pending_images: Vec::new(),
92+
requested_images: std::collections::HashSet::new(),
8593
viewport: Position {
8694
x: 0.0,
8795
y: 0.0,
@@ -92,6 +100,8 @@ impl PixbufContainer {
92100
caption: String::new(),
93101
scale_factor,
94102
ignore_overflow_clips: false,
103+
last_anchor_click: None,
104+
current_cursor: String::new(),
95105
}
96106
}
97107

@@ -170,6 +180,33 @@ impl PixbufContainer {
170180
self.ignore_overflow_clips = ignore;
171181
}
172182

183+
/// Take the last anchor click URL, if any.
184+
///
185+
/// Returns `Some(url)` when `on_anchor_click` was triggered by litehtml
186+
/// (via `Document::on_lbutton_up`), clearing the stored value.
187+
pub fn take_anchor_click(&mut self) -> Option<String> {
188+
self.last_anchor_click.take()
189+
}
190+
191+
/// Drain and return image URLs discovered during layout that haven't been
192+
/// loaded yet. The consumer is responsible for fetching the data and calling
193+
/// `load_image_data` with the result.
194+
pub fn take_pending_images(&mut self) -> Vec<(String, bool)> {
195+
std::mem::take(&mut self.pending_images)
196+
}
197+
198+
/// Clear all pending/requested image tracking. Call on navigation so
199+
/// the new page's images are discovered fresh.
200+
pub fn clear_pending_images(&mut self) {
201+
self.pending_images.clear();
202+
self.requested_images.clear();
203+
}
204+
205+
/// Get the current CSS cursor value set by litehtml (e.g. `"pointer"`, `"default"`).
206+
pub fn cursor(&self) -> &str {
207+
&self.current_cursor
208+
}
209+
173210
/// Resize the pixmap, clearing all existing content.
174211
pub fn resize(&mut self, width: u32, height: u32) {
175212
self.resize_with_scale(width, height, self.scale_factor);
@@ -739,8 +776,13 @@ impl DocumentContainer for PixbufContainer {
739776
width: 1.0,
740777
..Stroke::default()
741778
};
742-
self.pixmap
743-
.stroke_path(&path, &paint, &stroke, transform, self.cached_clip_mask.as_ref());
779+
self.pixmap.stroke_path(
780+
&path,
781+
&paint,
782+
&stroke,
783+
transform,
784+
self.cached_clip_mask.as_ref(),
785+
);
744786
}
745787
}
746788
2 => {
@@ -756,8 +798,12 @@ impl DocumentContainer for PixbufContainer {
756798
}
757799
}
758800

759-
fn load_image(&mut self, _src: &str, _baseurl: &str, _redraw_on_ready: bool) {
760-
// Image loading is handled externally via `load_image_data`.
801+
fn load_image(&mut self, src: &str, _baseurl: &str, redraw_on_ready: bool) {
802+
if src.is_empty() || self.images.contains_key(src) || self.requested_images.contains(src) {
803+
return;
804+
}
805+
self.requested_images.insert(src.to_string());
806+
self.pending_images.push((src.to_string(), redraw_on_ready));
761807
}
762808

763809
fn get_image_size(&self, src: &str, _baseurl: &str) -> Size {
@@ -807,14 +853,22 @@ impl DocumentContainer for PixbufContainer {
807853
intersect_masks(&mut m, existing);
808854
}
809855
self.pixmap.draw_pixmap(
810-
dst_x, dst_y, img.as_ref(), &img_paint,
811-
Transform::identity(), Some(&m),
856+
dst_x,
857+
dst_y,
858+
img.as_ref(),
859+
&img_paint,
860+
Transform::identity(),
861+
Some(&m),
812862
);
813863
}
814864
} else {
815865
self.pixmap.draw_pixmap(
816-
dst_x, dst_y, img.as_ref(), &img_paint,
817-
Transform::identity(), self.cached_clip_mask.as_ref(),
866+
dst_x,
867+
dst_y,
868+
img.as_ref(),
869+
&img_paint,
870+
Transform::identity(),
871+
self.cached_clip_mask.as_ref(),
818872
);
819873
}
820874
}
@@ -832,8 +886,13 @@ impl DocumentContainer for PixbufContainer {
832886
if let Some(path) =
833887
build_rounded_rect_path(border.x, border.y, border.width, border.height, &radii)
834888
{
835-
self.pixmap
836-
.fill_path(&path, &paint, FillRule::Winding, transform, self.cached_clip_mask.as_ref());
889+
self.pixmap.fill_path(
890+
&path,
891+
&paint,
892+
FillRule::Winding,
893+
transform,
894+
self.cached_clip_mask.as_ref(),
895+
);
837896
}
838897
}
839898

@@ -878,8 +937,13 @@ impl DocumentContainer for PixbufContainer {
878937
if let Some(path) =
879938
build_rounded_rect_path(border.x, border.y, border.width, border.height, &radii)
880939
{
881-
self.pixmap
882-
.fill_path(&path, &paint, FillRule::Winding, transform, self.cached_clip_mask.as_ref());
940+
self.pixmap.fill_path(
941+
&path,
942+
&paint,
943+
FillRule::Winding,
944+
transform,
945+
self.cached_clip_mask.as_ref(),
946+
);
883947
}
884948
}
885949
}
@@ -930,8 +994,13 @@ impl DocumentContainer for PixbufContainer {
930994
if let Some(path) =
931995
build_rounded_rect_path(border.x, border.y, border.width, border.height, &radii)
932996
{
933-
self.pixmap
934-
.fill_path(&path, &paint, FillRule::Winding, transform, self.cached_clip_mask.as_ref());
997+
self.pixmap.fill_path(
998+
&path,
999+
&paint,
1000+
FillRule::Winding,
1001+
transform,
1002+
self.cached_clip_mask.as_ref(),
1003+
);
9351004
}
9361005
}
9371006
}
@@ -1016,9 +1085,13 @@ impl DocumentContainer for PixbufContainer {
10161085
self.base_url = base_url.to_string();
10171086
}
10181087

1019-
fn on_anchor_click(&mut self, _url: &str) {}
1088+
fn on_anchor_click(&mut self, url: &str) {
1089+
self.last_anchor_click = Some(url.to_string());
1090+
}
10201091

1021-
fn set_cursor(&mut self, _cursor: &str) {}
1092+
fn set_cursor(&mut self, cursor: &str) {
1093+
self.current_cursor = cursor.to_string();
1094+
}
10221095

10231096
fn set_clip(&mut self, pos: Position, radius: BorderRadiuses) {
10241097
if self.ignore_overflow_clips {
@@ -1100,13 +1173,13 @@ fn blend_pixel(
11001173
}
11011174

11021175
// Apply clip mask
1176+
let pixel_offset = y as usize * width as usize + x as usize;
11031177
let effective_a = if let Some(mask) = mask {
1104-
let mask_idx = (y * width + x) as usize;
11051178
let mask_data = mask.data();
1106-
if mask_idx >= mask_data.len() {
1179+
if pixel_offset >= mask_data.len() {
11071180
return;
11081181
}
1109-
let mask_val = mask_data[mask_idx];
1182+
let mask_val = mask_data[pixel_offset];
11101183
if mask_val == 0 {
11111184
return;
11121185
}
@@ -1119,7 +1192,7 @@ fn blend_pixel(
11191192
return;
11201193
}
11211194

1122-
let idx = ((y * width + x) * 4) as usize;
1195+
let idx = pixel_offset * 4;
11231196
if idx + 3 >= data.len() {
11241197
return;
11251198
}

0 commit comments

Comments
 (0)