Skip to content

Commit f84b1a6

Browse files
feat: Historical navigation within one characteristic (#11)
1 parent 00d0467 commit f84b1a6

File tree

9 files changed

+287
-66
lines changed

9 files changed

+287
-66
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"cSpell.words": [
3-
"Blendr"
3+
"Blendr",
4+
"Keydown"
45
]
56
}

src/route.rs

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use crate::{
66
use btleplug::api::Peripheral;
77
use std::{
88
ops::{Deref, DerefMut},
9-
sync::{atomic::AtomicU16, Arc, RwLock},
9+
sync::{
10+
atomic::{AtomicIsize, AtomicU16},
11+
Arc, RwLock,
12+
},
1013
time::Duration,
1114
};
1215
use tokio::time::{self, timeout};
@@ -19,6 +22,46 @@ pub struct CharacteristicValue {
1922
pub data: Vec<u8>,
2023
}
2124

25+
/// Atomic implementation for optional index
26+
#[derive(Debug)]
27+
pub struct AtomicOptionalIndex(AtomicIsize);
28+
29+
impl Default for AtomicOptionalIndex {
30+
fn default() -> Self {
31+
Self(AtomicIsize::new(-1))
32+
}
33+
}
34+
35+
impl AtomicOptionalIndex {
36+
pub fn read(&self) -> Option<usize> {
37+
let value = self.0.load(std::sync::atomic::Ordering::SeqCst);
38+
39+
if value < 0 {
40+
None
41+
} else {
42+
Some(value as usize)
43+
}
44+
}
45+
46+
pub fn write(&self, value: usize) {
47+
let new_value: isize = if let Ok(new_value) = value.try_into() {
48+
new_value
49+
} else {
50+
tracing::error!(
51+
"Failed to convert atomic optional index. Falling back to the isize max"
52+
);
53+
54+
isize::MAX
55+
};
56+
57+
self.0.store(new_value, std::sync::atomic::Ordering::SeqCst)
58+
}
59+
60+
pub fn annulate(&self) {
61+
self.0.store(-1, std::sync::atomic::Ordering::SeqCst)
62+
}
63+
}
64+
2265
#[derive(Debug, Clone)]
2366
pub enum Route {
2467
PeripheralList,
@@ -27,10 +70,14 @@ pub enum Route {
2770
peripheral: HandledPeripheral,
2871
retry: Arc<AtomicU16>,
2972
},
73+
// todo pull out into separate struct with default impl
3074
CharacteristicView {
3175
peripheral: ConnectedPeripheral,
3276
characteristic: ConnectedCharacteristic,
33-
value: Arc<RwLock<Option<CharacteristicValue>>>,
77+
/// If index is negative should show the latest mode,
78+
/// can't use option here cause it will require additional mutex lock around while we can use simple atomic here
79+
historical_view_index: Arc<AtomicOptionalIndex>,
80+
history: Arc<RwLock<Vec<CharacteristicValue>>>,
3481
},
3582
}
3683

@@ -74,15 +121,16 @@ impl Route {
74121
Route::CharacteristicView {
75122
peripheral,
76123
characteristic,
77-
value,
124+
history,
125+
..
78126
},
79127
) => loop {
80128
let ble_peripheral = &peripheral.peripheral.ble_peripheral;
81129
if let Ok(data) = ble_peripheral
82130
.read(&characteristic.ble_characteristic)
83131
.await
84132
{
85-
value.write().unwrap().replace(CharacteristicValue {
133+
history.write().unwrap().push(CharacteristicValue {
86134
time: chrono::Local::now(),
87135
data,
88136
});

src/tui/connection_view.rs

Lines changed: 144 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
use crate::{
2-
bluetooth::display_properties,
3-
route::Route,
2+
bluetooth::ConnectedCharacteristic,
3+
route::{CharacteristicValue, Route},
44
tui::{
5-
ui::{block, BlendrBlock},
6-
AppRoute,
5+
ui::{
6+
block::{self, Title},
7+
BlendrBlock,
8+
},
9+
AppRoute, HandleKeydownResult,
710
},
811
Ctx,
912
};
@@ -52,6 +55,54 @@ fn try_parse_numeric_value<T: ByteOrder>(
5255
})
5356
}
5457

58+
fn render_title_with_navigation_controls(
59+
area: &tui::layout::Rect,
60+
char: &ConnectedCharacteristic,
61+
historical_view_index: Option<usize>,
62+
history: &[CharacteristicValue],
63+
) -> Title<'static> {
64+
const PREVIOUS_BUTTON: &str = " [<- Previous] ";
65+
const PREVIOUS_BUTTON_DENSE: &str = " [<-] ";
66+
const NEXT_BUTTON: &str = " [Next ->] ";
67+
const NEXT_BUTTON_DENSE: &str = " [->] ";
68+
69+
let mut spans = vec![];
70+
let available_width = area.width - 2; // 2 chars for borders on the left and right
71+
let base_title = format!(
72+
"Characteristic {} / Service {}",
73+
char.char_name(),
74+
char.service_name()
75+
);
76+
77+
let previous_button_style = if history.len() < 2 || historical_view_index == Some(0) {
78+
Style::default().fg(Color::DarkGray)
79+
} else {
80+
Style::default()
81+
};
82+
83+
let next_button_style = if history.len() < 2 || historical_view_index.is_none() {
84+
Style::default().fg(Color::DarkGray)
85+
} else {
86+
Style::default()
87+
};
88+
89+
let not_enough_space = (available_width as i32).saturating_sub(
90+
PREVIOUS_BUTTON.len() as i32 + NEXT_BUTTON.len() as i32 + base_title.len() as i32,
91+
) < 0;
92+
93+
if not_enough_space {
94+
spans.push(Span::styled(PREVIOUS_BUTTON_DENSE, previous_button_style));
95+
spans.push(Span::raw(format!("Char. {}", char.char_name())));
96+
spans.push(Span::styled(NEXT_BUTTON_DENSE, next_button_style));
97+
} else {
98+
spans.push(Span::styled(PREVIOUS_BUTTON, previous_button_style));
99+
spans.push(Span::raw(base_title));
100+
spans.push(Span::styled(NEXT_BUTTON, next_button_style));
101+
}
102+
103+
Title::new(spans)
104+
}
105+
55106
impl AppRoute for ConnectionView {
56107
fn new(ctx: std::sync::Arc<crate::Ctx>) -> Self
57108
where
@@ -67,21 +118,63 @@ impl AppRoute for ConnectionView {
67118
}
68119
}
69120

70-
fn handle_input(&mut self, key: &crossterm::event::KeyEvent) {
121+
fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> HandleKeydownResult {
71122
match key.code {
72123
KeyCode::Char('f') => {
73124
self.float_numbers = !self.float_numbers;
74-
return;
125+
return HandleKeydownResult::Handled;
75126
}
76127
KeyCode::Char('u') => {
77128
self.unsigned_numbers = !self.unsigned_numbers;
78-
return;
129+
return HandleKeydownResult::Handled;
79130
}
80131
_ => (),
81132
}
82133

83134
let active_route = self.ctx.get_active_route();
84135

136+
if let Route::CharacteristicView {
137+
historical_view_index,
138+
history,
139+
..
140+
} = active_route.deref()
141+
{
142+
let update_index = |new_index| {
143+
historical_view_index.write(new_index);
144+
};
145+
146+
match (
147+
key.code,
148+
history.read().ok().as_ref(),
149+
historical_view_index.deref().read(),
150+
) {
151+
(KeyCode::Left, _, Some(current_historical_index)) => {
152+
if current_historical_index >= 1 {
153+
update_index(current_historical_index - 1);
154+
}
155+
}
156+
(KeyCode::Left, Some(history), None) => {
157+
update_index(history.len() - 1);
158+
}
159+
(KeyCode::Right, Some(history), Some(current_historical_index))
160+
if current_historical_index == history.len() - 2 =>
161+
{
162+
historical_view_index.annulate();
163+
}
164+
(KeyCode::Right, Some(history), Some(current_historical_index)) => {
165+
if history.len() > current_historical_index {
166+
update_index(current_historical_index + 1);
167+
}
168+
}
169+
_ => (),
170+
}
171+
172+
if matches!(key.code, KeyCode::Left | KeyCode::Right) {
173+
// on this view we always handing arrows as history navigation and preventing other view's actions
174+
return HandleKeydownResult::Handled;
175+
}
176+
}
177+
85178
match (active_route.deref(), self.clipboard.as_mut()) {
86179
(Route::CharacteristicView { characteristic, .. }, Some(clipboard)) => match key.code {
87180
KeyCode::Char('c') => {
@@ -96,6 +189,8 @@ impl AppRoute for ConnectionView {
96189
},
97190
_ => (),
98191
}
192+
193+
HandleKeydownResult::Continue
99194
}
100195

101196
fn render(
@@ -105,35 +200,47 @@ impl AppRoute for ConnectionView {
105200
f: &mut tui::Frame<super::TerminalBackend>,
106201
) -> crate::error::Result<()> {
107202
let active_route = self.ctx.active_route.read()?;
108-
let (_, characteristic, value) = if let Route::CharacteristicView {
109-
peripheral,
110-
characteristic,
111-
value,
112-
} = active_route.deref()
113-
{
114-
(peripheral, characteristic, value)
115-
} else {
116-
tracing::error!(
117-
"ConnectionView::render called when active route is not CharacteristicView"
118-
);
203+
let (_, characteristic, history, historical_view_index) =
204+
if let Route::CharacteristicView {
205+
peripheral,
206+
characteristic,
207+
history,
208+
historical_view_index,
209+
} = active_route.deref()
210+
{
211+
(peripheral, characteristic, history, historical_view_index)
212+
} else {
213+
tracing::error!(
214+
"ConnectionView::render called when active route is not CharacteristicView"
215+
);
119216

120-
return Ok(());
121-
};
217+
return Ok(());
218+
};
122219

123-
let mut text = vec![];
124-
text.push(Line::from(""));
220+
let history = history.read()?;
221+
let historical_index = historical_view_index.deref().read();
125222

126-
text.push(Line::from(format!(
127-
"Properties: {}",
128-
display_properties(characteristic.ble_characteristic.properties)
129-
)));
223+
let active_value = match historical_index {
224+
Some(index) => history.get(index),
225+
None => history.last(),
226+
};
130227

131-
if let Some(value) = value.read().unwrap().as_ref() {
228+
let mut text = vec![];
229+
if let Some(value) = active_value.as_ref() {
132230
text.push(Line::from(""));
133231

134232
text.push(Line::from(format!(
135-
"Last updated: {}",
136-
value.time.format("%Y-%m-%d %H:%M:%S")
233+
"{label}: {}",
234+
value.time.format("%Y-%m-%d %H:%M:%S"),
235+
label = if let Some(index) = historical_index {
236+
format!(
237+
"Historical data ({} of {}) viewing data of\n",
238+
index + 1,
239+
history.len()
240+
)
241+
} else {
242+
"Latest value received".to_owned()
243+
},
137244
)));
138245

139246
text.push(Line::from(""));
@@ -210,10 +317,12 @@ impl AppRoute for ConnectionView {
210317
.block(tui::widgets::Block::from(BlendrBlock {
211318
route_active,
212319
focused: route_active,
213-
title: format!(
214-
"Characteristic {} / Service {}",
215-
characteristic.char_name(),
216-
characteristic.service_name()
320+
title_alignment: tui::layout::Alignment::Center,
321+
title: render_title_with_navigation_controls(
322+
&area,
323+
characteristic,
324+
historical_index,
325+
&history,
217326
),
218327
..Default::default()
219328
}));
@@ -222,6 +331,8 @@ impl AppRoute for ConnectionView {
222331
if chunks[1].height > 0 {
223332
f.render_widget(
224333
block::render_help([
334+
Some(("<-", "Previous value", false)),
335+
Some(("->", "Next value", false)),
225336
Some(("d", "Disconnect from device", false)),
226337
Some(("u", "Parse numeric as unsigned", self.unsigned_numbers)),
227338
Some(("f", "Parse numeric as floats", self.float_numbers)),

src/tui/error_popup.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::{
22
error,
3-
tui::{ui::BlendrBlock, AppRoute, TerminalBackend},
3+
tui::{ui::BlendrBlock, AppRoute, HandleKeydownResult, TerminalBackend},
44
Ctx,
55
};
66
use crossterm::event::KeyCode;
@@ -51,18 +51,19 @@ impl AppRoute for ErrorView {
5151
ErrorView { ctx }
5252
}
5353

54-
fn handle_input(&mut self, key: &crossterm::event::KeyEvent) {
54+
fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> HandleKeydownResult {
5555
// unwrap here because we are already in error state and if can not get out of it – it means a super serious race condition
5656
let mut global_error_lock = self.ctx.global_error.lock().unwrap();
5757
if global_error_lock.deref().is_none() {
58-
return;
58+
return HandleKeydownResult::Errored;
5959
}
6060

6161
match key.code {
6262
KeyCode::Esc | KeyCode::Enter | KeyCode::Tab | KeyCode::Char(' ') => {
63-
*global_error_lock.deref_mut() = None
63+
*global_error_lock.deref_mut() = None;
64+
HandleKeydownResult::Handled
6465
}
65-
_ => {}
66+
_ => HandleKeydownResult::Continue,
6667
}
6768
}
6869

@@ -88,6 +89,7 @@ impl AppRoute for ErrorView {
8889
route_active: true,
8990
title: "Error",
9091
color: Some(tui::style::Color::Red),
92+
..Default::default()
9193
}),
9294
);
9395

0 commit comments

Comments
 (0)