Initial Comicviewer implementation

This commit is contained in:
burnintuna 2026-07-21 02:12:05 +09:00
commit cb01395612
20 changed files with 4940 additions and 0 deletions

822
src/window.rs Normal file
View file

@ -0,0 +1,822 @@
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Local};
use gtk::gio;
use gtk::gio::prelude::*;
use gtk::glib;
use gtk::prelude::*;
use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries};
use crate::config::{AppConfig, LocationKind, LocationProfile};
use crate::database::Database;
use crate::source::{
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
parent_within_root, root_file, validate_profile,
};
use crate::viewer::ViewerWindow;
use crate::zip_cache::ZipCache;
pub struct ComicWindow {
controller: Rc<BrowserController>,
}
impl ComicWindow {
pub fn new(
application: &gtk::Application,
config: AppConfig,
config_path: PathBuf,
database: Database,
zip_cache: Rc<ZipCache>,
) -> Result<Self> {
let initial_profile = home_profile();
let root_uri: String = root_file(&initial_profile)?.uri().into();
let window = gtk::ApplicationWindow::builder()
.application(application)
.title("Comicviewer")
.default_width(1100)
.default_height(720)
.build();
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some("Comicviewer"))));
window.set_titlebar(Some(&header));
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
root.set_margin_top(12);
root.set_margin_bottom(12);
root.set_margin_start(12);
root.set_margin_end(12);
let locations = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let location_dropdown = gtk::DropDown::from_strings(&["내 홈"]);
location_dropdown.set_hexpand(true);
let add_location_button = gtk::Button::with_label("위치 추가");
let edit_location_button = gtk::Button::with_label("수정");
edit_location_button.set_sensitive(false);
let delete_location_button = gtk::Button::with_label("삭제");
delete_location_button.set_sensitive(false);
locations.append(&gtk::Label::new(Some("위치")));
locations.append(&location_dropdown);
locations.append(&add_location_button);
locations.append(&edit_location_button);
locations.append(&delete_location_button);
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let up_button = gtk::Button::builder()
.icon_name("go-up-symbolic")
.tooltip_text("상위 폴더")
.build();
let path_entry = gtk::Entry::builder().hexpand(true).build();
let open_button = gtk::Button::with_label("이동");
let refresh_button = gtk::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("새로고침")
.build();
navigation.append(&up_button);
navigation.append(&path_entry);
navigation.append(&open_button);
navigation.append(&refresh_button);
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let search_entry = gtk::SearchEntry::builder()
.placeholder_text("현재 폴더에서 검색")
.hexpand(true)
.build();
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
let direction_button = gtk::ToggleButton::with_label("오름차순");
let fast_loading_switch = gtk::Switch::builder()
.active(config.fast_image_loading)
.tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지")
.build();
controls.append(&search_entry);
controls.append(&sort_dropdown);
controls.append(&direction_button);
controls.append(&gtk::Label::new(Some("빠른 이미지 로딩")));
controls.append(&fast_loading_switch);
let list_box = gtk::ListBox::new();
list_box.set_selection_mode(gtk::SelectionMode::Single);
list_box.set_activate_on_single_click(false);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.child(&list_box)
.build();
let status_label = gtk::Label::builder().xalign(0.0).build();
root.append(&locations);
root.append(&navigation);
root.append(&controls);
root.append(&scrolled);
root.append(&status_label);
window.set_child(Some(&root));
let controller = Rc::new(BrowserController {
window,
location_dropdown,
edit_location_button,
delete_location_button,
path_entry,
search_entry,
sort_dropdown,
direction_button,
list_box,
status_label,
state: RefCell::new(BrowserState {
profile_index: 0,
root_uri: root_uri.clone(),
current_uri: root_uri,
entries: Vec::new(),
visible_entries: Vec::new(),
sort_field: SortField::Name,
sort_direction: SortDirection::Ascending,
generation: 0,
typeahead_query: String::new(),
typeahead_last_input: None,
}),
config: RefCell::new(config),
config_path,
database: Rc::new(database),
zip_cache,
});
controller.rebuild_profiles();
controller.connect_signals(
open_button,
refresh_button,
up_button,
add_location_button,
fast_loading_switch,
);
controller.refresh();
Ok(Self { controller })
}
pub fn present(&self) {
self.controller.window.present();
}
}
struct BrowserState {
profile_index: usize,
root_uri: String,
current_uri: String,
entries: Vec<BrowserEntry>,
visible_entries: Vec<BrowserEntry>,
sort_field: SortField,
sort_direction: SortDirection,
generation: u64,
typeahead_query: String,
typeahead_last_input: Option<Instant>,
}
struct BrowserController {
window: gtk::ApplicationWindow,
location_dropdown: gtk::DropDown,
edit_location_button: gtk::Button,
delete_location_button: gtk::Button,
path_entry: gtk::Entry,
search_entry: gtk::SearchEntry,
sort_dropdown: gtk::DropDown,
direction_button: gtk::ToggleButton,
list_box: gtk::ListBox,
status_label: gtk::Label,
state: RefCell<BrowserState>,
config: RefCell<AppConfig>,
config_path: PathBuf,
database: Rc<Database>,
zip_cache: Rc<ZipCache>,
}
impl BrowserController {
fn connect_signals(
self: &Rc<Self>,
open_button: gtk::Button,
refresh_button: gtk::Button,
up_button: gtk::Button,
add_location_button: gtk::Button,
fast_loading_switch: gtk::Switch,
) {
let controller = Rc::clone(self);
open_button.connect_clicked(move |_| controller.open_entered_path());
let controller = Rc::clone(self);
self.path_entry
.connect_activate(move |_| controller.open_entered_path());
let controller = Rc::clone(self);
refresh_button.connect_clicked(move |_| controller.refresh());
let controller = Rc::clone(self);
up_button.connect_clicked(move |_| controller.go_up());
let controller = Rc::clone(self);
add_location_button.connect_clicked(move |_| controller.show_location_dialog(None));
let controller = Rc::clone(self);
self.edit_location_button.connect_clicked(move |_| {
if let Some(index) = controller.selected_config_index() {
controller.show_location_dialog(Some(index));
}
});
let controller = Rc::clone(self);
self.delete_location_button.connect_clicked(move |_| {
if let Some(index) = controller.selected_config_index() {
controller.show_delete_location_dialog(index);
}
});
let controller = Rc::clone(self);
self.location_dropdown
.connect_selected_notify(move |_| controller.select_profile());
let controller = Rc::clone(self);
self.search_entry
.connect_search_changed(move |_| controller.render());
let controller = Rc::clone(self);
self.sort_dropdown
.connect_selected_notify(move |_| controller.change_sort_field());
let controller = Rc::clone(self);
self.direction_button
.connect_toggled(move |_| controller.change_sort_direction());
let controller = Rc::clone(self);
self.list_box
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
let key_controller = gtk::EventControllerKey::new();
let controller = Rc::clone(self);
key_controller
.connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers));
self.list_box.add_controller(key_controller);
let controller = Rc::clone(self);
fast_loading_switch.connect_active_notify(move |switch| {
controller.set_fast_image_loading(switch);
});
}
fn profiles(&self) -> Vec<LocationProfile> {
let mut profiles = vec![home_profile()];
profiles.extend(self.config.borrow().locations.clone());
profiles
}
fn rebuild_profiles(&self) {
let profiles = self.profiles();
let names: Vec<_> = profiles
.iter()
.map(|profile| profile.name.as_str())
.collect();
self.location_dropdown
.set_model(Some(&gtk::StringList::new(&names)));
}
fn select_profile(self: &Rc<Self>) {
let profile_index = self.location_dropdown.selected() as usize;
let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len();
self.edit_location_button.set_sensitive(editable);
self.delete_location_button.set_sensitive(editable);
let profiles = self.profiles();
let Some(profile) = profiles.get(profile_index) else {
return;
};
match root_file(profile) {
Ok(root) => {
let root_uri: String = root.uri().into();
let mut state = self.state.borrow_mut();
state.profile_index = profile_index;
state.root_uri = root_uri.clone();
state.current_uri = root_uri;
drop(state);
self.search_entry.set_text("");
self.refresh();
}
Err(error) => self.status_label.set_text(&format!("{error:#}")),
}
}
fn open_entered_path(self: &Rc<Self>) {
let profile = self.profiles()[self.state.borrow().profile_index].clone();
let entered = self.path_entry.text();
let file = file_from_user_input(profile.kind, entered.as_str());
let root = gio::File::for_uri(&self.state.borrow().root_uri);
if !is_within_root(&file, &root) {
self.status_label
.set_text("저장된 위치의 루트 아래 경로만 열 수 있습니다.");
return;
}
self.state.borrow_mut().current_uri = file.uri().into();
self.refresh();
}
fn go_up(self: &Rc<Self>) {
let state = self.state.borrow();
let current = gio::File::for_uri(&state.current_uri);
let root = gio::File::for_uri(&state.root_uri);
drop(state);
if let Some(parent) = parent_within_root(&current, &root) {
self.state.borrow_mut().current_uri = parent.uri().into();
self.refresh();
}
}
fn refresh(self: &Rc<Self>) {
let (profile, uri, generation) = {
let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1);
state.typeahead_query.clear();
state.typeahead_last_input = None;
(
self.profiles()[state.profile_index].clone(),
state.current_uri.clone(),
state.generation,
)
};
self.path_entry.set_text(&display_uri(&uri));
self.status_label
.set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)");
let operation = mount_operation(&profile, &self.window);
let progress_controller = Rc::downgrade(self);
let controller = Rc::downgrade(self);
glib::MainContext::default().spawn_local(async move {
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
let Some(controller) = progress_controller.upgrade() else {
return;
};
if controller.state.borrow().generation == generation {
controller.status_label.set_text(&format!(
"폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)"
));
}
})
.await;
let Some(controller) = controller.upgrade() else {
return;
};
if controller.state.borrow().generation != generation {
return;
}
match result {
Ok(entries) => {
controller.state.borrow_mut().entries = entries;
controller.render();
}
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
}
});
}
fn render(&self) {
while let Some(child) = self.list_box.first_child() {
self.list_box.remove(&child);
}
let query = self.search_entry.text().to_lowercase();
let mut state = self.state.borrow_mut();
let mut visible_entries: Vec<_> = state
.entries
.iter()
.filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
.cloned()
.collect();
sort_entries(&mut visible_entries, state.sort_field, state.sort_direction);
for entry in &visible_entries {
self.list_box.append(&entry_row(entry));
}
let count = visible_entries.len();
state.visible_entries = visible_entries;
self.status_label.set_text(&format!("{count}개 항목"));
}
fn typeahead(
&self,
key: gtk::gdk::Key,
modifiers: gtk::gdk::ModifierType,
) -> glib::Propagation {
let blocked = gtk::gdk::ModifierType::CONTROL_MASK
| gtk::gdk::ModifierType::ALT_MASK
| gtk::gdk::ModifierType::SUPER_MASK;
if modifiers.intersects(blocked) {
return glib::Propagation::Proceed;
}
let Some(character) = key
.to_unicode()
.filter(|character| character.is_alphanumeric())
else {
return glib::Propagation::Proceed;
};
let selected = self.list_box.selected_row().map(|row| row.index() as usize);
let now = Instant::now();
let mut state = self.state.borrow_mut();
if state
.typeahead_last_input
.is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200))
{
state.typeahead_query.clear();
}
let input = character.to_lowercase().collect::<String>();
let repeated_single =
state.typeahead_query.chars().count() == 1 && state.typeahead_query == input;
if !repeated_single {
state.typeahead_query.push_str(&input);
}
state.typeahead_last_input = Some(now);
let query = state.typeahead_query.clone();
let start = if repeated_single {
selected.map_or(0, |index| index + 1)
} else {
0
};
let found = find_prefix_index(&state.visible_entries, &query, start);
let found_name = found.map(|index| state.visible_entries[index].name.clone());
drop(state);
if let Some(index) = found
&& let Some(row) = self.list_box.row_at_index(index as i32)
{
self.list_box.select_row(Some(&row));
let _ = row.grab_focus();
self.status_label.set_text(&format!(
"'{query}'로 이동: {}",
found_name.unwrap_or_default()
));
} else {
self.status_label
.set_text(&format!("'{query}'로 시작하는 항목이 없습니다."));
}
glib::Propagation::Stop
}
fn change_sort_field(&self) {
self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() {
1 => SortField::Modified,
2 => SortField::Created,
_ => SortField::Name,
};
self.render();
}
fn change_sort_direction(&self) {
let direction = if self.direction_button.is_active() {
self.direction_button.set_label("내림차순");
SortDirection::Descending
} else {
self.direction_button.set_label("오름차순");
SortDirection::Ascending
};
self.state.borrow_mut().sort_direction = direction;
self.render();
}
fn activate_row(self: &Rc<Self>, index: i32) {
let Some(entry) = self
.state
.borrow()
.visible_entries
.get(index as usize)
.cloned()
else {
return;
};
match entry.kind {
EntryKind::Directory => {
self.state.borrow_mut().current_uri = entry.uri;
self.search_entry.set_text("");
self.refresh();
}
EntryKind::ZipArchive => self.open_archive(entry),
}
}
fn open_archive(self: &Rc<Self>, entry: BrowserEntry) {
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
let progress_label = self.status_label.clone();
let controller = Rc::clone(self);
self.zip_cache.prepare(
entry,
move |current, total| {
let percent = if total > 0 { current * 100 / total } else { 0 };
progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%"));
},
move |result| match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
let Some(application) = controller.window.application() else {
return;
};
ViewerWindow::open(
&application,
source,
Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading,
);
}
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
},
);
}
fn set_fast_image_loading(&self, switch: &gtk::Switch) {
let active = switch.is_active();
let previous = self.config.borrow().fast_image_loading;
if active == previous {
return;
}
let mut updated = self.config.borrow().clone();
updated.fast_image_loading = active;
match updated.save(&self.config_path) {
Ok(()) => {
*self.config.borrow_mut() = updated;
self.status_label.set_text(if active {
"빠른 이미지 로딩을 켰습니다."
} else {
"빠른 이미지 로딩을 껐습니다."
});
}
Err(error) => {
self.status_label
.set_text(&format!("설정을 저장할 수 없습니다: {error:#}"));
switch.set_active(previous);
}
}
}
fn selected_config_index(&self) -> Option<usize> {
let index = (self.location_dropdown.selected() as usize).checked_sub(1)?;
(index < self.config.borrow().locations.len()).then_some(index)
}
fn show_location_dialog(self: &Rc<Self>, edit_index: Option<usize>) {
let existing =
edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned());
let dialog = gtk::Window::builder()
.title(if existing.is_some() {
"위치 수정"
} else {
"위치 추가"
})
.transient_for(&self.window)
.modal(true)
.default_width(520)
.build();
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
let grid = gtk::Grid::builder()
.row_spacing(8)
.column_spacing(10)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build();
let kind = gtk::DropDown::from_strings(&["로컬", "SMB", "FTP", "SFTP"]);
let name = gtk::Entry::new();
let address = gtk::Entry::builder()
.placeholder_text("smb://server/share 또는 /home/user/comics")
.build();
let username = gtk::Entry::new();
let password = gtk::Entry::builder().visibility(false).build();
let domain = gtk::Entry::new();
if let Some(profile) = &existing {
kind.set_selected(location_kind_index(profile.kind));
name.set_text(&profile.name);
address.set_text(&profile.address);
username.set_text(&profile.username);
password.set_text(&profile.password);
domain.set_text(&profile.domain);
}
for (row, label, widget) in [
(0, "종류", kind.clone().upcast::<gtk::Widget>()),
(1, "이름", name.clone().upcast()),
(2, "주소", address.clone().upcast()),
(3, "사용자", username.clone().upcast()),
(4, "비밀번호", password.clone().upcast()),
(5, "SMB 도메인", domain.clone().upcast()),
] {
grid.attach(&gtk::Label::new(Some(label)), 0, row, 1, 1);
grid.attach(&widget, 1, row, 1, 1);
}
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
actions.set_halign(gtk::Align::End);
actions.set_margin_bottom(12);
actions.set_margin_end(12);
let cancel_button = gtk::Button::with_label("취소");
let save_button = gtk::Button::with_label("저장");
actions.append(&cancel_button);
actions.append(&save_button);
content.append(&grid);
content.append(&actions);
dialog.set_child(Some(&content));
let dialog_to_close = dialog.clone();
cancel_button.connect_clicked(move |_| dialog_to_close.close());
let controller = Rc::clone(self);
let dialog_to_close = dialog.clone();
save_button.connect_clicked(move |_| {
let profile = LocationProfile {
name: name.text().into(),
kind: match kind.selected() {
1 => LocationKind::Smb,
2 => LocationKind::Ftp,
3 => LocationKind::Sftp,
_ => LocationKind::Local,
},
address: address.text().into(),
username: username.text().into(),
password: password.text().into(),
domain: domain.text().into(),
};
if let Err(error) = validate_profile(&profile) {
controller.status_label.set_text(&format!("{error:#}"));
return;
}
let mut updated = controller.config.borrow().clone();
let selected_profile = if let Some(index) = edit_index {
let Some(existing) = updated.locations.get_mut(index) else {
controller
.status_label
.set_text("수정할 위치를 찾을 수 없습니다.");
return;
};
*existing = profile;
index + 1
} else {
updated.locations.push(profile);
updated.locations.len()
};
if let Err(error) = updated.save(&controller.config_path) {
controller.status_label.set_text(&format!("{error:#}"));
return;
}
*controller.config.borrow_mut() = updated;
controller.rebuild_profiles();
controller
.location_dropdown
.set_selected(selected_profile as u32);
dialog_to_close.close();
});
dialog.present();
}
fn show_delete_location_dialog(self: &Rc<Self>, index: usize) {
let Some(profile) = self.config.borrow().locations.get(index).cloned() else {
return;
};
let dialog = gtk::Window::builder()
.title("위치 삭제")
.transient_for(&self.window)
.modal(true)
.default_width(420)
.build();
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
content.set_margin_top(18);
content.set_margin_bottom(12);
content.set_margin_start(18);
content.set_margin_end(18);
let message = gtk::Label::new(Some(&format!(
"저장된 위치 '{}'을(를) 삭제하시겠습니까?\n서버의 파일은 삭제되지 않습니다.",
profile.name
)));
message.set_wrap(true);
message.set_xalign(0.0);
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
actions.set_halign(gtk::Align::End);
let cancel_button = gtk::Button::with_label("취소");
let delete_button = gtk::Button::with_label("삭제");
delete_button.add_css_class("destructive-action");
actions.append(&cancel_button);
actions.append(&delete_button);
content.append(&message);
content.append(&actions);
dialog.set_child(Some(&content));
let dialog_to_close = dialog.clone();
cancel_button.connect_clicked(move |_| dialog_to_close.close());
let controller = Rc::clone(self);
let dialog_to_close = dialog.clone();
delete_button.connect_clicked(move |_| {
let mut updated = controller.config.borrow().clone();
if index >= updated.locations.len() {
controller
.status_label
.set_text("삭제할 위치를 찾을 수 없습니다.");
dialog_to_close.close();
return;
}
updated.locations.remove(index);
if let Err(error) = updated.save(&controller.config_path) {
controller.status_label.set_text(&format!("{error:#}"));
return;
}
*controller.config.borrow_mut() = updated;
controller.rebuild_profiles();
controller.location_dropdown.set_selected(0);
controller
.status_label
.set_text("저장된 위치를 삭제했습니다.");
dialog_to_close.close();
});
dialog.present();
}
}
fn location_kind_index(kind: LocationKind) -> u32 {
match kind {
LocationKind::Local => 0,
LocationKind::Smb => 1,
LocationKind::Ftp => 2,
LocationKind::Sftp => 3,
}
}
fn home_profile() -> LocationProfile {
LocationProfile {
name: "내 홈".into(),
kind: LocationKind::Local,
address: std::env::var("HOME").unwrap_or_else(|_| "/".into()),
username: String::new(),
password: String::new(),
domain: String::new(),
}
}
fn display_uri(uri: &str) -> String {
let file = gio::File::for_uri(uri);
file.path().map_or_else(
|| display_remote_uri(file.uri().as_str()),
|path| path.to_string_lossy().into_owned(),
)
}
fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
let row = gtk::ListBoxRow::new();
let content = gtk::Box::new(gtk::Orientation::Horizontal, 12);
content.set_margin_top(8);
content.set_margin_bottom(8);
content.set_margin_start(10);
content.set_margin_end(10);
let icon = match entry.kind {
EntryKind::Directory => "folder-symbolic",
EntryKind::ZipArchive => "package-x-generic-symbolic",
};
content.append(&gtk::Image::from_icon_name(icon));
content.append(
&gtk::Label::builder()
.label(&entry.name)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk::pango::EllipsizeMode::Middle)
.build(),
);
for time in [entry.modified, entry.created] {
let label = gtk::Label::new(Some(&format_time(time)));
label.set_width_chars(17);
label.set_xalign(1.0);
content.append(&label);
}
row.set_child(Some(&content));
row
}
fn format_time(time: Option<std::time::SystemTime>) -> String {
time.map(|time| {
let datetime: DateTime<Local> = time.into();
datetime.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "-".into())
}
fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Option<usize> {
if entries.is_empty() {
return None;
}
let query = query.to_lowercase();
(0..entries.len())
.map(|offset| (start + offset) % entries.len())
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str) -> BrowserEntry {
BrowserEntry {
uri: format!("file:///{name}"),
name: name.into(),
kind: EntryKind::Directory,
size: None,
modified: None,
created: None,
etag: None,
}
}
#[test]
fn typeahead_matches_case_insensitively() {
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
assert_eq!(find_prefix_index(&entries, "br", 0), Some(2));
assert_eq!(find_prefix_index(&entries, "AL", 0), Some(0));
}
#[test]
fn repeated_typeahead_wraps_to_next_match() {
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
assert_eq!(find_prefix_index(&entries, "b", 2), Some(2));
assert_eq!(find_prefix_index(&entries, "b", 3), Some(1));
}
}