Skip to content

Commit c2cafdb

Browse files
authored
Merge pull request #88 from ynqa/feat/structured-stable-viewport
feat(widgets): preserve structured viewports during navigation
2 parents df50ee1 + 9bbbe6d commit c2cafdb

5 files changed

Lines changed: 285 additions & 28 deletions

File tree

‎promkit-widgets/src/structured/json.rs‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ impl Widget for State {
4141
.config
4242
.lines
4343
.map_or(height as usize, |lines| lines.min(height as usize));
44-
let rows = self.document.extract_rows_from_current(height);
45-
let line_numbers = self
46-
.config
47-
.show_line_numbers
48-
.then(|| self.document.line_numbers_from_current(height));
49-
self.create_graphemes_from_rows(&rows, 0, line_numbers)
44+
let (viewport_start, active_row) = self.document.project_viewport(height);
45+
let rows = self.document.extract_rows_from(viewport_start, height);
46+
let line_numbers = self.config.show_line_numbers.then(|| {
47+
self.document
48+
.line_numbers_from_viewport(viewport_start, height)
49+
});
50+
self.create_graphemes_from_rows(&rows, active_row, line_numbers)
5051
}
5152
}
5253

@@ -97,7 +98,7 @@ impl State {
9798
/// Interprets a content position in a viewport projection as a semantic target.
9899
pub fn hit_at_viewport(&self, position: ContentPosition) -> Option<JsonHit> {
99100
self.document
100-
.row_index_at_visible_offset_from_current(position.row)
101+
.row_index_at_viewport_position(position.row)
101102
.map(|row_index| JsonHit::Toggle { row_index })
102103
}
103104
}
@@ -113,7 +114,7 @@ mod tests {
113114
use super::*;
114115

115116
#[test]
116-
fn viewport_projection_is_bounded_and_resolves_hits_from_cursor() {
117+
fn viewport_projection_is_bounded_and_resolves_hits() {
117118
let value = serde_json::json!({
118119
"first": "one",
119120
"second": {
@@ -126,6 +127,9 @@ mod tests {
126127
config: Config::default(),
127128
};
128129

130+
state.create_graphemes_in_viewport(80, 2);
131+
state.document.down();
132+
state.create_graphemes_in_viewport(80, 2);
129133
state.document.down();
130134
let projected = state.create_graphemes_in_viewport(80, 2);
131135
let rendered = projected.graphemes.to_string();
@@ -134,7 +138,7 @@ mod tests {
134138
assert!(rendered.contains("\"second\": {"));
135139
assert!(!rendered.contains("\"nested\": \"two\""));
136140
assert!(!rendered.contains("\"third\": \"three\""));
137-
assert_eq!(projected.cursor.unwrap().row, 0);
141+
assert_eq!(projected.cursor.unwrap().row, 1);
138142

139143
let JsonHit::Toggle { row_index } = state
140144
.hit_at_viewport(ContentPosition { row: 1, column: 4 })
@@ -145,6 +149,61 @@ mod tests {
145149
assert!(collapsed.graphemes.to_string().contains("\"second\": {…}"));
146150
}
147151

152+
#[test]
153+
fn viewport_projection_stays_stable_until_cursor_leaves_it() {
154+
let value = serde_json::json!({
155+
"first": 1,
156+
"second": 2,
157+
"third": 3,
158+
"fourth": 4
159+
});
160+
let mut state = State {
161+
document: Document::new([&value]),
162+
config: Config::default(),
163+
};
164+
165+
let initial = state.create_graphemes_in_viewport(80, 3);
166+
assert!(initial.graphemes.to_string().starts_with("{"));
167+
assert_eq!(initial.cursor.unwrap().row, 0);
168+
169+
state.document.down();
170+
let moved_inside = state.create_graphemes_in_viewport(80, 3);
171+
assert!(moved_inside.graphemes.to_string().starts_with("{"));
172+
assert_eq!(moved_inside.cursor.unwrap().row, 1);
173+
174+
state.document.down();
175+
let moved_to_edge = state.create_graphemes_in_viewport(80, 3);
176+
assert!(moved_to_edge.graphemes.to_string().starts_with("{"));
177+
assert_eq!(moved_to_edge.cursor.unwrap().row, 2);
178+
179+
state.document.down();
180+
let moved_outside = state.create_graphemes_in_viewport(80, 3);
181+
assert!(
182+
moved_outside
183+
.graphemes
184+
.to_string()
185+
.starts_with("\"first\": 1")
186+
);
187+
assert_eq!(moved_outside.cursor.unwrap().row, 2);
188+
189+
state.document.up();
190+
let moved_back_inside = state.create_graphemes_in_viewport(80, 3);
191+
assert!(
192+
moved_back_inside
193+
.graphemes
194+
.to_string()
195+
.starts_with("\"first\": 1")
196+
);
197+
assert_eq!(moved_back_inside.cursor.unwrap().row, 1);
198+
199+
state.document.up();
200+
state.create_graphemes_in_viewport(80, 3);
201+
state.document.up();
202+
let moved_above = state.create_graphemes_in_viewport(80, 3);
203+
assert!(moved_above.graphemes.to_string().starts_with("{"));
204+
assert_eq!(moved_above.cursor.unwrap().row, 0);
205+
}
206+
148207
#[test]
149208
fn configured_line_limit_bounds_viewport_projection() {
150209
let value = serde_json::json!({"first": 1, "second": 2});
@@ -193,7 +252,7 @@ mod tests {
193252
}
194253

195254
#[test]
196-
fn viewport_projection_uses_stable_line_numbers_from_cursor() {
255+
fn viewport_projection_uses_stable_line_numbers() {
197256
let value = serde_json::json!({"first": 1, "second": 2, "third": 3});
198257
let mut state = State {
199258
document: Document::new([&value]),
@@ -203,6 +262,8 @@ mod tests {
203262
},
204263
};
205264

265+
state.create_graphemes_in_viewport(80, 2);
266+
state.document.down();
206267
state.document.down();
207268
state.document.down();
208269

‎promkit-widgets/src/structured/json/document.rs‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
use std::cell::Cell;
2+
13
use super::jsonz::{self, Row, RowOperation};
4+
use crate::structured::{ProjectionViewport, projection_viewport};
25

36
/// Represents a navigable JSON document, allowing for efficient row navigation and folding.
47
#[derive(Clone)]
58
pub struct Document {
69
rows: Vec<Row>,
710
position: usize,
11+
viewport: Cell<ProjectionViewport>,
812
}
913

1014
impl Document {
1115
pub fn new<'a, I: IntoIterator<Item = &'a serde_json::Value>>(iter: I) -> Self {
1216
Self {
1317
rows: jsonz::create_rows(iter),
1418
position: 0,
19+
viewport: Cell::default(),
1520
}
1621
}
1722
}
@@ -27,6 +32,14 @@ impl Document {
2732
self.rows.extract(self.position, n)
2833
}
2934

35+
pub(super) fn project_viewport(&self, n: usize) -> (usize, usize) {
36+
projection_viewport(&self.rows, self.position, n, &self.viewport)
37+
}
38+
39+
pub(super) fn extract_rows_from(&self, position: usize, n: usize) -> Vec<Row> {
40+
self.rows.extract(position, n)
41+
}
42+
3043
/// Returns all currently visible rows from the beginning of the document.
3144
pub fn visible_rows(&self) -> Vec<Row> {
3245
self.rows.extract(self.rows.head(), usize::MAX)
@@ -37,9 +50,8 @@ impl Document {
3750
self.line_numbers_from(self.rows.head(), usize::MAX)
3851
}
3952

40-
/// Returns stable one-based line numbers for rows projected from the cursor.
41-
pub(super) fn line_numbers_from_current(&self, n: usize) -> Vec<usize> {
42-
self.line_numbers_from(self.position, n)
53+
pub(super) fn line_numbers_from_viewport(&self, position: usize, n: usize) -> Vec<usize> {
54+
self.line_numbers_from(position, n)
4355
}
4456

4557
/// Returns the number of rows in the fully expanded document.
@@ -92,6 +104,14 @@ impl Document {
92104
row_index_at_visible_position(&self.rows, self.position, visible_offset)
93105
}
94106

107+
pub(super) fn row_index_at_viewport_position(&self, visible_position: usize) -> Option<usize> {
108+
let viewport = self.viewport.get();
109+
viewport
110+
.initialized
111+
.then(|| row_index_at_visible_position(&self.rows, viewport.start, visible_position))
112+
.flatten()
113+
}
114+
95115
/// Sets the visibility of all rows.
96116
pub fn set_nodes_visibility(&mut self, collapsed: bool) {
97117
self.rows.set_rows_visibility(collapsed);

‎promkit-widgets/src/structured/mod.rs‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub mod yaml;
1010
#[cfg_attr(docsrs, doc(cfg(feature = "tree")))]
1111
pub mod tree;
1212

13+
use std::cell::Cell;
14+
1315
use promkit_core::grapheme::StyledGraphemes;
1416

1517
fn with_line_numbers(
@@ -128,3 +130,107 @@ pub trait RowOperation {
128130
/// Extract up to `n` visible rows starting from `current`.
129131
fn extract(&self, current: usize, n: usize) -> Vec<Self::Row>;
130132
}
133+
134+
#[derive(Clone, Copy, Debug, Default)]
135+
pub(crate) struct ProjectionViewport {
136+
start: usize,
137+
cursor: usize,
138+
cursor_row: usize,
139+
height: usize,
140+
initialized: bool,
141+
}
142+
143+
pub(crate) fn projection_viewport<R>(
144+
rows: &Vec<R>,
145+
cursor: usize,
146+
height: usize,
147+
viewport: &Cell<ProjectionViewport>,
148+
) -> (usize, usize)
149+
where
150+
Vec<R>: RowOperation<Row = R>,
151+
{
152+
if rows.is_empty() || height == 0 {
153+
return (rows.head(), 0);
154+
}
155+
156+
let previous = viewport.get();
157+
let (start, cursor_row) = if !previous.initialized {
158+
place_cursor_from(rows, rows.head(), cursor, height)
159+
} else if previous.height != height {
160+
place_cursor_from(rows, previous.start, cursor, height)
161+
} else if cursor == previous.cursor {
162+
(previous.start, previous.cursor_row)
163+
} else if cursor > previous.cursor && is_next(rows, previous.cursor, cursor) {
164+
if previous.cursor_row + 1 < height {
165+
(previous.start, previous.cursor_row + 1)
166+
} else {
167+
(rows.down(previous.start), height - 1)
168+
}
169+
} else if cursor < previous.cursor && is_previous(rows, previous.cursor, cursor) {
170+
if previous.cursor_row > 0 {
171+
(previous.start, previous.cursor_row - 1)
172+
} else {
173+
(cursor, 0)
174+
}
175+
} else {
176+
place_cursor_from(rows, previous.start, cursor, height)
177+
};
178+
179+
viewport.set(ProjectionViewport {
180+
start,
181+
cursor,
182+
cursor_row,
183+
height,
184+
initialized: true,
185+
});
186+
(start, cursor_row)
187+
}
188+
189+
#[inline]
190+
fn is_next<R>(rows: &Vec<R>, previous: usize, cursor: usize) -> bool
191+
where
192+
Vec<R>: RowOperation<Row = R>,
193+
{
194+
cursor == previous.saturating_add(1) || rows.down(previous) == cursor
195+
}
196+
197+
#[inline]
198+
fn is_previous<R>(rows: &Vec<R>, previous: usize, cursor: usize) -> bool
199+
where
200+
Vec<R>: RowOperation<Row = R>,
201+
{
202+
cursor.saturating_add(1) == previous || rows.up(previous) == cursor
203+
}
204+
205+
fn place_cursor_from<R>(rows: &Vec<R>, start: usize, cursor: usize, height: usize) -> (usize, usize)
206+
where
207+
Vec<R>: RowOperation<Row = R>,
208+
{
209+
if cursor < start {
210+
return (cursor, 0);
211+
}
212+
213+
let mut position = start;
214+
for cursor_row in 0..height {
215+
if position == cursor {
216+
return (start, cursor_row);
217+
}
218+
let next = rows.down(position);
219+
if next == position {
220+
break;
221+
}
222+
position = next;
223+
}
224+
225+
let mut start = cursor;
226+
let mut cursor_row = 0;
227+
while cursor_row + 1 < height {
228+
let previous = rows.up(start);
229+
if previous == start {
230+
break;
231+
}
232+
start = previous;
233+
cursor_row += 1;
234+
}
235+
(start, cursor_row)
236+
}

0 commit comments

Comments
 (0)