mirror of
https://github.com/enso-org/enso.git
synced 2024-11-23 08:08:34 +03:00
GridView selection keyboard navigation. (#3657)
Add support for moving the selection in a Grid View using the keyboard. https://www.pivotaltracker.com/story/show/182585789 #### Visuals See below for videos showcasing GridView selection keyboard navigation in the `grid_view` debug scene. In the videos, messages in the Developer Console can be observed. When a keypress would result in the selection being moved out of the GridView, the selection is not moved and a message is emitted in the Developer Console instead, showcasing an FRP output signal emitted on such event. Please note that the videos are recorded with the tracing level changed to `DEBUG`. In a default build, the tracing level is set to `WARN`, and the messages visible in the videos are not displayed in the Developer Console. https://user-images.githubusercontent.com/273837/188483972-89d79f7b-1303-457b-869f-282e0809a755.mov https://user-images.githubusercontent.com/273837/188484294-e9b6461c-a84f-4817-9447-d792f2ebdbb5.mov The following video shows moving the selection between "regular" entries and header entries. It also shows a current usability limitation of the selection keyboard navigation feature, such that the Grid is not scrolled when the selection leaves the visible part of the Grid, and the selection may thus disappear from view. https://user-images.githubusercontent.com/273837/188485238-29a82b27-de2f-4cf8-a2e7-ff8c3f41478d.mov # Important Notes - Keyboard navigation only works when a GridView has focus. - Selection keyboard navigation only works if the selection was already set to some entry beforehand. - If keyboard navigation would move selection outside of the grid, the selection movement is canceled and an FRP event is emitted.
This commit is contained in:
parent
77bcb87f7c
commit
5cd94d0126
@ -360,7 +360,7 @@ pub type GridView<Entry, HeaderEntry> = GridViewTemplate<
|
||||
impl<E: Entry, HeaderEntry: Entry<Params = E::Params>> GridView<E, HeaderEntry> {
|
||||
/// Create new Grid View with headers.
|
||||
pub fn new(app: &Application) -> Self {
|
||||
let grid = crate::GridView::<E>::new(app);
|
||||
let grid = app.new_view::<crate::GridView<E>>();
|
||||
Self::new_wrapping(grid)
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,11 @@
|
||||
//! highlight or headers) are available through accessors (like `header_frp` or
|
||||
//! `selection_highlight_frp`). Also, as every variant is based on the basic [`GridView`], they
|
||||
//! implement `AsRef<GridView>`.
|
||||
//!
|
||||
//! Locations in a grid are described by coordinates of [`Row`] and [`Col`] types. Correct behavior
|
||||
//! of a [`GridView`] is not guaranteed for grid sizes or locations where any of the coordinates
|
||||
//! equal or exceed 10^7. That is due to loss of precision when converting such numbers to the
|
||||
//! `f32` type used for internal calculations.
|
||||
|
||||
#![recursion_limit = "1024"]
|
||||
// === Features ===
|
||||
@ -64,6 +69,7 @@ pub mod prelude {
|
||||
use crate::prelude::*;
|
||||
|
||||
use enso_frp as frp;
|
||||
use ensogl_core::application;
|
||||
use ensogl_core::application::command::FrpNetworkProvider;
|
||||
use ensogl_core::application::Application;
|
||||
use ensogl_core::display;
|
||||
@ -120,34 +126,42 @@ impl Properties {
|
||||
ensogl_core::define_endpoints_2! {
|
||||
<EntryModel: (frp::node::Data), EntryParams: (frp::node::Data)>
|
||||
Input {
|
||||
/// Declare what area of the GridView is visible. The area position is relative to left-top
|
||||
/// corner of the Grid View.
|
||||
set_viewport(Viewport),
|
||||
accept_entry(Row, Col),
|
||||
hover_entry(Option<(Row, Col)>),
|
||||
/// Provide model for specific entry. Should be called only after `model_for_entry_needed`
|
||||
/// event for given row and column. After that the entry will be visible.
|
||||
model_for_entry(Row, Col, EntryModel),
|
||||
/// Move selection one position down.
|
||||
move_selection_down(),
|
||||
/// Move selection one position to the left.
|
||||
move_selection_left(),
|
||||
/// Move selection one position to the right.
|
||||
move_selection_right(),
|
||||
/// Move selection one position up.
|
||||
move_selection_up(),
|
||||
/// Emit `model_for_entry_needed` signal for each visible entry. In contrary to
|
||||
/// [`reset_entries`], it does not detach any entry.
|
||||
request_model_for_visible_entries(),
|
||||
/// Reset entries, providing new number of rows and columns. All currently displayed entries
|
||||
/// will be detached and their models re-requested.
|
||||
reset_entries(Row, Col),
|
||||
/// Set new size of the grid. If the number of rows or columns is reduced, the entries are
|
||||
/// removed from the view. If it is extended, new model for entries may be requested if
|
||||
/// needed.
|
||||
resize_grid(Row, Col),
|
||||
/// Reset entries, providing new number of rows and columns. All currently displayed entries
|
||||
/// will be detached and their models re-requested.
|
||||
reset_entries(Row, Col),
|
||||
/// Provide model for specific entry. Should be called only after `model_for_entry_needed`
|
||||
/// event for given row and column. After that the entry will be visible.
|
||||
model_for_entry(Row, Col, EntryModel),
|
||||
/// Emit `model_for_entry_needed` signal for each visible entry. In contrary to
|
||||
/// [`reset_entries`], it does not detach any entry.
|
||||
request_model_for_visible_entries(),
|
||||
/// Set the entries size. All entries have the same size.
|
||||
set_entries_size(Vector2),
|
||||
select_entry(Option<(Row, Col)>),
|
||||
/// Set the width of the specified column.
|
||||
set_column_width((Col, f32)),
|
||||
/// Set the entries parameters.
|
||||
set_entries_params(EntryParams),
|
||||
/// Set the entries size. All entries have the same size.
|
||||
set_entries_size(Vector2),
|
||||
/// Set the layer for any texts rendered by entries. The layer will be passed to entries'
|
||||
/// constructors. **Performance note**: This will re-instantiate all entries.
|
||||
set_text_layer(Option<WeakLayer>),
|
||||
select_entry(Option<(Row, Col)>),
|
||||
hover_entry(Option<(Row, Col)>),
|
||||
accept_entry(Row, Col),
|
||||
/// Declare what area of the GridView is visible. The area position is relative to left-top
|
||||
/// corner of the Grid View.
|
||||
set_viewport(Viewport),
|
||||
}
|
||||
|
||||
Output {
|
||||
@ -165,6 +179,9 @@ ensogl_core::define_endpoints_2! {
|
||||
entry_selected(Option<(Row, Col)>),
|
||||
entry_accepted(Row, Col),
|
||||
column_resized(Col, f32),
|
||||
/// Event emitted after a request was made to move the selection in a direction, but the
|
||||
/// currently selected entry is the last one in the grid in that direction.
|
||||
selection_movement_out_of_grid_prevented(Option<frp::io::keyboard::ArrowDirection>),
|
||||
}
|
||||
}
|
||||
|
||||
@ -564,6 +581,39 @@ impl<Entry, EntryModel: frp::node::Data, EntryParams: frp::node::Data> display::
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Entry> FrpNetworkProvider for GridView<E> {
|
||||
fn network(&self) -> &frp::Network {
|
||||
self.widget.network()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Entry> application::View for GridView<E> {
|
||||
fn label() -> &'static str {
|
||||
"GridView"
|
||||
}
|
||||
|
||||
fn new(app: &Application) -> Self {
|
||||
GridView::<E>::new(app)
|
||||
}
|
||||
|
||||
fn app(&self) -> &Application {
|
||||
self.widget.app()
|
||||
}
|
||||
|
||||
fn default_shortcuts() -> Vec<application::shortcut::Shortcut> {
|
||||
use application::shortcut::ActionType::*;
|
||||
(&[
|
||||
(PressAndRepeat, "up", "move_selection_up"),
|
||||
(PressAndRepeat, "down", "move_selection_down"),
|
||||
(PressAndRepeat, "left", "move_selection_left"),
|
||||
(PressAndRepeat, "right", "move_selection_right"),
|
||||
])
|
||||
.iter()
|
||||
.map(|(a, b, c)| Self::self_shortcut_when(*a, *b, *c, "focused"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============
|
||||
|
@ -3,7 +3,9 @@
|
||||
use crate::prelude::*;
|
||||
|
||||
use crate::header;
|
||||
use crate::Col;
|
||||
use crate::Entry;
|
||||
use crate::Row;
|
||||
|
||||
use ensogl_core::application::Application;
|
||||
use ensogl_core::display;
|
||||
@ -17,6 +19,69 @@ pub mod highlight;
|
||||
|
||||
|
||||
|
||||
// ======================
|
||||
// === MovementTarget ===
|
||||
// ======================
|
||||
|
||||
/// An internal structure describing where selection would go after being moved (i.e.
|
||||
/// after navigating with arrows). If moving the selection would put it outside the grid, the
|
||||
/// [`OutOfBounds`] variant contains the direction in which the grid boundary would be crossed.
|
||||
/// Otherwise, the [`Location`] variant contains row and column in the grid.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum MovementTarget {
|
||||
Location { row: Row, col: Col },
|
||||
OutOfBounds(frp::io::keyboard::ArrowDirection),
|
||||
}
|
||||
|
||||
impl MovementTarget {
|
||||
/// Calculate row and column of the nearest entry in given direction from given row and col.
|
||||
/// Returns a [`MovementTarget::Location`] if the entry is in bounds of a grid with given
|
||||
/// amount of rows and columns. Returns [`MovementTarget::OutOfBounds`] otherwise.
|
||||
fn next_in_direction(
|
||||
row: Row,
|
||||
col: Col,
|
||||
direction: frp::io::keyboard::ArrowDirection,
|
||||
rows: Row,
|
||||
columns: Col,
|
||||
) -> MovementTarget {
|
||||
use frp::io::keyboard::ArrowDirection::*;
|
||||
use MovementTarget::*;
|
||||
let row_below = row + 1;
|
||||
let col_to_the_right = col + 1;
|
||||
match direction {
|
||||
Up if row > 0 => Location { row: row - 1, col },
|
||||
Down if row_below < rows => Location { row: row_below, col },
|
||||
Left if col > 0 => Location { row, col: col - 1 },
|
||||
Right if col_to_the_right < columns => Location { row, col: col_to_the_right },
|
||||
_ => OutOfBounds(direction),
|
||||
}
|
||||
}
|
||||
|
||||
/// In case of a [`Location`] variant, return the row and col it contains.
|
||||
fn location(self) -> Option<(Row, Col)> {
|
||||
match self {
|
||||
Self::Location { row, col } => Some((row, col)),
|
||||
Self::OutOfBounds(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// In case of an [`OutOfBounds`] variant, return the arrow direction it contains.
|
||||
fn out_of_bounds(self) -> Option<frp::io::keyboard::ArrowDirection> {
|
||||
match self {
|
||||
Self::Location { .. } => None,
|
||||
Self::OutOfBounds(dir) => Some(dir),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MovementTarget {
|
||||
fn default() -> Self {
|
||||
Self::Location { row: 0, col: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ================
|
||||
// === GridView ===
|
||||
// ================
|
||||
@ -97,6 +162,7 @@ where
|
||||
highlight::HasConstructor<InnerGridView = InnerGridView>,
|
||||
{
|
||||
fn new_wrapping(app: &Application, grid: InnerGridView) -> Self {
|
||||
use frp::io::keyboard::ArrowDirection as Direction;
|
||||
let highlights = highlight::shape::View::new(Logger::new("highlights"));
|
||||
let header_highlights = Immutable(None);
|
||||
let selection_handler = highlight::SelectionHandler::new_connected(app, &grid);
|
||||
@ -105,12 +171,33 @@ where
|
||||
selection_handler.connect_with_shape(&highlights);
|
||||
hover_handler.connect_with_shape(&highlights);
|
||||
|
||||
let grid_frp = grid.as_ref().frp();
|
||||
let grid_ref = grid.as_ref();
|
||||
let grid_frp = grid_ref.frp();
|
||||
let network = grid_frp.network();
|
||||
let input = &grid_frp.private.input;
|
||||
frp::extend! { network
|
||||
eval grid_frp.viewport ([highlights](&vp) {
|
||||
highlight::shape::set_viewport(&highlights, vp);
|
||||
});
|
||||
|
||||
|
||||
// === Move selection by one position ===
|
||||
|
||||
input_move_selection_dir <- any(...);
|
||||
input_move_selection_dir <+ input.move_selection_up.constant(Some(Direction::Up));
|
||||
input_move_selection_dir <+ input.move_selection_down.constant(Some(Direction::Down));
|
||||
input_move_selection_dir <+ input.move_selection_left.constant(Some(Direction::Left));
|
||||
input_move_selection_dir <+ input.move_selection_right.constant(Some(Direction::Right));
|
||||
let grid_size = &grid_frp.grid_size;
|
||||
let selection = &grid_frp.entry_selected;
|
||||
selection_after_movement <= input_move_selection_dir.map3(grid_size, selection,
|
||||
|dir, (rows, cols), selection| selection.zip(*dir).map(|((row, col), dir)|
|
||||
MovementTarget::next_in_direction(row, col, dir, *rows, *cols)
|
||||
)
|
||||
);
|
||||
grid_frp.select_entry <+ selection_after_movement.filter_map(|s| s.location()).some();
|
||||
grid_frp.private.output.selection_movement_out_of_grid_prevented <+
|
||||
selection_after_movement.map(|s| s.out_of_bounds());
|
||||
}
|
||||
|
||||
Self { grid, highlights, header_highlights, selection_handler, hover_handler }
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
use crate::application::command::Command;
|
||||
use crate::application::command::CommandApi;
|
||||
use crate::application::Application;
|
||||
use crate::display;
|
||||
use crate::display::scene;
|
||||
@ -11,6 +13,7 @@ use crate::display::scene::ShapeRegistry;
|
||||
use crate::display::shape::primitive::system::DynamicShape;
|
||||
use crate::display::shape::primitive::system::DynamicShapeInternals;
|
||||
use crate::display::symbol;
|
||||
use crate::frp;
|
||||
|
||||
|
||||
// ==============
|
||||
@ -286,3 +289,14 @@ impl<Model: 'static, Frp: 'static> display::Object for Widget<Model, Frp> {
|
||||
&self.data.display_object
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: 'static, Frp: 'static + crate::application::frp::API> CommandApi
|
||||
for Widget<Model, Frp>
|
||||
{
|
||||
fn command_api(&self) -> Rc<RefCell<HashMap<String, Command>>> {
|
||||
self.data.frp.public().command_api()
|
||||
}
|
||||
fn status_api(&self) -> Rc<RefCell<HashMap<String, frp::Sampler<bool>>>> {
|
||||
self.data.frp.public().status_api()
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,15 @@ fn setup_grid_view(
|
||||
eval entry_hovered ([]((row, col)) tracing::debug!("Hovered entry ({row}, {col})."));
|
||||
eval entry_selected ([]((row, col)) tracing::debug!("Selected entry ({row}, {col})."));
|
||||
eval view.entry_accepted ([]((row, col)) tracing::debug!("ACCEPTED entry ({row}, {col})."));
|
||||
eval view.selection_movement_out_of_grid_prevented ([](dir)
|
||||
if let Some(dir) = dir {
|
||||
let msg = iformat!(
|
||||
"An attempt to select an entry outside of the grid in " dir;?
|
||||
" direction was prevented."
|
||||
);
|
||||
tracing::debug!("{msg}");
|
||||
}
|
||||
);
|
||||
}
|
||||
view.set_entries_size(Vector2(130.0, 28.0));
|
||||
let params = grid_view::simple::EntryParams {
|
||||
@ -117,6 +126,7 @@ fn init(app: &Application) {
|
||||
let grid_views = std::iter::repeat_with(|| setup_grid_view(app)).take(3).collect_vec();
|
||||
let with_hover_mask = [&grid_views[2]];
|
||||
let with_selection_mask = [&grid_views[1], &grid_views[2]];
|
||||
grid_views[2].frp().focus();
|
||||
let positions = itertools::iproduct!([-450.0, 50.0], [350.0, -50.0]);
|
||||
|
||||
for (view, (x, y)) in grid_views.iter().zip(positions) {
|
||||
|
@ -41,7 +41,11 @@ impl Side {
|
||||
// ===========
|
||||
|
||||
macro_rules! define_keys {
|
||||
(Side { $($side:ident),* $(,)? } Regular { $($regular:ident),* $(,)? }) => {
|
||||
(
|
||||
Side { $($side:ident),* $(,)? }
|
||||
Arrow { $($arrow:ident),* $(,)? }
|
||||
Regular { $($regular:ident),* $(,)? }
|
||||
) => {
|
||||
/// A key representation.
|
||||
///
|
||||
/// For reference, see the following links:
|
||||
@ -51,11 +55,21 @@ macro_rules! define_keys {
|
||||
pub enum Key {
|
||||
$($side(Side),)*
|
||||
$($regular,)*
|
||||
Arrow (ArrowDirection),
|
||||
Character (String),
|
||||
Other (String),
|
||||
Other (String),
|
||||
}
|
||||
|
||||
|
||||
// === ArrowDirection ===
|
||||
|
||||
/// The directions of the arrow keys on a keyboard.
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
|
||||
#[allow(missing_docs)]
|
||||
pub enum ArrowDirection {
|
||||
$($arrow,)*
|
||||
}
|
||||
|
||||
// === KEY_NAME_MAP ===
|
||||
|
||||
lazy_static! {
|
||||
@ -68,6 +82,10 @@ macro_rules! define_keys {
|
||||
let mut m = HashMap::new();
|
||||
$(m.insert(stringify!($side), $side(Left));)*
|
||||
$(m.insert(stringify!($regular), $regular);)*
|
||||
$(
|
||||
let key_name = concat!("Arrow", stringify!($arrow));
|
||||
m.insert(key_name, Arrow(ArrowDirection::$arrow));
|
||||
)*
|
||||
m
|
||||
};
|
||||
}
|
||||
@ -75,12 +93,9 @@ macro_rules! define_keys {
|
||||
}
|
||||
|
||||
define_keys! {
|
||||
Side {Alt,AltGr,AltGraph,Control,Meta,Shift}
|
||||
Side {Alt, AltGr, AltGraph, Control, Meta, Shift}
|
||||
Arrow {Down, Left, Right, Up}
|
||||
Regular {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Backspace,
|
||||
Delete,
|
||||
End,
|
||||
@ -140,6 +155,7 @@ impl Key {
|
||||
|
||||
/// Simple, kebab-case name of a key.
|
||||
pub fn simple_name(&self) -> String {
|
||||
use ArrowDirection as Dir;
|
||||
let fmt = |side: &Side, repr| format!("{}-{}", repr, side.simple_name());
|
||||
match self {
|
||||
Self::Alt(side) => fmt(side, "alt"),
|
||||
@ -149,10 +165,10 @@ impl Key {
|
||||
Self::Meta(side) => fmt(side, "meta"),
|
||||
Self::Shift(side) => fmt(side, "shift"),
|
||||
|
||||
Self::ArrowDown => "arrow-down".into(),
|
||||
Self::ArrowLeft => "arrow-left".into(),
|
||||
Self::ArrowRight => "arrow-right".into(),
|
||||
Self::ArrowUp => "arrow-up".into(),
|
||||
Self::Arrow(Dir::Down) => "arrow-down".into(),
|
||||
Self::Arrow(Dir::Left) => "arrow-left".into(),
|
||||
Self::Arrow(Dir::Right) => "arrow-right".into(),
|
||||
Self::Arrow(Dir::Up) => "arrow-up".into(),
|
||||
Self::Backspace => "backspace".into(),
|
||||
Self::Delete => "delete".into(),
|
||||
Self::End => "end".into(),
|
||||
|
Loading…
Reference in New Issue
Block a user