blob: f9968fc426dce2737870925453db21d5d9c9da98 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use {
crate::key_util::get_input_sequence_for_key_event,
crate::pty::Pty,
crate::ui::{PointerEventResponse, ScrollContext, TerminalScene},
anyhow::{Context as _, Error},
carnelian::{
color::Color,
input::{self},
render::Context as RenderContext,
AppContext, Message, Size, ViewAssistant, ViewAssistantContext, ViewKey,
},
fidl_fuchsia_hardware_pty::WindowSize,
fuchsia_async as fasync, fuchsia_trace as ftrace,
fuchsia_zircon::{AsHandleRef, Signals},
futures::{channel::mpsc, io::AsyncReadExt, select, FutureExt, StreamExt},
log::error,
std::{cell::RefCell, convert::TryFrom, ffi::CStr, fs::File, io::prelude::*, rc::Rc},
term_model::{
ansi::Processor,
clipboard::Clipboard,
config::Config,
event::{Event, EventListener},
grid::Scroll,
index::{Column, Line, Point},
term::SizeInfo,
Term,
},
};
#[cfg(test)]
use cstr::cstr;
const BYTE_BUFFER_MAX_SIZE: usize = 128;
struct ResizeEvent {
window_size: WindowSize,
}
#[derive(Clone)]
struct AppContextWrapper {
app_context: Option<AppContext>,
test_sender: Option<mpsc::UnboundedSender<Message>>,
}
impl AppContextWrapper {
fn request_render(&self, target: ViewKey) {
if let Some(app_context) = &self.app_context {
app_context.request_render(target);
} else if let Some(sender) = &self.test_sender {
sender
.unbounded_send(Box::new("request_render"))
.expect("Unable queue message to test_sender");
}
}
#[cfg(test)]
// Allows tests to observe what is sent to the app_context.
fn use_test_sender(&mut self, sender: mpsc::UnboundedSender<Message>) {
self.app_context = None;
self.test_sender = Some(sender);
}
}
struct PtyContext {
resize_sender: mpsc::UnboundedSender<ResizeEvent>,
file: File,
resize_receiver: Option<mpsc::UnboundedReceiver<ResizeEvent>>,
test_buffer: Option<Vec<u8>>,
}
impl PtyContext {
fn from_pty(pty: &Pty) -> Result<PtyContext, Error> {
let (resize_sender, resize_receiver) = mpsc::unbounded();
let file = pty.try_clone_fd()?;
Ok(PtyContext {
resize_sender,
file,
resize_receiver: Some(resize_receiver),
test_buffer: None,
})
}
fn take_resize_receiver(&mut self) -> mpsc::UnboundedReceiver<ResizeEvent> {
self.resize_receiver.take().expect("attempting to take resize receiver")
}
#[cfg(test)]
fn allow_dual_write_for_test(&mut self) {
self.test_buffer = Some(vec![]);
}
}
impl Write for PtyContext {
fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
if let Some(test_buffer) = self.test_buffer.as_mut() {
for b in buf {
test_buffer.push(*b);
}
}
self.file.write(buf)
}
fn flush(&mut self) -> Result<(), std::io::Error> {
self.file.flush()
}
}
struct EventProxy {
app_context: AppContextWrapper,
view_key: ViewKey,
}
impl EventListener for EventProxy {
fn send_event(&self, event: Event) {
match event {
Event::MouseCursorDirty => {
self.app_context.request_render(self.view_key);
}
_ => (),
}
}
}
/// Empty type for term model config
struct UIConfig;
impl Default for UIConfig {
fn default() -> UIConfig {
UIConfig
}
}
type TerminalConfig = Config<UIConfig>;
trait PointerEventResponseHandler {
/// Signals that the struct should queue a view update.
fn update_view(&mut self);
fn scroll_term(&mut self, scroll: Scroll);
}
struct PointerEventResponseHandlerImpl<'a> {
ctx: &'a mut ViewAssistantContext,
term: Rc<RefCell<Term<EventProxy>>>,
}
impl PointerEventResponseHandler for PointerEventResponseHandlerImpl<'_> {
fn update_view(&mut self) {
self.ctx.request_render();
}
fn scroll_term(&mut self, scroll: Scroll) {
let mut term = self.term.borrow_mut();
term.scroll_display(scroll);
}
}
pub struct TerminalViewAssistant {
last_known_size: Size,
last_known_size_info: SizeInfo,
pty_context: Option<PtyContext>,
terminal_scene: TerminalScene,
term: Rc<RefCell<Term<EventProxy>>>,
app_context: AppContextWrapper,
view_key: ViewKey,
/// If set, will use this command when spawning the pty, this is useful for tests.
spawn_command: Option<&'static CStr>,
}
impl TerminalViewAssistant {
/// Creates a new instance of the TerminalViewAssistant.
pub fn new(app_context: &AppContext, view_key: ViewKey) -> TerminalViewAssistant {
let cell_size = Size::new(12.0, 22.0);
let size_info = SizeInfo {
// set the initial size/width to be that of the cell size which prevents
// the term from panicing if a byte is received before a resize event.
width: cell_size.width,
height: cell_size.height,
cell_width: cell_size.width,
cell_height: cell_size.height,
padding_x: 0.0,
padding_y: 0.0,
dpr: 1.0,
};
let app_context =
AppContextWrapper { app_context: Some(app_context.clone()), test_sender: None };
let event_proxy = EventProxy { app_context: app_context.clone(), view_key };
let term = Term::new(&TerminalConfig::default(), &size_info, Clipboard::new(), event_proxy);
TerminalViewAssistant {
last_known_size: Size::zero(),
last_known_size_info: size_info,
pty_context: None,
term: Rc::new(RefCell::new(term)),
terminal_scene: TerminalScene::new(Color::new()),
app_context,
view_key,
spawn_command: None,
}
}
#[cfg(test)]
pub fn new_for_test() -> TerminalViewAssistant {
let app_context = AppContext::new_for_testing_purposes_only();
let mut view = Self::new(&app_context, 1);
view.spawn_command = Some(cstr!("/pkg/bin/sh"));
view
}
/// Checks if we need to perform a resize based on a new size.
/// This method rounds pixels down to the next pixel value.
fn needs_resize(prev_size: &Size, new_size: &Size) -> bool {
prev_size.floor().not_equal(new_size.floor()).any()
}
/// Checks to see if the size of terminal has changed and resizes if it has.
fn resize_if_needed(&mut self, new_size: &Size, metrics: &Size) -> Result<(), Error> {
// The shell works on logical size units but the views operate based on the size
if TerminalViewAssistant::needs_resize(&self.last_known_size, new_size) {
let floored_size = new_size.floor();
let term_size = TerminalScene::calculate_term_size_from_size(&floored_size);
// we can safely call borrow_mut here because we are running the terminal
// in single threaded mode. If we do move to a multithreaded model we will
// get a compiler error since we are using spawn_local in our pty_loop.
let mut term = self.term.borrow_mut();
let last_size_info = self.last_known_size_info.clone();
let cell_width = last_size_info.cell_width;
let cell_height = last_size_info.cell_height;
let padding_x = last_size_info.padding_x;
let padding_y = last_size_info.padding_y;
let dpr = metrics.width.min(metrics.height) as f64;
let term_size_info = SizeInfo {
width: term_size.width,
height: term_size.height,
cell_width,
cell_height,
padding_x,
padding_y,
dpr,
};
term.resize(&term_size_info);
drop(term);
let window_size =
WindowSize { width: new_size.width as u32, height: new_size.height as u32 };
self.queue_resize_event(ResizeEvent { window_size })
.context("unable to queue outgoing pty message")?;
self.last_known_size = floored_size;
self.last_known_size_info = term_size_info;
self.terminal_scene.update_size(floored_size, Size::new(cell_width, cell_height));
}
Ok(())
}
/// Checks to see if the Pty has been spawned and if not it does so.
fn spawn_pty_loop(&mut self) -> Result<(), Error> {
if self.pty_context.is_some() {
return Ok(());
}
let mut pty = Pty::new()?;
let mut pty_context = PtyContext::from_pty(&pty)?;
let mut resize_receiver = pty_context.take_resize_receiver();
let app_context = self.app_context.clone();
let view_key = self.view_key;
let term_clone = self.term.clone();
let spawn_command = self.spawn_command.clone();
// We want spawn_local here to enforce the single threaded model. If we
// do move to multithreaded we will need to refactor the term parsing
// logic to account for thread safaty.
fasync::Task::local(async move {
pty.spawn(spawn_command).await.expect("unable to spawn pty");
let fd = pty.try_clone_fd().expect("unable to clone pty read fd");
let mut evented_fd = unsafe {
// EventedFd::new() is unsafe because it can't guarantee the lifetime of
// the file descriptor passed to it exceeds the lifetime of the EventedFd.
// Since we're cloning the file when passing it in, the EventedFd
// effectively owns that file descriptor and thus controls it's lifetime.
fasync::net::EventedFd::new(fd).expect("failed to create evented_fd for io_loop")
};
let mut write_fd = pty.try_clone_fd().expect("unable to clone pty write fd");
let mut parser = Processor::new();
let mut read_buf = [0u8; BYTE_BUFFER_MAX_SIZE];
loop {
let mut read_fut = evented_fd.read(&mut read_buf).fuse();
select!(
result = read_fut => {
let read_count = result.unwrap_or_else(|e: std::io::Error| {
error!(
"failed to read bytes from io_loop, dropping current message: {:?}",
e
);
0
});
ftrace::duration!("terminal", "parse_bytes", "len" => read_count as u32);
let mut term = term_clone.borrow_mut();
if read_count > 0 {
for byte in &read_buf[0..read_count] {
parser.advance(&mut *term, *byte, &mut write_fd);
}
app_context.request_render(view_key);
}
},
result = resize_receiver.next().fuse() => {
if let Some(event) = result {
pty.resize(event.window_size).await.unwrap_or_else(|e: anyhow::Error| {
error!("failed to send resize message to pty: {:?}", e)
});
app_context.request_render(view_key);
}
}
);
if !pty.is_shell_process_running() {
break;
}
}
// TODO(fxb/60181): Exit by using Carnelian, when implemented.
std::process::exit(
match pty.shell_process_info().map(|info| i32::try_from(info.return_code)) {
Some(Ok(return_code)) => return_code,
_ => {
error!("failed to obtain the shell process return code");
1
}
},
);
})
.detach();
self.pty_context = Some(pty_context);
Ok(())
}
fn queue_resize_event(&mut self, event: ResizeEvent) -> Result<(), Error> {
if let Some(pty_context) = &mut self.pty_context {
pty_context.resize_sender.unbounded_send(event).context("Unable send resize event")?;
}
Ok(())
}
// This method is overloaded from the ViewAssistant trait so we can test the method.
// The ViewAssistant trait requires a ViewAssistantContext which we do not use and
// we cannot make. This allows us to call the method directly in the tests.
fn handle_keyboard_event_internal(
&mut self,
event: &input::keyboard::Event,
) -> Result<(), Error> {
if let Some(string) = get_input_sequence_for_key_event(event) {
// In practice these writes will contain a small amount of data
// so we can use a synchronous write. If that proves to not be the
// case we will need to refactor to have buffered writing.
if let Some(pty_context) = &mut self.pty_context {
pty_context
.write_all(string.as_bytes())
.unwrap_or_else(|e| println!("failed to write character to pty: {}", e));
}
}
Ok(())
}
/// Handles the pointer event response.
fn handle_pointer_event_response<'a, T: PointerEventResponseHandler>(
&mut self,
response: PointerEventResponse,
handler: &'a mut T,
) {
match response {
PointerEventResponse::ScrollLines(lines) => {
handler.scroll_term(Scroll::Lines(lines));
}
PointerEventResponse::ViewDirty => handler.update_view(),
}
}
}
impl ViewAssistant for TerminalViewAssistant {
fn setup(&mut self, _context: &ViewAssistantContext) -> Result<(), Error> {
Ok(())
}
fn render(
&mut self,
render_context: &mut RenderContext,
ready_event: fuchsia_zircon::Event,
context: &ViewAssistantContext,
) -> Result<(), Error> {
ftrace::duration!("terminal", "TerminalViewAssistant:render");
// we need to call spawn in this update block because calling it in the
// setup method causes us to receive write events before the view is
// prepared to draw.
self.spawn_pty_loop()?;
self.resize_if_needed(&context.size, &context.metrics)?;
// Tell the termnial scene to render the values
let config = TerminalConfig::default();
let term = self.term.borrow();
let iter = {
ftrace::duration!("terminal", "TerminalViewAssistant:update:renderable_cells");
term.renderable_cells(&config)
};
let grid = term.grid();
let scroll_context = ScrollContext {
history: grid.history_size(),
visible_lines: *grid.num_lines(),
display_offset: grid.display_offset(),
};
self.terminal_scene.update_scroll_context(scroll_context);
// Write the grid to inspect for e2e testing. The contents will trim all whitespace
// from either end of the string which means that the trailing space after the prompt
// will not be included.
let bottom = grid.display_offset();
let top = bottom + *grid.num_lines() - 1;
let txt = term.bounds_to_string(
Point::new(*Line(top), Column(0)),
Point::new(*Line(bottom), grid.num_cols()),
);
fuchsia_inspect::component::inspector().root().record_string("grid", txt.trim());
drop(grid);
self.terminal_scene.render(render_context, context, iter);
ready_event.as_handle_ref().signal(Signals::NONE, Signals::EVENT_SIGNALED)?;
Ok(())
}
fn handle_keyboard_event(
&mut self,
_context: &mut ViewAssistantContext,
_event: &input::Event,
keyboard_event: &input::keyboard::Event,
) -> Result<(), Error> {
self.handle_keyboard_event_internal(keyboard_event)?;
Ok(())
}
fn handle_pointer_event(
&mut self,
ctx: &mut ViewAssistantContext,
_event: &input::Event,
pointer_event: &input::pointer::Event,
) -> Result<(), Error> {
if let Some(response) = self.terminal_scene.handle_pointer_event(&pointer_event, ctx) {
let mut handler = PointerEventResponseHandlerImpl { ctx, term: self.term.clone() };
self.handle_pointer_event_response(response, &mut handler);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use {
super::*,
anyhow::anyhow,
fuchsia_async::{DurationExt, Timer},
fuchsia_zircon::DurationNum,
futures::future::Either,
term_model::grid::Scroll,
};
fn unit_metrics() -> Size {
Size::new(1.0, 1.0)
}
#[test]
fn can_create_view() {
let _ = TerminalViewAssistant::new_for_test();
}
#[fasync::run_singlethreaded(test)]
async fn handle_pointer_event_response_updates_view_for_view_dirty() -> Result<(), Error> {
let mut view = TerminalViewAssistant::new_for_test();
let mut handler = TestPointerEventResponder::new();
view.handle_pointer_event_response(PointerEventResponse::ViewDirty, &mut handler);
assert_eq!(handler.update_count, 1);
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn handle_pointer_event_response_does_not_update_view_for_scroll_lines(
) -> Result<(), Error> {
let mut view = TerminalViewAssistant::new_for_test();
let mut handler = TestPointerEventResponder::new();
view.handle_pointer_event_response(PointerEventResponse::ScrollLines(1), &mut handler);
assert_eq!(handler.update_count, 0);
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn handle_pointer_event_response_scroll_lines_updates_grid() -> Result<(), Error> {
let mut view = TerminalViewAssistant::new_for_test();
let mut handler = TestPointerEventResponder::new();
view.handle_pointer_event_response(PointerEventResponse::ScrollLines(1), &mut handler);
assert_eq!(handler.scroll_offset, 1);
Ok(())
}
#[test]
fn needs_resize_false_for_zero_sizes() {
let zero = Size::zero();
assert_eq!(TerminalViewAssistant::needs_resize(&zero, &zero), false);
}
#[test]
fn needs_resize_true_for_different_sizes() {
let prev_size = Size::zero();
let new_size = Size::new(100.0, 100.0);
assert!(TerminalViewAssistant::needs_resize(&prev_size, &new_size));
}
#[test]
fn needs_resize_true_different_width_same_height() {
let prev_size = Size::new(100.0, 10.0);
let new_size = Size::new(100.0, 100.0);
assert!(TerminalViewAssistant::needs_resize(&prev_size, &new_size));
}
#[test]
fn needs_resize_true_different_height_same_width() {
let prev_size = Size::new(10.0, 100.0);
let new_size = Size::new(100.0, 100.0);
assert!(TerminalViewAssistant::needs_resize(&prev_size, &new_size));
}
#[test]
fn needs_resize_false_when_rounding_down() {
let prev_size = Size::new(100.0, 100.0);
let new_size = Size::new(100.1, 100.0);
assert_eq!(TerminalViewAssistant::needs_resize(&prev_size, &new_size), false);
}
#[test]
fn term_is_resized_when_needed() {
let mut view = TerminalViewAssistant::new_for_test();
let new_size = Size::new(100.5, 100.9);
view.resize_if_needed(&new_size, &unit_metrics()).expect("call to resize failed");
let size_info = view.last_known_size_info.clone();
let expected_size = TerminalScene::calculate_term_size_from_size(&view.last_known_size);
// we want to make sure that the values are floored and that they
// match what the scene will render the terminal as.
assert_eq!(size_info.width, expected_size.width);
assert_eq!(size_info.height, expected_size.height);
}
#[test]
fn last_known_size_is_floored_on_resize() {
let mut view = TerminalViewAssistant::new_for_test();
let new_size = Size::new(100.3, 100.4);
view.resize_if_needed(&new_size, &unit_metrics()).expect("call to resize failed");
assert_eq!(view.last_known_size.width, 100.0);
assert_eq!(view.last_known_size.height, 100.0);
}
#[fasync::run_singlethreaded(test)]
async fn event_proxy_calls_view_update_on_dirty_mouse_cursor() -> Result<(), Error> {
let (sender, mut receiver) = mpsc::unbounded();
let app_context = AppContextWrapper { app_context: None, test_sender: Some(sender) };
let event_proxy = EventProxy { app_context, view_key: 0 };
event_proxy.send_event(Event::MouseCursorDirty);
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":event_proxy_calls_view_update_on_dirty_mouse_cursor failed to get update")?;
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn scroll_display_triggers_call_to_redraw() -> Result<(), Error> {
let (view, mut receiver) = make_test_view_with_spawned_pty_loop().await?;
let event_proxy =
EventProxy { app_context: view.app_context.clone(), view_key: view.view_key };
let mut term = Term::new(
&TerminalConfig::default(),
&view.last_known_size_info,
Clipboard::new(),
event_proxy,
);
term.scroll_display(Scroll::Lines(1));
// No redraw will trigger a timeout and failure
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":resize_message_triggers_call_to_redraw after queue event")?;
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn resize_message_queued_with_logical_size_when_resize_needed() -> Result<(), Error> {
let pty = Pty::new()?;
let mut pty_context = PtyContext::from_pty(&pty)?;
let mut view = TerminalViewAssistant::new_for_test();
let mut receiver = pty_context.take_resize_receiver();
view.pty_context = Some(pty_context);
view.resize_if_needed(&Size::new(1000.0, 2000.0), &unit_metrics())
.expect("call to resize failed");
let event = receiver.next().await.expect("failed to receive pty event");
assert_eq!(event.window_size.width, 1000);
assert_eq!(event.window_size.height, 2000);
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn handle_keyboard_event_writes_characters() -> Result<(), Error> {
let pty = Pty::new()?;
let mut pty_context = PtyContext::from_pty(&pty)?;
let mut view = TerminalViewAssistant::new_for_test();
pty_context.allow_dual_write_for_test();
view.pty_context = Some(pty_context);
let capital_a = 65;
view.handle_keyboard_event_internal(&make_keyboard_event(capital_a))?;
let test_buffer = view.pty_context.as_mut().unwrap().test_buffer.take().unwrap();
assert_eq!(test_buffer, b"A");
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn pty_is_spawned_on_first_request() -> Result<(), Error> {
let mut view = TerminalViewAssistant::new_for_test();
view.spawn_pty_loop()?;
assert!(view.pty_context.is_some());
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn pty_message_reads_trigger_call_to_redraw() -> Result<(), Error> {
let (view, mut receiver) = make_test_view_with_spawned_pty_loop().await?;
let mut fd = view
.pty_context
.as_ref()
.map(|ctx| ctx.file.try_clone().expect("attempt to clone fd failed"))
.unwrap();
fasync::Task::local(async move {
let _ = fd.write_all(b"ls");
})
.detach();
// No redraw will trigger a timeout and failure
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":pty_message_reads_trigger_call_to_redraw after write")?;
Ok(())
}
#[fasync::run_singlethreaded(test)]
async fn resize_message_triggers_call_to_redraw() -> Result<(), Error> {
let (mut view, mut receiver) = make_test_view_with_spawned_pty_loop().await?;
let window_size = WindowSize { width: 123, height: 123 };
view.queue_resize_event(ResizeEvent { window_size })
.context("unable to queue outgoing pty message")?;
// No redraw will trigger a timeout and failure
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":resize_message_triggers_call_to_redraw after queue event")?;
Ok(())
}
#[fasync::run_singlethreaded(test)]
#[ignore] // TODO(fxbug.dev/52560) re-enable this test when de-flaked
async fn bytes_written_are_processed_by_term() -> Result<(), Error> {
let (mut view, mut receiver) = make_test_view_with_spawned_pty_loop().await?;
// make sure we have a big enough size that a single character does not wrap
let large_size = Size::new(1000.0, 1000.0);
view.resize_if_needed(&large_size, &unit_metrics())?;
// Resizing will cause an update so we need to wait for that before we write.
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":bytes_written_are_processed_by_term after resize_if_needed")?;
let term = view.term.borrow();
let col_pos_before = term.cursor().point.col;
drop(term);
let mut fd = view
.pty_context
.as_ref()
.map(|ctx| ctx.file.try_clone().expect("attempt to clone fd failed"))
.unwrap();
fasync::Task::local(async move {
let _ = fd.write_all(b"A");
})
.detach();
// Wait until we get a notice that the view is ready to redraw
wait_until_update_received_or_timeout(&mut receiver)
.await
.context(":bytes_written_are_processed_by_term after write")?;
let term = view.term.borrow();
let col_pos_after = term.cursor().point.col;
assert_eq!(col_pos_before + 1, col_pos_after);
Ok(())
}
async fn make_test_view_with_spawned_pty_loop(
) -> Result<(TerminalViewAssistant, mpsc::UnboundedReceiver<Message>), Error> {
let (sender, mut receiver) = mpsc::unbounded();
let mut view = TerminalViewAssistant::new_for_test();
view.app_context.use_test_sender(sender);
let _ = view.spawn_pty_loop();
// Spawning the loop triggers a read and a redraw, we want to skip this
// so that we can check that our test event triggers the redraw.
wait_until_update_received_or_timeout(&mut receiver)
.await
.context("::make_test_view_with_spawned_pty_loop")?;
Ok((view, receiver))
}
async fn wait_until_update_received_or_timeout(
receiver: &mut mpsc::UnboundedReceiver<Message>,
) -> Result<(), Error> {
loop {
let timeout = Timer::new(5000_i64.millis().after_now());
let either = futures::future::select(timeout, receiver.next().fuse());
let resolved = either.await;
match resolved {
Either::Left(_) => {
return Err(anyhow!("wait_until_update_received timed out"));
}
Either::Right((result, _)) => {
let _ = result.expect("result should not be None");
break;
}
}
}
Ok(())
}
fn make_keyboard_event(code_point: u32) -> input::keyboard::Event {
input::keyboard::Event {
code_point: Some(code_point),
phase: input::keyboard::Phase::Pressed,
hid_usage: 0 as u32,
modifiers: input::Modifiers::default(),
}
}
struct TestPointerEventResponder {
update_count: usize,
scroll_offset: isize,
}
impl TestPointerEventResponder {
fn new() -> TestPointerEventResponder {
TestPointerEventResponder { update_count: 0, scroll_offset: 0 }
}
}
impl PointerEventResponseHandler for TestPointerEventResponder {
fn update_view(&mut self) {
self.update_count += 1;
}
fn scroll_term(&mut self, scroll: Scroll) {
match scroll {
Scroll::Lines(lines) => self.scroll_offset += lines,
_ => (),
}
}
}
}