Skip to content

Commit

Permalink
Disable invalid menu actions
Browse files Browse the repository at this point in the history
The implementation is mildly jank, but it's simple and clear to the user. Disabled items can still be selected, they just don't trigger callbacks. This was the easiest thing to implement and I think also the most intuitive.

Closes #222
  • Loading branch information
LucasPickering committed Jul 28, 2024
1 parent 8993e80 commit c401637
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 71 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Add syntax highlight to recipe, request, and response display [#264](https://github.com/LucasPickering/slumber/issues/264)
- Add syntax highlighting to recipe, request, and response display [#264](https://github.com/LucasPickering/slumber/issues/264)

### Changed

- Upgrade to Rust 1.80
- Disable unavailable menu actions [#222](https://github.com/LucasPickering/slumber/issues/222)

## [1.7.0] - 2024-07-22

Expand Down
15 changes: 12 additions & 3 deletions crates/slumber_tui/src/view/common/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ pub struct ActionsModal<T: FixedSelect> {
actions: Component<FixedSelectState<T, ListState>>,
}

impl<T: FixedSelect> Default for ActionsModal<T> {
fn default() -> Self {
impl<T: FixedSelect> ActionsModal<T> {
/// Create a new actions modal, optionall disabling certain actions based on
/// some external condition(s).
pub fn new(disabled_actions: &[T]) -> Self {
let on_submit = move |action: &mut T| {
// Close the modal *first*, so the parent can handle the
// callback event. Jank but it works
Expand All @@ -32,13 +34,20 @@ impl<T: FixedSelect> Default for ActionsModal<T> {

Self {
actions: FixedSelectState::builder()
.disabled_items(disabled_actions)
.on_submit(on_submit)
.build()
.into(),
}
}
}

impl<T: FixedSelect> Default for ActionsModal<T> {
fn default() -> Self {
Self::new(&[])
}
}

impl<T> Modal for ActionsModal<T>
where
T: FixedSelect,
Expand Down Expand Up @@ -67,7 +76,7 @@ where
fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) {
self.actions.draw(
frame,
List::new(self.actions.data().items()),
List::from(self.actions.data()),
metadata.area(),
true,
);
Expand Down
3 changes: 2 additions & 1 deletion crates/slumber_tui/src/view/common/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl<T: FixedSelect> Draw for ButtonGroup<T> {
// The button width is based on the longest button
let width = items
.iter()
.map(|button| button.to_string().len())
.map(|button| button.value.to_string().len())
.max()
.unwrap_or(0) as u16;
let (areas, _) =
Expand All @@ -91,6 +91,7 @@ impl<T: FixedSelect> Draw for ButtonGroup<T> {
.split_with_spacers(metadata.area());

for (button, area) in items.iter().zip(areas.iter()) {
let button = &button.value;
frame.render_widget(
Button {
text: &button.to_string(),
Expand Down
90 changes: 75 additions & 15 deletions crates/slumber_tui/src/view/common/list.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,94 @@
use crate::{
context::TuiContext,
view::{common::scrollbar::Scrollbar, draw::Generate},
view::{
common::scrollbar::Scrollbar,
draw::Generate,
state::{
fixed_select::{FixedSelect, FixedSelectState},
select::{SelectItem, SelectState},
},
},
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Styled,
text::Text,
widgets::{ListItem, ListState, StatefulWidget, Widget},
widgets::{
List as TuiList, ListItem as TuiListItem, ListState, StatefulWidget,
Widget,
},
};
use std::marker::PhantomData;

/// A sequence of items, with a scrollbar and optional surrounding pane
pub struct List<'a, Item, Iter: 'a + IntoIterator<Item = Item>> {
items: Iter,
_phantom: PhantomData<&'a ()>,
pub struct List<'a, Item> {
items: Vec<ListItem<Item>>,
/// This *shouldn't* be required, but without it we hit this ICE:
/// https://github.com/rust-lang/rust/issues/124189
phantom: PhantomData<&'a ()>,
}

impl<'a, Item, Iter: 'a + IntoIterator<Item = Item>> List<'a, Item, Iter> {
pub fn new(items: Iter) -> Self {
impl<'a, Item> List<'a, Item> {
pub fn from_iter(items: impl 'a + IntoIterator<Item = Item>) -> Self {
Self {
items,
_phantom: PhantomData,
items: items
.into_iter()
.map(|value| ListItem {
value,
disabled: false,
})
.collect(),
phantom: PhantomData,
}
}
}

impl<'a, T, Item, Iter> StatefulWidget for List<'a, Item, Iter>
impl<'a, Item> From<&'a SelectState<Item>> for List<'a, &'a Item> {
fn from(select: &'a SelectState<Item>) -> Self {
Self {
items: select.items().iter().map(ListItem::from).collect(),
phantom: PhantomData,
}
}
}

impl<'a, Item> From<&'a FixedSelectState<Item>> for List<'a, &'a Item>
where
Item: FixedSelect,
{
fn from(select: &'a FixedSelectState<Item>) -> Self {
Self {
items: select.items().iter().map(ListItem::from).collect(),
phantom: PhantomData,
}
}
}

impl<'a, T, Item> StatefulWidget for List<'a, Item>
where
T: Into<Text<'a>>,
Item: 'a + Generate<Output<'a> = T>,
Iter: 'a + IntoIterator<Item = Item>,
{
type State = ListState;

fn render(self, area: Rect, buf: &mut Buffer, state: &mut ListState) {
let styles = &TuiContext::get().styles;

// Draw list
let items: Vec<ListItem<'_>> = self
let items: Vec<TuiListItem<'_>> = self
.items
.into_iter()
.map(|i| ListItem::new(i.generate()))
.map(|item| {
let mut list_item = TuiListItem::new(item.value.generate());
if item.disabled {
list_item = list_item.set_style(styles.list.disabled);
}
list_item
})
.collect();
let num_items = items.len();
let list = ratatui::widgets::List::new(items)
.highlight_style(TuiContext::get().styles.list.highlight);
let list = TuiList::new(items).highlight_style(styles.list.highlight);
StatefulWidget::render(list, area, buf, state);

// Draw scrollbar
Expand All @@ -54,3 +100,17 @@ where
.render(area, buf);
}
}

struct ListItem<T> {
value: T,
disabled: bool,
}

impl<'a, T> From<&'a SelectItem<T>> for ListItem<&'a T> {
fn from(item: &'a SelectItem<T>) -> Self {
Self {
value: &item.value,
disabled: item.disabled,
}
}
}
2 changes: 1 addition & 1 deletion crates/slumber_tui/src/view/common/scrollbar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ mod tests {

// Render a list once to get a realistic offset calculation
let mut buffer = Buffer::empty(area);
let list = List::new((0..content_length).map(|i| i.to_string()));
let list = List::from_iter((0..content_length).map(|i| i.to_string()));
let mut state = ListState::default().with_selected(Some(selected));
StatefulWidget::render(list, area, &mut buffer, &mut state);

Expand Down
4 changes: 2 additions & 2 deletions crates/slumber_tui/src/view/component/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ impl Modal for History {
fn dimensions(&self) -> (Constraint, Constraint) {
(
Constraint::Length(40),
Constraint::Length(self.select.data().items().len().min(20) as u16),
Constraint::Length(self.select.data().len().min(20) as u16),
)
}
}
Expand All @@ -78,7 +78,7 @@ impl Draw for History {
fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) {
self.select.draw(
frame,
List::new(self.select.data().items()),
List::from(self.select.data()),
metadata.area(),
true,
);
Expand Down
13 changes: 8 additions & 5 deletions crates/slumber_tui/src/view/component/profile_select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ impl ProfilePane {
ViewContext::open_modal(
ProfileListModal::new(
// See self.profiles doc comment for why we need to clone
self.profiles.items().to_owned(),
self.profiles
.items()
.iter()
.map(|item| item.value.clone())
.collect(),
self.profiles.selected().map(|profile| &profile.id),
),
ModalPriority::Low,
Expand Down Expand Up @@ -189,7 +193,7 @@ impl Draw for ProfileListModal {
fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) {
// Empty state
let select = self.select.data();
if select.items().is_empty() {
if select.is_empty() {
frame.render_widget(
Text::from(vec![
"No profiles defined; add one to your collection.".into(),
Expand All @@ -201,14 +205,13 @@ impl Draw for ProfileListModal {
}

let [list_area, _, detail_area] = Layout::vertical([
Constraint::Length(select.items().len().min(5) as u16),
Constraint::Length(select.len().min(5) as u16),
Constraint::Length(1), // Padding
Constraint::Min(0),
])
.areas(metadata.area());

self.select
.draw(frame, List::new(select.items()), list_area, true);
self.select.draw(frame, List::from(select), list_area, true);
if let Some(profile) = select.selected() {
self.detail.draw(
frame,
Expand Down
21 changes: 15 additions & 6 deletions crates/slumber_tui/src/view/component/recipe_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
draw::{Draw, DrawMetadata, Generate},
event::{Event, EventHandler, Update},
state::select::SelectState,
Component, ViewContext,
Component, ModalPriority, ViewContext,
},
};
use derive_more::{Deref, DerefMut};
Expand Down Expand Up @@ -137,9 +137,17 @@ impl EventHandler for RecipeListPane {
Action::Toggle => {
self.set_selected_collapsed(CollapseState::Toggle);
}
Action::OpenActions => ViewContext::open_modal_default::<
ActionsModal<RecipeMenuAction>,
>(),
Action::OpenActions => {
let recipe =
self.select.data().selected().and_then(RecipeNode::recipe);
ViewContext::open_modal(
ActionsModal::new(RecipeMenuAction::disabled_actions(
recipe.is_some(),
recipe.map(|recipe| recipe.body.as_ref()).is_some(),
)),
ModalPriority::Low,
)
}
_ => return Update::Propagate(event),
}

Expand Down Expand Up @@ -172,7 +180,8 @@ impl Draw for RecipeListPane {
let items = select
.items()
.iter()
.map(|node| {
.map(|item| {
let node = &item.value;
let (icon, name) = match node {
RecipeNode::Folder(folder) => {
let icon = if self.collapsed.is_collapsed(&folder.id) {
Expand Down Expand Up @@ -203,7 +212,7 @@ impl Draw for RecipeListPane {
})
.collect_vec();

self.select.draw(frame, List::new(items), area, true);
self.select.draw(frame, List::from_iter(items), area, true);
}
}

Expand Down
36 changes: 31 additions & 5 deletions crates/slumber_tui/src/view/component/recipe_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
draw::{Draw, DrawMetadata, Generate, ToStringGenerate},
event::{Event, EventHandler, Update},
state::{select::SelectState, StateCell},
Component, ViewContext,
Component, ModalPriority, ViewContext,
},
};
use derive_more::Display;
Expand Down Expand Up @@ -201,6 +201,24 @@ pub enum RecipeMenuAction {
#[display("Copy as cURL")]
CopyCurl,
}

impl RecipeMenuAction {
pub fn disabled_actions(
has_recipe: bool,
has_body: bool,
) -> &'static [Self] {
if has_recipe {
if has_body {
&[]
} else {
&[Self::CopyBody]
}
} else {
&[Self::CopyUrl, Self::CopyBody, Self::CopyCurl]
}
}
}

impl ToStringGenerate for RecipeMenuAction {}

impl RecipePane {
Expand All @@ -215,7 +233,7 @@ impl RecipePane {
.items()
.iter()
.enumerate()
.filter(|(_, row)| !*row.enabled)
.filter(|(_, row)| !*row.value.enabled)
.map(|(i, _)| i)
.collect()
}
Expand Down Expand Up @@ -255,9 +273,16 @@ impl EventHandler for RecipePane {
PrimaryPane::Recipe,
));
}
Action::OpenActions => ViewContext::open_modal_default::<
ActionsModal<RecipeMenuAction>,
>(),
Action::OpenActions => {
let state = self.recipe_state.get_mut();
ViewContext::open_modal(
ActionsModal::new(RecipeMenuAction::disabled_actions(
state.is_some(),
state.map(|state| state.body.as_ref()).is_some(),
)),
ModalPriority::Low,
)
}
_ => return Update::Propagate(event),
}
} else {
Expand Down Expand Up @@ -689,6 +714,7 @@ fn to_table<'a, K: PersistedKey<Value = bool>>(
.items()
.iter()
.map(|item| {
let item = &item.value;
ToggleRow::new(
[item.key.as_str().into(), item.value.generate()],
*item.enabled,
Expand Down
Loading

0 comments on commit c401637

Please sign in to comment.