mod error;
mod models;
mod progress;
use crate::models::{ContentElement, EpubReader};
use crate::progress::{BookProgress, ProgressStore};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::text::Text;
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Margin, Rect},
widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
};
use ratatui_image::{StatefulImage, picker::Picker, protocol::StatefulProtocol};
use std::{
collections::HashMap,
error::Error,
io,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(PartialEq, Clone, Copy)]
enum SortCriteria {
Recent,
Title,
Author,
}
enum ViewMode {
Reading,
Cover,
ChapterBrowser,
Library,
}
struct App {
reader: EpubReader,
book_id: String,
book_path: String,
current_chapter: usize,
scroll: usize,
scroll_to_start: bool,
scroll_to_end: bool,
chapter_scrolls: HashMap<usize, usize>,
image_protocols: HashMap<String, StatefulProtocol>,
mode: ViewMode,
cover_protocol: Option<StatefulProtocol>,
chapter_list_state: ListState,
chapter_titles: Vec<String>,
library_state: ListState,
library_books: Vec<BookProgress>,
library_cover_protocol: Option<StatefulProtocol>,
sort_criteria: SortCriteria,
}
impl App {
fn new(
reader: EpubReader,
book_id: String,
book_path: String,
picker: &mut Picker,
progress: BookProgress,
) -> Self {
let cover_protocol = if let Ok(Some(data)) = reader.cover_image() {
if let Ok(img) = image::load_from_memory(&data) {
Some(picker.new_resize_protocol(img))
} else {
None
}
} else {
None
};
let current_chapter = progress.current_chapter;
let scroll = progress
.chapter_scrolls
.get(¤t_chapter)
.cloned()
.unwrap_or(0);
let (scroll_to_end, scroll_to_start) = (false, false);
let mut chapter_titles = Vec::new();
for i in 0..reader.chapter_count() {
if let Ok(chapter) = reader.get_chapter(i) {
chapter_titles.push(format!("Chapter {}: {}", i + 1, chapter.title));
} else {
chapter_titles.push(format!("Chapter {}", i + 1));
}
}
let mut list_state = ListState::default();
list_state.select(Some(current_chapter));
let store = ProgressStore::load();
let mut library_books: Vec<BookProgress> = store.books.values().cloned().collect();
library_books.sort_by_key(|b| std::cmp::Reverse(b.last_read));
let library_state = ListState::default();
App {
reader,
book_id,
book_path,
current_chapter,
scroll,
scroll_to_end,
scroll_to_start,
chapter_scrolls: progress.chapter_scrolls,
image_protocols: HashMap::new(),
mode: if cover_protocol.is_some() && current_chapter == 0 && scroll == 0 {
ViewMode::Cover
} else {
ViewMode::Reading
},
cover_protocol,
chapter_list_state: list_state,
chapter_titles,
library_state,
library_books,
library_cover_protocol: None,
sort_criteria: SortCriteria::Recent,
}
}
fn save_progress(&self) {
let mut store = ProgressStore::load();
let mut chapter_scrolls = self.chapter_scrolls.clone();
chapter_scrolls.insert(self.current_chapter, self.scroll);
let last_read = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
store.last_book_path = Some(self.book_path.clone());
store.set_book(
self.book_id.clone(),
BookProgress {
current_chapter: self.current_chapter,
chapter_scrolls,
title: self.reader.title().to_string(),
author: self.reader.author().to_string(),
path: self.book_path.clone(),
last_read,
},
);
let _ = store.save();
}
fn jump_to_chapter(&mut self, index: usize) {
if index < self.reader.chapter_count() {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter = index;
self.scroll = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
self.mode = ViewMode::Reading;
}
}
fn next_chapter(&mut self) {
if self.current_chapter + 1 < self.reader.chapter_count() {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter += 1;
self.scroll = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
}
}
fn previous_chapter(&mut self) {
if self.current_chapter > 0 {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter -= 1;
self.scroll = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
}
}
fn toggle_mode(&mut self) {
self.mode = match self.mode {
ViewMode::Reading => ViewMode::Cover,
ViewMode::Cover => ViewMode::Reading,
ViewMode::ChapterBrowser => ViewMode::Reading,
ViewMode::Library => ViewMode::Reading,
};
}
fn open_chapter_browser(&mut self) {
self.chapter_list_state.select(Some(self.current_chapter));
self.mode = ViewMode::ChapterBrowser;
}
fn open_library(&mut self, picker: &mut Picker) {
let store = ProgressStore::load();
self.library_books = store
.books
.values()
.filter(|b| std::path::Path::new(&b.path).exists())
.cloned()
.collect();
self.sort_library();
let current_index = self
.library_books
.iter()
.position(|b| b.path == self.book_path);
if self.library_state.selected().is_none() {
self.library_state
.select(current_index.or(if self.library_books.is_empty() {
None
} else {
Some(0)
}));
}
self.mode = ViewMode::Library;
self.update_library_cover(picker);
}
fn sort_library(&mut self) {
match self.sort_criteria {
SortCriteria::Recent => self
.library_books
.sort_by_key(|b| std::cmp::Reverse(b.last_read)),
SortCriteria::Title => self.library_books.sort_by(|a, b| a.title.cmp(&b.title)),
SortCriteria::Author => self.library_books.sort_by(|a, b| a.author.cmp(&b.author)),
}
}
fn delete_book(&mut self, i: usize, store: &mut ProgressStore) {
if i < self.library_books.len() {
let book = &self.library_books[i];
store.remove_book(&book.title, &book.author);
self.library_books.remove(i);
let _ = store.save();
}
}
fn next_sort_criteria(&mut self, picker: &mut Picker) {
self.sort_criteria = match self.sort_criteria {
SortCriteria::Recent => SortCriteria::Title,
SortCriteria::Title => SortCriteria::Author,
SortCriteria::Author => SortCriteria::Recent,
};
self.sort_library();
self.library_state.select(Some(0));
self.update_library_cover(picker);
}
fn update_library_cover(&mut self, picker: &mut Picker) {
if let Some(selected) = self.library_state.selected()
&& let Some(book) = self.library_books.get(selected)
&& let Ok(reader) = EpubReader::new(&book.path)
&& let Ok(Some(data)) = reader.cover_image()
&& let Ok(img) = image::load_from_memory(&data)
{
self.library_cover_protocol = Some(picker.new_resize_protocol(img));
return;
}
self.library_cover_protocol = None;
}
fn select_book(&mut self) -> Option<String> {
if let Some(selected) = self.library_state.selected()
&& let Some(book) = self.library_books.get(selected)
{
if book.path != self.book_path {
return Some(book.path.clone());
} else {
self.mode = ViewMode::Reading;
}
}
None
}
fn go_to_end(&mut self) {
self.scroll_to_end = true;
}
fn go_to_start(&mut self) {
self.scroll_to_start = true;
}
fn scroll_down(&mut self, i: usize) {
self.scroll = self.scroll.saturating_add(i);
}
fn scroll_up(&mut self, i: usize) {
self.scroll = self.scroll.saturating_sub(i);
}
fn get_image_protocol(
&mut self,
path: &str,
picker: &mut Picker,
) -> Option<&mut StatefulProtocol> {
if !self.image_protocols.contains_key(path)
&& let Ok(data) = self.reader.get_image_by_path(path)
&& let Ok(img) = image::load_from_memory(&data)
{
self.image_protocols
.insert(path.to_string(), picker.new_resize_protocol(img));
}
self.image_protocols.get_mut(path)
}
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let progress_store = ProgressStore::load();
let mut current_path = if args.len() >= 2 {
args[1].clone()
} else if let Some(ref last_path) = progress_store.last_book_path {
last_path.clone()
} else {
eprintln!("Usage: {} <epub_file>", args[0]);
std::process::exit(1);
};
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
let absolute_path = match std::fs::canonicalize(¤t_path) {
Ok(p) => p,
Err(e) => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
eprintln!("Failed to resolve path '{}': {}", current_path, e);
return Ok(());
}
};
let file_path_str = absolute_path.to_string_lossy().to_string();
let reader = match EpubReader::new(&file_path_str) {
Ok(r) => r,
Err(e) => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
eprintln!("Failed to open EPUB '{}': {}", file_path_str, e);
return Ok(());
}
};
let book_id = std::path::Path::new(&file_path_str)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| file_path_str.clone());
let progress_store = ProgressStore::load();
let book_progress = progress_store.get_book(&book_id);
let mut picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let mut app = App::new(reader, book_id, file_path_str, &mut picker, book_progress);
let mut picker_clone = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let res = run_app(&mut terminal, &mut app, &mut picker_clone);
app.save_progress();
match res {
Ok(Some(new_path)) => {
current_path = new_path;
terminal.clear()?;
}
Ok(None) => break,
Err(err) => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Err(err);
}
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
picker: &mut Picker,
) -> Result<Option<String>, Box<dyn Error>>
where
B::Error: 'static,
{
loop {
terminal.draw(|f| ui(f, app, picker))?;
if let Event::Key(key) = event::read()? {
match app.mode {
ViewMode::Library => match key.code {
KeyCode::Char('q') => return Ok(None),
KeyCode::Esc | KeyCode::Char('l') => app.mode = ViewMode::Reading,
KeyCode::Enter => {
if let Some(path) = app.select_book() {
return Ok(Some(path));
}
}
KeyCode::Down | KeyCode::Char('j') if !app.library_books.is_empty() => {
let i = app.library_state.selected().unwrap_or(0);
if i + 1 < app.library_books.len() {
app.library_state.select(Some(i + 1));
app.update_library_cover(picker);
} else {
app.library_state.select(Some(0));
app.update_library_cover(picker);
}
}
KeyCode::Up | KeyCode::Char('k') if !app.library_books.is_empty() => {
let i = app.library_state.selected().unwrap_or(0);
if i > 0 {
app.library_state.select(Some(i - 1));
app.update_library_cover(picker);
} else {
app.library_state.select(Some(app.library_books.len() - 1));
app.update_library_cover(picker);
}
}
KeyCode::Char('d') => {
let i = app.library_state.selected().unwrap_or(0);
let mut store = ProgressStore::load();
app.delete_book(i, &mut store);
if !app.library_books.is_empty() {
if i < app.library_books.len() {
app.library_state.select(Some(i));
app.update_library_cover(picker);
} else {
app.library_state.select(Some(0));
app.update_library_cover(picker);
}
}
}
KeyCode::Char('s') => app.next_sort_criteria(picker),
_ => {}
},
ViewMode::ChapterBrowser => match key.code {
KeyCode::Char('q') => return Ok(None),
KeyCode::Esc | KeyCode::Tab => app.mode = ViewMode::Reading,
KeyCode::Enter => {
if let Some(selected) = app.chapter_list_state.selected() {
app.jump_to_chapter(selected);
}
}
KeyCode::Down | KeyCode::Char('j') => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i + 1 < app.chapter_titles.len() {
app.chapter_list_state.select(Some(i + 1));
}
}
KeyCode::PageDown => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i + 5 < app.chapter_titles.len() {
app.chapter_list_state.select(Some(i + 5));
}
}
KeyCode::Up | KeyCode::Char('k') => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i > 0 {
app.chapter_list_state.select(Some(i - 1));
}
}
KeyCode::PageUp => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i > 5 {
app.chapter_list_state.select(Some(i - 5));
}
}
_ => {}
},
_ => match key.code {
KeyCode::Char('q') => return Ok(None),
KeyCode::Right | KeyCode::Char('n') => app.next_chapter(),
KeyCode::Left | KeyCode::Char('p') => app.previous_chapter(),
KeyCode::Home => app.go_to_start(),
KeyCode::End => app.go_to_end(),
KeyCode::Down | KeyCode::Char('j') => app.scroll_down(1),
KeyCode::PageDown => app.scroll_down(5),
KeyCode::Up | KeyCode::Char('k') => app.scroll_up(1),
KeyCode::PageUp => app.scroll_up(5),
KeyCode::Char('c') => app.toggle_mode(),
KeyCode::Char('l') => app.open_library(picker),
KeyCode::Tab => app.open_chapter_browser(),
_ => {}
},
}
}
}
}
fn ui(f: &mut Frame, app: &mut App, picker: &mut Picker) {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(size);
let title = format!(" {} - {} ", app.reader.title(), app.reader.author());
let header = Paragraph::new(title).block(
Block::default()
.borders(Borders::ALL)
.title("Changxi - EPUB Reader"),
);
f.render_widget(header, chunks[0]);
match app.mode {
ViewMode::ChapterBrowser => {
let area = centered_rect(70, 90, chunks[1]);
let mut list_items = Vec::new();
for i in 0..app.chapter_titles.len() {
let title = &app.chapter_titles[i];
let is_current = i == app.current_chapter;
let prefix = if is_current { "▶ " } else { " " };
let content = format!("{}{}", prefix, title);
let mut text = Text::from(content);
if is_current {
text = text
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Green));
}
list_items.push(ListItem::new(text));
}
let list = List::new(list_items)
.block(Block::default().borders(Borders::ALL).title(" Chapters "))
.highlight_style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.chapter_list_state);
let footer_text = format!(
" Chapter Browser | ↑/↓ or j/k: navigate, Enter: select, Esc: back, q: quit | Chapter {}/{} ",
app.chapter_list_state.selected().unwrap_or(0) + 1,
app.chapter_titles.len()
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
ViewMode::Reading => {
if let Ok(chapter) = app.reader.get_chapter(app.current_chapter) {
let chapter_title =
format!(" Chapter {}: {} ", app.current_chapter + 1, chapter.title);
let inner_area = chunks[1];
let width = inner_area.width.saturating_sub(2) as usize;
let viewport_height = inner_area.height.saturating_sub(2) as usize;
let mut heights = Vec::new();
for element in &chapter.elements {
heights.push(count_lines(element, width));
}
let total_height: usize = heights.iter().sum();
if app.scroll_to_end && total_height > viewport_height {
app.scroll = total_height.saturating_sub(viewport_height);
app.scroll_to_end = false;
}
if app.scroll_to_start {
app.scroll = 0;
app.scroll_to_start = false;
}
if app.scroll + viewport_height > total_height && total_height > viewport_height {
app.scroll = total_height.saturating_sub(viewport_height);
}
let mut current_y_offset = 0;
let content_rect = inner_area.inner(Margin {
vertical: 1,
horizontal: 1,
});
for (i, element) in chapter.elements.iter().enumerate() {
let h = heights[i];
if current_y_offset + h > app.scroll
&& current_y_offset < app.scroll + viewport_height
{
let rel_y = current_y_offset as i32 - app.scroll as i32;
match element {
ContentElement::Text(spans) => {
let start_line = if rel_y < 0 { (-rel_y) as u16 } else { 0 };
let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
let draw_h = (h as u16)
.saturating_sub(start_line)
.min((viewport_height as u16).saturating_sub(draw_y));
if draw_h > 0 {
let area = Rect {
x: content_rect.x,
y: content_rect.y + draw_y,
width: content_rect.width,
height: draw_h,
};
let mut ratatui_spans = Vec::new();
for s in spans {
let mut style = ratatui::style::Style::default();
if s.style.bold {
style =
style.add_modifier(ratatui::style::Modifier::BOLD);
}
if s.style.italic {
style = style
.add_modifier(ratatui::style::Modifier::ITALIC);
}
if s.style.underline {
style = style
.add_modifier(ratatui::style::Modifier::UNDERLINED);
}
if s.style.strikethrough {
style = style.add_modifier(
ratatui::style::Modifier::CROSSED_OUT,
);
}
ratatui_spans
.push(ratatui::text::Span::styled(&s.text, style));
}
let p =
Paragraph::new(ratatui::text::Line::from(ratatui_spans))
.wrap(Wrap { trim: true })
.scroll((start_line, 0));
f.render_widget(p, area);
}
}
ContentElement::Image(path) => {
let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
let visible_h = if rel_y < 0 {
(h as i32 + rel_y).max(0) as u16
} else {
(h as u16).min((viewport_height as u16).saturating_sub(draw_y))
};
if visible_h > 0
&& let Some(protocol) = app.get_image_protocol(path, picker)
{
let area = Rect {
x: content_rect.x,
y: content_rect.y + draw_y,
width: content_rect.width,
height: visible_h,
};
f.render_stateful_widget(
StatefulImage::default(),
area,
protocol,
);
}
}
}
}
current_y_offset += h;
}
f.render_widget(
Block::default().borders(Borders::ALL).title(chapter_title),
chunks[1],
);
let max_scroll = total_height.saturating_sub(viewport_height);
let scroll_position = app.scroll.min(max_scroll);
let mut scrollbar_state = ScrollbarState::new(total_height)
.position(scroll_position)
.viewport_content_length(viewport_height);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
chunks[1].inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
let progress = if total_height > viewport_height {
(app.scroll as f64 / (total_height - viewport_height) as f64 * 100.0).min(100.0)
} else {
100.0
};
let footer_text = format!(
" Chapter {}/{} | {:>3.0}% | Tab: chapters, q: quit, c: cover, l: library, ←/→: prev/next chapter, ↑/↓: scroll ",
app.current_chapter + 1,
app.reader.chapter_count(),
progress
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
}
ViewMode::Cover => {
if let Some(ref mut protocol) = app.cover_protocol {
let area = centered_rect(80, 100, chunks[1]);
f.render_stateful_widget(StatefulImage::default(), area, protocol);
} else {
f.render_widget(
Paragraph::new("No cover image available")
.block(Block::default().borders(Borders::ALL)),
chunks[1],
);
}
let footer_text = format!(
" Cover View | Chapter {}/{} | Tab: chapters, q: quit, c: text, ←/→: prev/next chapter, l: library ",
app.current_chapter + 1,
app.reader.chapter_count()
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
ViewMode::Library => {
let area = centered_rect(80, 80, chunks[1]);
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let mut list_items = Vec::new();
for book in &app.library_books {
let is_current = book.path == app.book_path;
let prefix = if is_current { "▶ " } else { " " };
let display_title = if book.title.is_empty() {
"Unknown Title"
} else {
&book.title
};
let content = format!("{}{}", prefix, display_title);
let mut text = Text::from(content);
if is_current {
text = text
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Green));
}
list_items.push(ListItem::new(text));
}
let sort_text = match app.sort_criteria {
SortCriteria::Recent => "Recent",
SortCriteria::Title => "Title",
SortCriteria::Author => "Author",
};
let list = List::new(list_items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Library (Sort: {}) ", sort_text)),
)
.highlight_style(
ratatui::style::Style::default().bg(ratatui::style::Color::DarkGray),
)
.highlight_symbol("> ");
f.render_stateful_widget(list, layout[0], &mut app.library_state);
let cover_block = Block::default().borders(Borders::ALL);
let cover_area = layout[1];
f.render_widget(cover_block.clone(), cover_area);
if let Some(ref mut protocol) = app.library_cover_protocol {
f.render_stateful_widget(
StatefulImage::default(),
cover_area.inner(Margin {
vertical: 1,
horizontal: 1,
}),
protocol,
);
} else {
f.render_widget(
Paragraph::new("No cover available"),
cover_area.inner(Margin {
vertical: 1,
horizontal: 1,
}),
);
}
let footer_text =
" Library | Enter: select, Esc/l: back, s: sort, q: quit, j/k: navigate ";
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
}
}
fn count_lines(element: &ContentElement, width: usize) -> usize {
match element {
ContentElement::Text(spans) => {
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
let mut count = 0;
for line in text.lines() {
if line.trim().is_empty() {
count += 1;
continue;
}
let wrapped = (line.chars().count() as f64 / width as f64).ceil() as usize;
count += wrapped.max(1);
}
count
}
ContentElement::Image(_) => 30,
}
}
fn centered_rect(
percent_x: u16,
percent_y: u16,
r: ratatui::layout::Rect,
) -> ratatui::layout::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]
}