Iced Prompt (Part 1)


This is the first part of a blog series documenting my efforts in building a command prompt / launcher using iced.rs in the rust language.

You can find the source code for this project here: github.com/floscr/iced-prompt

Introduction

The idea

I’ve always been fascinated with Command prompts, they’re super interesting interfaces that bridge the gap between CLIs and UIs.

Alfred was my first introduction to programming way back, writing my own little prompt scripts.

The target would be to write a gui that can be interfaced with a JSON api that’s accessible from scripting languages like javascript or Babashka scripts.

Previous solution

I’ve been using rofi on linux, which is a let’s you create prompts via scripts.

But there is no good way to create multi-modal prompts that can deliver dynamic content (e.g.: searching the web via an api). And you’re restricted to a simple ui.

The requirements

  • Search applications on my system
  • Allow dynamic content via scripting
  • Multiple types of blocks
  • Dynamic Evaluation (evaluate math expressions: 1 + 1)

Iced Framework

Iced

For this project I’m using the iced.rs framework, which is a cross-platform GUI library for Rust taking inspiration from the Elm Model View Update Architecture.

I’m still actively learning rust and iced during this project, so some code might not be ideal.

A simple example application

When you’re used to web development this will feel familiar to you.

In our main.rs we’re going to write a simple application that let’s a user input text and show it again in some text below.

First we’re going to set up some imports:

rust
use iced::theme::Theme;
use iced::widget::{column, container, text, text_input};
use iced::window::{self};
use iced::{Application, Element, Settings};
use once_cell::sync::Lazy;

Next we’re going to create some state for our view

rust
#[derive(Debug, Default)]
struct State {
    query: String,
}

Here we’re adding the Debug and Default traits #[derive(Debug, Default)].
Debug will allow you to print the value with println! and default will give us default values for our struct (in this case an empty string).

So you could log the state like this:

rust
println!("{:#?}", State::default());
// State {
//    query: "",
// }

After this we will set up our messages types, which are identifiers for events like in Redux or any other state management framework.

rust
#[derive(Debug, Clone)]
enum Message {
    UpdateQuery(String),
}

And finally we’re going to create a static id for our input, so we can later focus it in our update function

rust
static INPUT_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique);

Now it’s time to write the application implementation:

We’re setting up this impl with some wanted feature traits like messaging, theming, async execution and without any flags.

rust
impl Application for State {
    type Message = Message;
    type Theme = Theme;
    type Executor = iced::executor::Default;
    type Flags = ();

Next we’re changing the theme for this component:

rust
    fn theme(&self) -> Theme {
        Theme::Dark
    }

Now we add an initialization method new.
This function expects a tuple of the initial state and an initial event.
In our case we want our input reference by the id we’ve set up before to be focused on launch of the application.

rust
    fn new(_flags: ()) -> (State, iced::Command<Message>) {
        (State::default(), text_input::focus(INPUT_ID.clone()))
    }

Another requirement is to set up the title for the app.

rust
    fn title(&self) -> String {
        "My iced application".to_string()
    }

Now we’ve arrived at the update function, which responds to events and aplies changes to the State.
In our case we simple update the query property in our string.

rust
    fn update(&mut self, message: Message) -> iced::Command<Message> {
        match message {
            Message::UpdateQuery(query) => {
                self.query = query;
                iced::Command::none()
            }
        }
    }

Finally we’re closing out the impl with our view which is responsible for rendering our UI. Here we render our input state and dispatch Message::UpdateQuery whenever the user types in the input.

rust
    fn view(&self) -> Element<Message> {
        let value = text(&self.query);
 
        let input = text_input("Query", &self.query)
            .id(INPUT_ID.clone())
            .on_input(Message::UpdateQuery);
 
        let column = column![input, value].spacing(5);
 
        container(column).padding(10).into()
    }

Now the only thing left is to launch the application:

rust
 
pub fn main() -> iced::Result {
    State::run(Settings {
        window: window::Settings {
            size: (700, 500),
            ..window::Settings::default()
        },
        ..Settings::default()
    })
}

And we’ve got our application!

You can find the full source code here

The application

This part will go a bit more in depth about the implementation of the application, so it’s less beginner friendly compared to the iced intro.

State & Types

The basic structure for an item in my application is a Command.

rust
pub struct Command {
    pub value: String,
    pub kind: CommandKind,
    pub icon: Option<String>,
    pub action: ActionKind,
    pub items: Items<Command>,
}

It’s a recursive data type to allow the user for infinite possibilities. But the most basic use-case will be, that Commands will have a list of actions that can be executed on it (Copy, Open with default application, etc).

Commands can be of multiple CommandKinds which for now will always be a shell command type that justs owns the shell command to be executed.

The action will determine if the prompt will go to the next level via ActionKind::Next or exit via ActionKind::Exit.

Items will be a special data type which will host it’s nested Commands in a HashMap for performan access and an order property (This will be important later, as we can override this to easily filter items or change the order without having to remap the inner commands)

rust
pub struct Items<T> {
    pub items: HashMap<Uuid, T>,
    pub order: Vec<Uuid>,
}

History

The history will be a singly linked list that consists of Commands.

This data type is ideal, as we only display the last history slice to the to the user, and to go back we pop off the current head.

The implementation for the list was taken from CrazyRoka/rust-linkedlist

Application State

Here’s all our state wrapped into a single struct.

rust
struct State {
    input_value: String,
    history: History,
    filter: Option<Vec<Uuid>>,
    selection: Selection,
    scrollable_offset: AbsoluteOffset,
}

The filter will be used to override the current history layers order. Selection will be governed of the current user selection, to execute commands.

Deserializing with serde

To allow the user scripting access via a json api we’re going to use the fantastic serde library.

With serde you can safely convert json strings from the user to your wanted types, this works for simple types using the Deserialize trait.

rust
#[derive(Deserialize)]
pub struct Command {
  pub value: String,
}

This will now accept json like this:

rust
let json = r#"{
    "value": "foo"
}"#;
let cmd: Command = serde_json::from_str(json).unwrap();

Deserializing the items array

Of course we don’t want our users having to map our detailed data type via the json api, for this serde has some tools like aliasing and custom deserialization.

For the Items data type we don’t want the user having to generate uuids and provide the structure with the nested order and items HashMap.

While the datatype looks like this:

rust
pub struct Items<T> {
    pub items: HashMap<Uuid, T>,
    pub order: Vec<Uuid>,
}

We want the user api to be a simple array like this:

json
{
  "items": [
    {
      "value": "List Files",
      "shell": "ls"
    },
    {
      "value": "Current Directory",
      "shell": "pwd"
    }
  ]
}

We can achieve this with our custom deserializer for items:

This requires a bit of verbose setup:

rust
impl<'de> Deserialize<'de> for Items<Command> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct ItemsVisitor;
 
        impl<'de> Visitor<'de> for ItemsVisitor {
            type Value = Items<Command>;
 
            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("an array of commands")
            }
 

But the actual implementation is quite simple, we loop over the array and insert each item with a uuid in our data structure:

rust
            fn visit_seq<A>(self, mut seq: A) -> Result<Items<Command>, A::Error>
            where
                A: SeqAccess<'de>,
            {
                let mut items = HashMap::new();
                let mut order = Vec::new();
 
                while let Some(command) = seq.next_element::<Command>()? {
                    let uuid = Uuid::new_v4();
                    order.push(uuid);
                    items.insert(uuid, command);
                }
 
                Ok(Items { items, order })
            }

We can now use this method with the deserialize_with trait like this:

rust
#[serde(default, deserialize_with = "Items::deserialize")]
pub items: Items<Command>,

Other traits I’ve used

Default: Will infer the default for missing properties in the json, in this case it will infer []

rust
#[serde(default)]
pub strings: Vec<String>,

Alias: Easy aliasing so you can accept the origin property key as well as the alias.

rust
#[serde(alias = "shell")]
pub kind: CommandKind,

Thoughts

The traits make deserialization nice to implement, but the api can get quite hidden from the developer.
A future consideration might be to create a translation data type that directly translates to the user json api.

Next steps

Phew, this post required quite a lot of setup 😅
The next parts in this series should be a lot shorter.

As next steps I want to:

  • Add dynamic commands that evaluate directly when typing
  • More command block types
  • Themes
  • Server / Client architecture (Keep the main process alive)

You can find the source code for this project here: github.com/floscr/iced-prompt