commit 0c15ef07cc39f16ae6175c203c0362b73ffc8a67
parent 91f3b13632ae1cd334bfd9094aeab5ad0aaf9297
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Thu, 6 Aug 2026 17:42:06 +0900
feat(audio): add output device management and settings UI view
Diffstat:
| M | Cargo.toml | | | 4 | ++-- |
| M | src/audio.rs | | | 50 | +++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/main.rs | | | 280 | ++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- |
| M | src/setting.rs | | | 21 | +++++++++++++++++++++ |
| M | src/ui.rs | | | 249 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- |
5 files changed, 432 insertions(+), 172 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -6,8 +6,8 @@ edition = "2024"
[dependencies]
crossterm = "0.29.0"
ratatui = {version = "0.30.2"}
-rodio = {version="0.22.2", default-features = true}
-toml = {version = "1.1.4+spec-1.1.0"}
+rodio = {version="0.22.2", features = ["playback"]}
+toml = {version = "1.1.4"}
serde = {version = "1.0.190", features = ["derive"]}
directories = {version = "6.0.0"}
diff --git a/src/audio.rs b/src/audio.rs
@@ -1,9 +1,14 @@
+use crate::setting::AudioSettings;
+use rodio::Device;
+use rodio::cpal::traits::{DeviceTrait, HostTrait};
+use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
+
pub struct AudioEngine {
player: rodio::Player,
stream_handle: rodio::MixerDeviceSink,
}
pub trait AudioPlayer {
- fn new() -> Result<AudioEngine, Box<dyn std::error::Error>>;
+ fn new(audio_settings: &AudioSettings) -> Result<AudioEngine, Box<dyn std::error::Error>>;
fn append_a_track(&mut self, file: &str) -> Result<(), Box<dyn std::error::Error>>;
fn play(&mut self);
fn pause(&mut self);
@@ -19,8 +24,8 @@ pub trait AudioPlayer {
}
impl AudioPlayer for AudioEngine {
- fn new() -> Result<Self, Box<dyn std::error::Error>> {
- let stream_handle = rodio::DeviceSinkBuilder::open_default_sink()?;
+ fn new(audio_settings: &AudioSettings) -> Result<Self, Box<dyn std::error::Error>> {
+ let stream_handle = DeviceManager::open_sink(&audio_settings.output_device())?;
let player = rodio::Player::connect_new(stream_handle.mixer());
Ok(Self {
player,
@@ -70,3 +75,42 @@ impl AudioPlayer for AudioEngine {
Ok(())
}
}
+
+pub struct DeviceManager;
+
+impl DeviceManager {
+ pub fn list_device_names() -> Vec<String> {
+ let host = rodio::cpal::default_host();
+ let Ok(devices) = host.output_devices() else {
+ return Vec::new();
+ };
+
+ devices.filter_map(|d| d.name().ok()).collect()
+ }
+
+ pub fn find_device_by_name(name: &str) -> Option<Device> {
+ if name.trim().is_empty() {
+ return None;
+ }
+
+ let host = rodio::cpal::default_host();
+ let Ok(mut devices) = host.output_devices() else {
+ return None;
+ };
+
+ devices.find(|d| d.name().map(|n| n == name).unwrap_or(false))
+ }
+
+ pub fn open_sink(device_name: &str) -> Result<MixerDeviceSink, Box<dyn std::error::Error>> {
+ if let Some(device) = Self::find_device_by_name(device_name) {
+ if let Ok(builder) = DeviceSinkBuilder::from_device(device) {
+ if let Ok(sink) = builder.open_stream() {
+ return Ok(sink);
+ }
+ }
+ }
+
+ let sink = DeviceSinkBuilder::open_default_sink()?;
+ Ok(sink)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -24,8 +24,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
io::stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
- let settings: Settings = Settings::load()?;
- let mut audioengine: AudioEngine = AudioEngine::new()?;
+ let mut settings: Settings = Settings::load()?;
+ let mut audioengine: AudioEngine = AudioEngine::new(settings.audio())?;
audioengine.set_volume(settings.audio().volume());
let raw_items = library::scan_library(settings.audio().library_path());
@@ -34,15 +34,57 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
loop {
terminal.draw(|f| {
- ui::render(f, &library, &mut state);
+ ui::render(f, &library, &mut state, &settings);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
+ if state.is_device_dialog_open {
+ match key.code {
+ KeyCode::Up => {
+ let i = state.device_dialog_state.selected().unwrap_or(0);
+ if i > 0 {
+ state.device_dialog_state.select(Some(i - 1));
+ }
+ }
+ KeyCode::Down => {
+ let i = state.device_dialog_state.selected().unwrap_or(0);
+ if !state.available_devices.is_empty()
+ && i < state.available_devices.len() - 1
+ {
+ state.device_dialog_state.select(Some(i + 1));
+ }
+ }
+ KeyCode::Enter => {
+ if let Some(i) = state.device_dialog_state.selected() {
+ if let Some(device_name) = state.available_devices.get(i) {
+ settings.audio_mut().set_output_device(device_name.clone());
+ audioengine = AudioEngine::new(settings.audio())?;
+ audioengine.set_volume(settings.audio().volume());
+ settings.save()?;
+ }
+ }
+ state.close_device_dialog();
+ }
+ KeyCode::Esc => {
+ state.close_device_dialog();
+ }
+ _ => {}
+ }
+ continue;
+ }
+
match key.code {
KeyCode::Char('q') => break,
+ KeyCode::Char('s') => {
+ state.focus = if state.focus == Focus::Settings {
+ Focus::Artist
+ } else {
+ Focus::Settings
+ };
+ }
KeyCode::Char('p') => {
if audioengine.is_paused() {
audioengine.play();
@@ -51,117 +93,167 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
KeyCode::Left => {
- state.focus = match state.focus {
- Focus::Artist => Focus::Artist,
- Focus::Album => Focus::Artist,
- Focus::Track => Focus::Album,
- };
+ if state.focus == Focus::Settings {
+ let selected_setting = state.settings_state.selected().unwrap_or(0);
+ if selected_setting == 0 {
+ let current_volume = settings.audio().volume();
+ let new_volume = (current_volume - 0.01).max(0.0);
+ settings.audio_mut().set_volume(new_volume);
+ audioengine.set_volume(new_volume);
+ settings.save()?;
+ }
+ } else {
+ state.focus = match state.focus {
+ Focus::Artist => Focus::Artist,
+ Focus::Album => Focus::Artist,
+ Focus::Track => Focus::Album,
+ Focus::Settings => Focus::Settings,
+ };
+ }
}
KeyCode::Right => {
- state.focus = match state.focus {
- Focus::Artist => {
- state.album_state.select(Some(0));
- Focus::Album
+ if state.focus == Focus::Settings {
+ let selected_setting = state.settings_state.selected().unwrap_or(0);
+ if selected_setting == 0 {
+ let current_volume = settings.audio().volume();
+ let new_volume = (current_volume + 0.01).min(1.0);
+ settings.audio_mut().set_volume(new_volume);
+ audioengine.set_volume(new_volume);
+ settings.save()?;
}
- Focus::Album => {
- state.track_state.select(Some(0));
- Focus::Track
- }
- Focus::Track => Focus::Track,
- };
+ } else {
+ state.focus = match state.focus {
+ Focus::Artist => {
+ state.album_state.select(Some(0));
+ Focus::Album
+ }
+ Focus::Album => {
+ state.track_state.select(Some(0));
+ Focus::Track
+ }
+ Focus::Track => Focus::Track,
+ Focus::Settings => Focus::Settings,
+ };
+ }
}
KeyCode::Up => {
- let current_state = match state.focus {
- Focus::Artist => &mut state.artist_state,
- Focus::Album => &mut state.album_state,
- Focus::Track => &mut state.track_state,
- };
- let i = current_state.selected().unwrap_or(0);
- if i > 0 {
- current_state.select(Some(i - 1));
+ if state.focus == Focus::Settings {
+ let i = state.settings_state.selected().unwrap_or(0);
+ if i > 0 {
+ state.settings_state.select(Some(i - 1));
+ }
+ } else {
+ let current_state = match state.focus {
+ Focus::Artist => &mut state.artist_state,
+ Focus::Album => &mut state.album_state,
+ Focus::Track => &mut state.track_state,
+ Focus::Settings => &mut state.artist_state,
+ };
+ let i = current_state.selected().unwrap_or(0);
+ if i > 0 {
+ current_state.select(Some(i - 1));
+ }
}
}
KeyCode::Down => {
- // カウントを取得するためにデータを取得
- let max_len = match state.focus {
- Focus::Artist => library.artists.len(),
- Focus::Album => {
- let artists: Vec<_> = library.artists.keys().collect();
- state
- .artist_state
- .selected()
- .and_then(|i| artists.get(i))
- .and_then(|a| library.artists.get(*a))
- .map(|m| m.len())
- .unwrap_or(0)
- }
- Focus::Track => {
- let artists: Vec<_> = library.artists.keys().collect();
- let artist =
- state.artist_state.selected().and_then(|i| artists.get(i));
- let albums: Vec<_> = artist
- .and_then(|a| library.artists.get(*a))
- .map(|m| m.keys().collect())
- .unwrap_or_default();
- let album =
- state.album_state.selected().and_then(|i| albums.get(i));
- artist
- .and_then(|a| library.artists.get(*a))
- .and_then(|m| album.and_then(|al| m.get(*al)))
- .map(|v| v.len())
- .unwrap_or(0)
+ if state.focus == Focus::Settings {
+ let max_len = 3;
+ let i = state.settings_state.selected().unwrap_or(0);
+ if i < max_len - 1 {
+ state.settings_state.select(Some(i + 1));
}
- };
+ } else {
+ let max_len = match state.focus {
+ Focus::Artist => library.artists.len(),
+ Focus::Album => {
+ let artists: Vec<_> = library.artists.keys().collect();
+ state
+ .artist_state
+ .selected()
+ .and_then(|i| artists.get(i))
+ .and_then(|a| library.artists.get(*a))
+ .map(|m| m.len())
+ .unwrap_or(0)
+ }
+ Focus::Track => {
+ let artists: Vec<_> = library.artists.keys().collect();
+ let artist =
+ state.artist_state.selected().and_then(|i| artists.get(i));
+ let albums: Vec<_> = artist
+ .and_then(|a| library.artists.get(*a))
+ .map(|m| m.keys().collect())
+ .unwrap_or_default();
+ let album =
+ state.album_state.selected().and_then(|i| albums.get(i));
+ artist
+ .and_then(|a| library.artists.get(*a))
+ .and_then(|m| album.and_then(|al| m.get(*al)))
+ .map(|v| v.len())
+ .unwrap_or(0)
+ }
+ Focus::Settings => 0,
+ };
- let current_state = match state.focus {
- Focus::Artist => &mut state.artist_state,
- Focus::Album => &mut state.album_state,
- Focus::Track => &mut state.track_state,
- };
- let i = current_state.selected().unwrap_or(0);
- if max_len > 0 && i < max_len - 1 {
- current_state.select(Some(i + 1));
+ let current_state = match state.focus {
+ Focus::Artist => &mut state.artist_state,
+ Focus::Album => &mut state.album_state,
+ Focus::Track => &mut state.track_state,
+ Focus::Settings => &mut state.artist_state,
+ };
+ let i = current_state.selected().unwrap_or(0);
+ if max_len > 0 && i < max_len - 1 {
+ current_state.select(Some(i + 1));
+ }
}
}
KeyCode::Enter => {
- // 再生ロジック
- let artists: Vec<_> = library.artists.keys().collect();
- if let Some(artist_name) =
- state.artist_state.selected().and_then(|i| artists.get(i))
- {
- let artist_data = library.artists.get(*artist_name).unwrap();
- let albums: Vec<_> = artist_data.keys().collect();
+ if state.focus == Focus::Settings {
+ let selected_setting = state.settings_state.selected().unwrap_or(0);
+ if selected_setting == 2 {
+ let devices = audio::DeviceManager::list_device_names();
+ state.open_device_dialog(devices);
+ }
+ } else {
+ let artists: Vec<_> = library.artists.keys().collect();
+ if let Some(artist_name) =
+ state.artist_state.selected().and_then(|i| artists.get(i))
+ {
+ let artist_data = library.artists.get(*artist_name).unwrap();
+ let albums: Vec<_> = artist_data.keys().collect();
- match state.focus {
- Focus::Track => {
- if let Some(album_name) =
- state.album_state.selected().and_then(|i| albums.get(i))
- {
- let tracks = artist_data.get(*album_name).unwrap();
- if let Some(track) =
- state.track_state.selected().and_then(|i| tracks.get(i))
+ match state.focus {
+ Focus::Track => {
+ if let Some(album_name) =
+ state.album_state.selected().and_then(|i| albums.get(i))
{
- audioengine.stop();
- audioengine
- .append_a_track(track.path.to_str().unwrap())?;
- audioengine.play();
+ let tracks = artist_data.get(*album_name).unwrap();
+ if let Some(track) = state
+ .track_state
+ .selected()
+ .and_then(|i| tracks.get(i))
+ {
+ audioengine.stop();
+ audioengine
+ .append_a_track(track.path.to_str().unwrap())?;
+ audioengine.play();
+ }
}
}
- }
- Focus::Album => {
- if let Some(album_name) =
- state.album_state.selected().and_then(|i| albums.get(i))
- {
- let tracks = artist_data.get(*album_name).unwrap();
- audioengine.stop();
- for track in tracks {
- audioengine
- .append_a_track(track.path.to_str().unwrap())?;
+ Focus::Album => {
+ if let Some(album_name) =
+ state.album_state.selected().and_then(|i| albums.get(i))
+ {
+ let tracks = artist_data.get(*album_name).unwrap();
+ audioengine.stop();
+ for track in tracks {
+ audioengine
+ .append_a_track(track.path.to_str().unwrap())?;
+ }
+ audioengine.play();
}
- audioengine.play();
}
+ _ => {}
}
- _ => {}
}
}
}
diff --git a/src/setting.rs b/src/setting.rs
@@ -43,6 +43,17 @@ impl Settings {
&self.audio
}
+ pub fn audio_mut(&mut self) -> &mut AudioSettings {
+ &mut self.audio
+ }
+
+ pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
+ let path = Self::get_path().ok_or("Failed to get config directory path")?;
+ let content = toml::to_string(self)?;
+ std::fs::write(path, content)?;
+ Ok(())
+ }
+
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::get_path().ok_or("Failed to get config directory path")?;
if !path.exists() {
@@ -72,4 +83,14 @@ impl AudioSettings {
pub fn volume(&self) -> f32 {
self.volume
}
+
+ pub fn set_volume(&mut self, volume: f32) {
+ self.volume = volume;
+ }
+ pub fn output_device(&self) -> &str {
+ &self.output_device
+ }
+ pub fn set_output_device(&mut self, output_device: String) {
+ self.output_device = output_device;
+ }
}
diff --git a/src/ui.rs b/src/ui.rs
@@ -1,17 +1,19 @@
use ratatui::{
Frame,
- layout::{Constraint, Direction, Layout},
+ layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
- widgets::{Block, Borders, List, ListItem, ListState},
+ widgets::{Block, Borders, Clear, List, ListItem, ListState},
};
use crate::library::Library;
+use crate::setting::Settings;
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Focus {
Artist,
Album,
Track,
+ Settings,
}
pub struct AppState {
@@ -19,100 +21,156 @@ pub struct AppState {
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
+ pub settings_state: ListState,
+ pub is_device_dialog_open: bool,
+ pub available_devices: Vec<String>,
+ pub device_dialog_state: ListState,
}
impl AppState {
pub fn new() -> Self {
let mut artist_state = ListState::default();
artist_state.select(Some(0));
+ let mut settings_state = ListState::default();
+ settings_state.select(Some(0));
+ let mut device_dialog_state = ListState::default();
+ device_dialog_state.select(Some(0));
Self {
focus: Focus::Artist,
artist_state,
album_state: ListState::default(),
track_state: ListState::default(),
+ settings_state,
+ is_device_dialog_open: false,
+ available_devices: Vec::new(),
+ device_dialog_state,
}
}
+
+ pub fn open_device_dialog(&mut self, devices: Vec<String>) {
+ self.available_devices = devices;
+ self.is_device_dialog_open = true;
+ self.device_dialog_state.select(Some(0));
+ }
+
+ pub fn close_device_dialog(&mut self) {
+ self.is_device_dialog_open = false;
+ }
}
-pub fn render(f: &mut Frame, library: &Library, state: &mut AppState) {
- let chunks = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage(33),
- Constraint::Percentage(33),
- Constraint::Percentage(34),
- ])
- .split(f.area());
+pub fn render(f: &mut Frame, library: &Library, state: &mut AppState, settings: &Settings) {
+ if state.focus == Focus::Settings {
+ render_settings(f, state, settings);
+ } else {
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(33),
+ Constraint::Percentage(33),
+ Constraint::Percentage(34),
+ ])
+ .split(f.area());
- // 1. Artists
- let artists: Vec<String> = library.artists.keys().cloned().collect();
- let artist_items: Vec<ListItem> = artists
- .iter()
- .map(|name| ListItem::new(name.as_str()))
- .collect();
+ let artists: Vec<String> = library.artists.keys().cloned().collect();
+ let artist_items: Vec<ListItem> = artists
+ .iter()
+ .map(|name| ListItem::new(name.as_str()))
+ .collect();
+ render_list(
+ f,
+ "Artist",
+ artist_items,
+ chunks[0],
+ state.focus == Focus::Artist,
+ &mut state.artist_state,
+ );
- render_list(
- f,
- "Artist",
- artist_items,
- chunks[0],
- state.focus == Focus::Artist,
- &mut state.artist_state,
- );
-
- // 2. Albums (selected artist)
- let selected_artist = state
- .artist_state
- .select_index()
- .and_then(|i| artists.get(i));
- let mut albums: Vec<String> = Vec::new();
- if let Some(artist) = selected_artist {
- if let Some(artist_data) = library.artists.get(artist) {
- albums = artist_data.keys().cloned().collect();
+ let selected_artist = state
+ .artist_state
+ .select_index()
+ .and_then(|i| artists.get(i));
+ let mut albums: Vec<String> = Vec::new();
+ if let Some(artist) = selected_artist {
+ if let Some(artist_data) = library.artists.get(artist) {
+ albums = artist_data.keys().cloned().collect();
+ }
}
- }
- let album_items: Vec<ListItem> = albums
- .iter()
- .map(|name| ListItem::new(name.as_str()))
- .collect();
+ let album_items: Vec<ListItem> = albums
+ .iter()
+ .map(|name| ListItem::new(name.as_str()))
+ .collect();
+ render_list(
+ f,
+ "Album",
+ album_items,
+ chunks[1],
+ state.focus == Focus::Album,
+ &mut state.album_state,
+ );
- render_list(
- f,
- "Album",
- album_items,
- chunks[1],
- state.focus == Focus::Album,
- &mut state.album_state,
- );
-
- // 3. Tracks (selected album)
- let selected_album = state.album_state.select_index().and_then(|i| albums.get(i));
- let mut tracks: Vec<String> = Vec::new();
- if let (Some(artist), Some(album)) = (selected_artist, selected_album) {
- if let Some(album_data) = library.artists.get(artist).and_then(|a| a.get(album)) {
- tracks = album_data.iter().map(|t| t.title.clone()).collect();
+ let selected_album = state.album_state.select_index().and_then(|i| albums.get(i));
+ let mut tracks: Vec<String> = Vec::new();
+ if let (Some(artist), Some(album)) = (selected_artist, selected_album) {
+ if let Some(album_data) = library.artists.get(artist).and_then(|a| a.get(album)) {
+ tracks = album_data.iter().map(|t| t.title.clone()).collect();
+ }
}
+ let track_items: Vec<ListItem> = tracks
+ .iter()
+ .map(|name| ListItem::new(name.as_str()))
+ .collect();
+ render_list(
+ f,
+ "Track",
+ track_items,
+ chunks[2],
+ state.focus == Focus::Track,
+ &mut state.track_state,
+ );
}
- let track_items: Vec<ListItem> = tracks
- .iter()
- .map(|name| ListItem::new(name.as_str()))
- .collect();
- render_list(
- f,
- "Track",
- track_items,
- chunks[2],
- state.focus == Focus::Track,
- &mut state.track_state,
- );
+ if state.is_device_dialog_open {
+ render_device_dialog(f, state);
+ }
+}
+
+pub fn render_settings(frame: &mut Frame, state: &mut AppState, settings: &Settings) {
+ let area = frame.area();
+ let settings_items = vec![
+ ListItem::new(format!("Volume: {:.2}", settings.audio().volume())),
+ ListItem::new(format!("Library Path: {}", settings.audio().library_path())),
+ ListItem::new(format!(
+ "Output Device: {}",
+ settings.audio().output_device()
+ )),
+ ];
+
+ let list = List::new(settings_items)
+ .block(
+ Block::default()
+ .title("Settings")
+ .borders(Borders::ALL)
+ .border_style(if state.focus == Focus::Settings {
+ Style::default().fg(Color::Yellow)
+ } else {
+ Style::default()
+ }),
+ )
+ .highlight_style(
+ Style::default()
+ .bg(Color::Blue)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(">> ");
+
+ frame.render_stateful_widget(list, area, &mut state.settings_state);
}
fn render_list(
- f: &mut Frame,
+ frame: &mut Frame,
title: &str,
items: Vec<ListItem>,
- area: ratatui::layout::Rect,
+ area: Rect,
focused: bool,
state: &mut ListState,
) {
@@ -121,7 +179,6 @@ fn render_list(
} else {
Style::default()
};
-
let list = List::new(items)
.block(
Block::default()
@@ -135,16 +192,62 @@ fn render_list(
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
-
- f.render_stateful_widget(list, area, state);
+ frame.render_stateful_widget(list, area, state);
}
-// Helper trait to get selection index safely
trait ListStateExt {
fn select_index(&self) -> Option<usize>;
}
+
impl ListStateExt for ListState {
fn select_index(&self) -> Option<usize> {
self.selected()
}
}
+
+pub fn render_device_dialog(frame: &mut Frame, state: &mut AppState) {
+ let area = centered_rect(60, 40, frame.area());
+ frame.render_widget(Clear, area);
+
+ let items: Vec<ListItem> = state
+ .available_devices
+ .iter()
+ .map(|name| ListItem::new(name.as_str()))
+ .collect();
+
+ let list = List::new(items)
+ .block(
+ Block::default()
+ .title("Select Output Device")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Yellow)),
+ )
+ .highlight_style(
+ Style::default()
+ .bg(Color::Blue)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(">> ");
+
+ frame.render_stateful_widget(list, area, &mut state.device_dialog_state);
+}
+
+pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
+ let popup_layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Percentage((100 - percent_y) / 2),
+ Constraint::Percentage(percent_y),
+ Constraint::Percentage((100 - percent_y) / 2),
+ ])
+ .split(r);
+
+ Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage((100 - percent_x) / 2),
+ Constraint::Percentage(percent_x),
+ Constraint::Percentage((100 - percent_x) / 2),
+ ])
+ .split(popup_layout[1])[1]
+}