Skip to content

Commit 3cb8205

Browse files
committed
feat: configurable main vertical pane order and semantic row limits
- feat: add `main_pane_order` and per-role vertical min/max keys in `settings.conf` with token aliases. - feat: render the three main bands in configured order while allocating heights by pane role, not slot. - feat: introduce `MainVerticalPane`, `VerticalLayoutLimits`, parse/format helpers, and `AppState` fields. - change: parse, normalize, ensure, and skeleton-default the new settings; copy limits into the runtime state at init. - change: anchor dropdown menus to button rects so placement stays correct when the results band moves. - refactor: extract top-bar menu rendering and simplify title/news/detail rect plumbing around reordering. - test: cover pane-order parsing, vertical normalization, settings load, band-length permutations, and UI with results last.
1 parent d6e0eb9 commit 3cb8205

25 files changed

Lines changed: 1062 additions & 400 deletions

config/settings.conf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
layout_left_pct = 20
44
layout_center_pct = 60
55
layout_right_pct = 20
6+
# Main vertical stack: comma-separated permutation of results, search, package_info
7+
# Aliases: middle (search row), details (package_info)
8+
main_pane_order = results, search, package_info
9+
# Vertical row limits (semantic per pane; not tied to top/middle/bottom slot)
10+
vertical_min_results = 3
11+
vertical_max_results = 17
12+
vertical_min_middle = 3
13+
vertical_max_middle = 5
14+
vertical_min_package_info = 3
615
# Default dry-run behavior when starting the app (overridden by --dry-run)
716
app_dry_run_default = false
817
# Middle row visibility (default true)

src/app/runtime/init.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ pub fn apply_settings_to_app_state(app: &mut AppState, prefs: &crate::theme::Set
294294
app.layout_left_pct = prefs.layout_left_pct;
295295
app.layout_center_pct = prefs.layout_center_pct;
296296
app.layout_right_pct = prefs.layout_right_pct;
297+
app.main_pane_order = prefs.main_pane_order;
298+
app.vertical_layout_limits = crate::state::VerticalLayoutLimits::from_u16s(
299+
prefs.vertical_min_results,
300+
prefs.vertical_max_results,
301+
prefs.vertical_min_middle,
302+
prefs.vertical_max_middle,
303+
prefs.vertical_min_package_info,
304+
);
297305
app.keymap = prefs.keymap.clone();
298306
app.sort_mode = prefs.sort_mode;
299307
app.package_marker = prefs.package_marker;

src/state/app_state/default_impl.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ impl Default for AppState {
312312
locale,
313313
translations,
314314
translations_fallback,
315+
main_pane_order,
316+
vertical_layout_limits,
315317
) = defaults::default_settings_state();
316318

317319
let (
@@ -600,6 +602,8 @@ impl Default for AppState {
600602
layout_left_pct,
601603
layout_center_pct,
602604
layout_right_pct,
605+
main_pane_order,
606+
vertical_layout_limits,
603607
keymap,
604608
locale,
605609
translations,

src/state/app_state/defaults.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -789,10 +789,12 @@ pub(super) const fn default_toast_state() -> (Option<String>, Option<Instant>) {
789789
/// Inputs: None.
790790
///
791791
/// Output:
792-
/// - Tuple of settings fields: `layout_left_pct`, `layout_center_pct`, `layout_right_pct`, `keymap`, `locale`, `translations`, `translations_fallback`.
792+
/// - Tuple of settings fields: layout percentages, `keymap`, locale, translations, `main_pane_order`,
793+
/// `vertical_layout_limits`.
793794
///
794795
/// Details:
795-
/// - Default layout percentages, keymap from settings, English locale, empty translation maps.
796+
/// - Layout percentages and main vertical layout match [`crate::theme::Settings::default`]; English locale;
797+
/// empty translation maps.
796798
pub(super) fn default_settings_state() -> (
797799
u16,
798800
u16,
@@ -801,15 +803,26 @@ pub(super) fn default_settings_state() -> (
801803
String,
802804
crate::i18n::translations::TranslationMap,
803805
crate::i18n::translations::TranslationMap,
806+
[crate::state::MainVerticalPane; 3],
807+
crate::state::VerticalLayoutLimits,
804808
) {
809+
let s = crate::theme::Settings::default();
805810
(
806-
20,
807-
60,
808-
20,
809-
crate::theme::Settings::default().keymap,
811+
s.layout_left_pct,
812+
s.layout_center_pct,
813+
s.layout_right_pct,
814+
s.keymap.clone(),
810815
"en-US".to_string(),
811816
std::collections::HashMap::new(),
812817
std::collections::HashMap::new(),
818+
s.main_pane_order,
819+
crate::state::VerticalLayoutLimits::from_u16s(
820+
s.vertical_min_results,
821+
s.vertical_max_results,
822+
s.vertical_min_middle,
823+
s.vertical_max_middle,
824+
s.vertical_min_package_info,
825+
),
813826
)
814827
}
815828

src/state/app_state/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,10 @@ pub struct AppState {
522522
pub layout_center_pct: u16,
523523
/// Right pane width percentage.
524524
pub layout_right_pct: u16,
525+
/// Top-to-bottom order of the main vertical stack (results, middle, package info).
526+
pub main_pane_order: [crate::state::MainVerticalPane; 3],
527+
/// Min/max row counts for vertical layout (semantic per pane, not screen slot).
528+
pub vertical_layout_limits: crate::state::VerticalLayoutLimits,
525529
/// Resolved key bindings from user settings
526530
pub keymap: KeyMap,
527531
// Internationalization (i18n)

src/state/main_vertical_pane.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! Vertical main-stack roles: results list, middle search row, and package info.
2+
3+
/// What: Identifies one of the three vertical regions in the main TUI stack.
4+
///
5+
/// Inputs: None (enum definition).
6+
///
7+
/// Output: None (enum definition).
8+
///
9+
/// Details:
10+
/// - Distinct from horizontal [`crate::state::Focus`]; this only describes top-to-bottom placement.
11+
/// - The active permutation is stored in [`crate::theme::Settings::main_pane_order`] and copied to
12+
/// [`crate::state::AppState::main_pane_order`] at startup and on config reload.
13+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14+
pub enum MainVerticalPane {
15+
/// Package search results list (title row + list).
16+
Results,
17+
/// Middle row: recent queries, search input, install list.
18+
Middle,
19+
/// Package details / news reader body.
20+
PackageInfo,
21+
}
22+
23+
/// What: Default top-to-bottom order (results, then middle search row, then package info).
24+
///
25+
/// Inputs: None (constant).
26+
///
27+
/// Output: Constant array value.
28+
///
29+
/// Details:
30+
/// - Matches the historical layout before `main_pane_order` existed.
31+
pub const DEFAULT_MAIN_PANE_ORDER: [MainVerticalPane; 3] = [
32+
MainVerticalPane::Results,
33+
MainVerticalPane::Middle,
34+
MainVerticalPane::PackageInfo,
35+
];
36+
37+
impl MainVerticalPane {
38+
/// What: Serialize this role to a stable settings token (lowercase).
39+
///
40+
/// Inputs:
41+
/// - `self`: Pane role.
42+
///
43+
/// Output:
44+
/// - Canonical token string.
45+
///
46+
/// Details:
47+
/// - Used when writing defaults into `settings.conf`.
48+
#[must_use]
49+
pub const fn as_config_token(self) -> &'static str {
50+
match self {
51+
Self::Results => "results",
52+
Self::Middle => "search",
53+
Self::PackageInfo => "package_info",
54+
}
55+
}
56+
57+
/// What: Parse a single role token from config (case-insensitive).
58+
///
59+
/// Inputs:
60+
/// - `token`: One comma-separated fragment from `main_pane_order`.
61+
///
62+
/// Output:
63+
/// - `Some(role)` when recognized, else `None`.
64+
///
65+
/// Details:
66+
/// - Accepts aliases: `middle` for search row, `details` for package info.
67+
#[must_use]
68+
pub fn from_config_token(token: &str) -> Option<Self> {
69+
let t = token.trim().to_ascii_lowercase().replace(['-', ' '], "_");
70+
match t.as_str() {
71+
"results" => Some(Self::Results),
72+
"search" | "middle" => Some(Self::Middle),
73+
"package_info" | "details" | "packageinfo" => Some(Self::PackageInfo),
74+
_ => None,
75+
}
76+
}
77+
}
78+
79+
/// What: Parse `main_pane_order` value into a length-3 permutation of distinct roles.
80+
///
81+
/// Inputs:
82+
/// - `value`: Comma-separated tokens (whitespace allowed).
83+
///
84+
/// Output:
85+
/// - `Some(order)` when exactly three distinct known roles are present; otherwise `None`.
86+
///
87+
/// Details:
88+
/// - Empty or duplicate roles yield `None`.
89+
#[must_use]
90+
pub fn parse_main_pane_order(value: &str) -> Option<[MainVerticalPane; 3]> {
91+
let parts: Vec<&str> = value
92+
.split(',')
93+
.map(str::trim)
94+
.filter(|s| !s.is_empty())
95+
.collect();
96+
if parts.len() != 3 {
97+
return None;
98+
}
99+
let mut seen = [false; 3];
100+
let mut out = [MainVerticalPane::Results; 3];
101+
for (i, part) in parts.iter().enumerate() {
102+
let role = MainVerticalPane::from_config_token(part)?;
103+
let idx = match role {
104+
MainVerticalPane::Results => 0usize,
105+
MainVerticalPane::Middle => 1usize,
106+
MainVerticalPane::PackageInfo => 2usize,
107+
};
108+
if seen[idx] {
109+
return None;
110+
}
111+
seen[idx] = true;
112+
out[i] = role;
113+
}
114+
Some(out)
115+
}
116+
117+
/// What: Format a pane order for `settings.conf` (canonical tokens).
118+
///
119+
/// Inputs:
120+
/// - `order`: Three distinct roles.
121+
///
122+
/// Output:
123+
/// - Comma+space separated string.
124+
///
125+
/// Details:
126+
/// - Intended for `ensure_settings_keys_present` and tests.
127+
#[must_use]
128+
pub fn format_main_pane_order(order: &[MainVerticalPane; 3]) -> String {
129+
format!(
130+
"{}, {}, {}",
131+
order[0].as_config_token(),
132+
order[1].as_config_token(),
133+
order[2].as_config_token()
134+
)
135+
}
136+
137+
/// What: Min/max row heights for vertical layout allocation (semantic per pane).
138+
///
139+
/// Inputs: None (struct definition).
140+
///
141+
/// Output: None (struct definition).
142+
///
143+
/// Details:
144+
/// - Values are applied after parsing and normalization from `settings.conf`.
145+
/// - Package info has no user `max`; the allocator assigns remaining rows.
146+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147+
pub struct VerticalLayoutLimits {
148+
/// Minimum height (terminal rows) for the results list region.
149+
pub min_results: u16,
150+
/// Maximum height for the results list region.
151+
pub max_results: u16,
152+
/// Minimum height for the middle (search) row.
153+
pub min_middle: u16,
154+
/// Maximum height for the middle row.
155+
pub max_middle: u16,
156+
/// Minimum height for package info when that band is visible.
157+
pub min_package_info: u16,
158+
}
159+
160+
impl Default for VerticalLayoutLimits {
161+
/// What: Match historical hardcoded [`crate::ui`] constraints.
162+
fn default() -> Self {
163+
Self {
164+
min_results: 3,
165+
max_results: 17,
166+
min_middle: 3,
167+
max_middle: 5,
168+
min_package_info: 3,
169+
}
170+
}
171+
}
172+
173+
impl VerticalLayoutLimits {
174+
/// What: Build limits from normalized numeric settings (avoids `state` ↔ `theme` cycles).
175+
///
176+
/// Inputs:
177+
/// - `min_results`, `max_results`, `min_middle`, `max_middle`, `min_package_info`: Parsed settings.
178+
///
179+
/// Output:
180+
/// - Populated limits struct.
181+
#[must_use]
182+
pub const fn from_u16s(
183+
min_results: u16,
184+
max_results: u16,
185+
min_middle: u16,
186+
max_middle: u16,
187+
min_package_info: u16,
188+
) -> Self {
189+
Self {
190+
min_results,
191+
max_results,
192+
min_middle,
193+
max_middle,
194+
min_package_info,
195+
}
196+
}
197+
}
198+
199+
#[cfg(test)]
200+
mod tests {
201+
use super::*;
202+
203+
#[test]
204+
fn parse_order_default_tokens() {
205+
let o = parse_main_pane_order("results, search, package_info").expect("parse");
206+
assert_eq!(o, DEFAULT_MAIN_PANE_ORDER);
207+
}
208+
209+
#[test]
210+
fn parse_order_aliases_and_permutation() {
211+
let o = parse_main_pane_order("package_info,middle,results").expect("parse");
212+
assert_eq!(
213+
o,
214+
[
215+
MainVerticalPane::PackageInfo,
216+
MainVerticalPane::Middle,
217+
MainVerticalPane::Results
218+
]
219+
);
220+
}
221+
222+
#[test]
223+
fn parse_order_rejects_duplicates_and_bad_length() {
224+
assert!(parse_main_pane_order("results,results,middle").is_none());
225+
assert!(parse_main_pane_order("results,middle").is_none());
226+
assert!(parse_main_pane_order("a,b,c").is_none());
227+
}
228+
}

src/state/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
//! preserving the public API under `crate::state::*` via re-exports.
55
66
pub mod app_state;
7+
pub mod main_vertical_pane;
78
pub mod modal;
89
pub mod types;
910

1011
// Public re-exports to keep existing paths working
1112
pub use app_state::AppState;
13+
pub use main_vertical_pane::{
14+
DEFAULT_MAIN_PANE_ORDER, MainVerticalPane, VerticalLayoutLimits, format_main_pane_order,
15+
parse_main_pane_order,
16+
};
1217
pub use modal::{Modal, PreflightAction, PreflightTab, SshSetupStep};
1318
pub use types::{
1419
ArchStatusColor, Focus, InstalledPackagesMode, NewsItem, PackageDetails, PackageItem,

src/theme/config/settings_ensure.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ fn get_layout_value(key: &str, prefs: &Settings) -> Option<String> {
5353
"layout_left_pct" => Some(prefs.layout_left_pct.to_string()),
5454
"layout_center_pct" => Some(prefs.layout_center_pct.to_string()),
5555
"layout_right_pct" => Some(prefs.layout_right_pct.to_string()),
56+
"main_pane_order" => Some(crate::state::format_main_pane_order(&prefs.main_pane_order)),
57+
"vertical_min_results" => Some(prefs.vertical_min_results.to_string()),
58+
"vertical_max_results" => Some(prefs.vertical_max_results.to_string()),
59+
"vertical_min_middle" => Some(prefs.vertical_min_middle.to_string()),
60+
"vertical_max_middle" => Some(prefs.vertical_max_middle.to_string()),
61+
"vertical_min_package_info" => Some(prefs.vertical_min_package_info.to_string()),
5662
_ => None,
5763
}
5864
}

src/theme/config/skeletons.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,15 @@ pub const SETTINGS_SKELETON_CONTENT: &str = "# Pacsea settings configuration\n\
202202
layout_left_pct = 20\n\
203203
layout_center_pct = 60\n\
204204
layout_right_pct = 20\n\
205+
# Main vertical stack order: comma-separated permutation of results, search, package_info\n\
206+
# Aliases: middle (search row), details (package_info)\n\
207+
main_pane_order = results, search, package_info\n\
208+
# Vertical row limits (apply to the pane role, not screen position)\n\
209+
vertical_min_results = 3\n\
210+
vertical_max_results = 17\n\
211+
vertical_min_middle = 3\n\
212+
vertical_max_middle = 5\n\
213+
vertical_min_package_info = 3\n\
205214
# Default dry-run behavior when starting the app (overridden by --dry-run)\n\
206215
app_dry_run_default = false\n\
207216
# Middle row visibility (default true)\n\

0 commit comments

Comments
 (0)