Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ This will produce an executable file at `target/release/bluetui` that you can co

`e`: Rename the device.

`f`: Favorite/Unfavorite the device.

### New devices

`Space or Enter`: Pair the device.
Expand All @@ -119,6 +121,7 @@ toggle_discovery = "d"
unpair = "u"
toggle_trust = "t"
rename = "e"
toggle_favorite = "f"
```

## ⚖️ License
Expand Down
21 changes: 20 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,11 @@ impl App {
.iter()
.map(|d| {
Row::new(vec![
if d.is_favorite {
"★".to_string()
} else {
" ".to_string()
},
{
if let Some(icon) = &d.icon {
format!("{} {}", icon, &d.alias)
Expand Down Expand Up @@ -462,6 +467,7 @@ impl App {
.any(|device| device.battery_percentage.is_some());

let mut widths = vec![
Constraint::Max(1),
Constraint::Max(25),
Constraint::Length(7),
Constraint::Length(9),
Expand All @@ -476,6 +482,7 @@ impl App {
if show_battery_column {
if self.focused_block == FocusedBlock::PairedDevices {
Row::new(vec![
Cell::from("★").style(Style::default().fg(Color::Yellow)),
Cell::from("Name").style(Style::default().fg(Color::Yellow)),
Cell::from("Trusted").style(Style::default().fg(Color::Yellow)),
Cell::from("Connected").style(Style::default().fg(Color::Yellow)),
Expand All @@ -485,6 +492,7 @@ impl App {
.bottom_margin(1)
} else {
Row::new(vec![
Cell::from("★"),
Cell::from("Name"),
Cell::from("Trusted"),
Cell::from("Connected"),
Expand All @@ -494,6 +502,7 @@ impl App {
}
} else if self.focused_block == FocusedBlock::PairedDevices {
Row::new(vec![
Cell::from("★").style(Style::default().fg(Color::Yellow)),
Cell::from("Name").style(Style::default().fg(Color::Yellow)),
Cell::from("Trusted").style(Style::default().fg(Color::Yellow)),
Cell::from("Connected").style(Style::default().fg(Color::Yellow)),
Expand Down Expand Up @@ -871,7 +880,17 @@ impl App {
controller.is_powered = refreshed_controller.is_powered;
controller.is_pairable = refreshed_controller.is_pairable;
controller.is_discoverable = refreshed_controller.is_discoverable;
controller.paired_devices = refreshed_controller.paired_devices;

let mut paired_devices_sorted = refreshed_controller.paired_devices;
paired_devices_sorted.sort_by(|a, b| {
use std::cmp::Ordering;
match (a.is_favorite, b.is_favorite) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => Ordering::Equal,
}
});
controller.paired_devices = paired_devices_sorted;
controller.new_devices = refreshed_controller.new_devices;
} else {
// Add new detected adapters
Expand Down
51 changes: 51 additions & 0 deletions src/bluetooth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use bluer::{

use bluer::Device as BTDevice;

use clap::crate_name;
use tokio::sync::oneshot;

use crate::app::AppResult;
Expand Down Expand Up @@ -35,6 +36,7 @@ pub struct Device {
pub is_trusted: bool,
pub is_connected: bool,
pub battery_percentage: Option<u8>,
pub is_favorite: bool,
}

impl Device {
Expand All @@ -58,6 +60,53 @@ impl Device {
_ => None,
}
}

pub async fn get_if_favorite(addr: Address) -> bool {
let state_dir = dirs::state_dir().expect("Could not find state directory.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if user's favorites aren't important enough to warrant placement in $XDG_DATA_HOME?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might be right. According to Gentoo's Wiki, $XDG_STATE_HOME is more for data that isn't important/portable enough.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the program should not exit because we can not find favorite directory or we can not read from it.

let favorite_addrs_file = state_dir.join(crate_name!()).join("favorite_addrs.txt");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just favorites.txt?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My code taste is to make names as specific as possible. The file contains MAC adresses of favorite devices, so that's why I named it that.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with @Jorenar
also, same as the comment above, the program should not quit if the read operation is not successful for any reason.

if !favorite_addrs_file.exists() {
return false;
}
let contents = tokio::fs::read_to_string(favorite_addrs_file).await;
Copy link

@eHammarstrom eHammarstrom Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this read cached or do we read the file every time we ask if the addr is a favorite?
Perhaps it is a good idea to store a map of the favorites (addr: Address, favorite: bool) that we serialize and deserialize on startup and favorite state modification?

Then you could have the member method is_favorite which only queries the map/store of the favorite state, giving a similar API as to is_trusted and is_connected

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not cached, and it called everytime the App:tick is called.. so this is EXTREMELY bad and practically constantly reading from disk.

I think your idea is good. I will try to implement!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think getting some input from @pythops as well would be a good idea before running with it :)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not good idea to have an io for every tick

if let Ok(contents) = contents {
for line in contents.lines() {
if line.trim() == addr.to_string() {
return true;
}
}
}
false
}

pub async fn toggle_favorite(&self) -> AppResult<()> {
let state_dir = dirs::state_dir().expect("Could not find state directory.");
let favorite_addrs_dir = state_dir.join(crate_name!());
if !favorite_addrs_dir.exists() {
tokio::fs::create_dir_all(&favorite_addrs_dir).await?;
}
let favorite_addrs_file = favorite_addrs_dir.join("favorite_addrs.txt");

let mut favorite_addrs: Vec<String> = Vec::new();
if favorite_addrs_file.exists() {
let contents = tokio::fs::read_to_string(&favorite_addrs_file).await?;
for line in contents.lines() {
favorite_addrs.push(line.trim().to_string());
}
}

if self.is_favorite {
// remove from favorites
favorite_addrs.retain(|addr| addr != &self.addr.to_string());
} else {
// add to favorites
favorite_addrs.push(self.addr.to_string());
}

let new_contents = favorite_addrs.join("\n");
tokio::fs::write(&favorite_addrs_file, new_contents).await?;

Ok(())
}
}

impl Controller {
Expand Down Expand Up @@ -111,6 +160,7 @@ impl Controller {
let is_trusted = device.is_trusted().await?;
let is_connected = device.is_connected().await?;
let battery_percentage = device.battery_percentage().await?;
let is_favorite = Device::get_if_favorite(addr).await;

let dev = Device {
device,
Expand All @@ -121,6 +171,7 @@ impl Controller {
is_trusted,
is_connected,
battery_percentage,
is_favorite,
};

if dev.is_paired {
Expand Down
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ pub struct PairedDevice {

#[serde(default = "default_set_new_name")]
pub rename: char,

#[serde(default = "default_toggle_device_favorite")]
pub toggle_favorite: char,
}

impl Default for PairedDevice {
Expand All @@ -55,6 +58,7 @@ impl Default for PairedDevice {
unpair: 'u',
toggle_trust: 't',
rename: 'e',
toggle_favorite: 'f',
}
}
}
Expand Down Expand Up @@ -87,6 +91,10 @@ fn default_toggle_device_trust() -> char {
't'
}

fn default_toggle_device_favorite() -> char {
'f'
}

impl Config {
pub fn new() -> Self {
let conf_path = dirs::config_dir()
Expand Down
37 changes: 37 additions & 0 deletions src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,43 @@ pub async fn handle_key_events(
app.focused_block = FocusedBlock::SetDeviceAliasBox;
}

// Favorite / Unfavorite
KeyCode::Char(c) if c == config.paired_device.toggle_favorite => {
if let Some(selected_controller) =
app.controller_state.selected()
{
let controller = app
.controllers
.get_mut(selected_controller)
.expect("Selected controller should be valid");

if let Some(index) = app.paired_devices_state.selected() {
match controller.paired_devices.get_mut(index) {
Some(device) => {
device.toggle_favorite().await?;
Notification::send(
if device.is_favorite {
format!("Device `{}` unfavorited", device.alias)
} else {
format!("Device `{}` favorited", device.alias)
},
NotificationLevel::Info,
sender.clone(),
)?;
}
None => {
Notification::send(
"Selected device should be valid"
.to_string(),
NotificationLevel::Error,
sender.clone(),
)?;
}
}
}
}
}

_ => {}
}
}
Expand Down
Loading