]> git.proxmox.com Git - pve-installer.git/blame - proxmox-tui-installer/src/main.rs
tui: fix panic in `NumericEditView` if the cursor is at the last position
[pve-installer.git] / proxmox-tui-installer / src / main.rs
CommitLineData
183e2a76
CH
1#![forbid(unsafe_code)]
2
83071f35
CH
3mod views;
4
52e6b337 5use crate::views::DiskSizeFormInputView;
183e2a76 6use cursive::{
ccf3b075 7 event::Event,
64220ff1 8 view::{Finder, Nameable, Resizable, ViewWrapper},
183e2a76 9 views::{
a142f457
CH
10 Button, Dialog, DummyView, EditView, LinearLayout, PaddedView, Panel, ResizedView,
11 ScrollView, SelectView, TextView,
183e2a76
CH
12 },
13 Cursive, View,
14};
e9557e62 15use std::fmt;
52e6b337 16use views::FormInputView;
183e2a76
CH
17
18// TextView::center() seems to garble the first two lines, so fix it manually here.
19const LOGO: &str = r#"
20 ____ _ __ _____
21 / __ \_________ _ ______ ___ ____ _ __ | | / / ____/
22 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | | / / __/
23 / ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___
24/_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/
25"#;
26
27const TITLE: &str = "Proxmox VE Installer";
28
29struct InstallerView {
8b43c2d3 30 view: ResizedView<LinearLayout>,
183e2a76
CH
31}
32
33impl InstallerView {
f522afb1
CH
34 pub fn new<T: View>(view: T, next_cb: Box<dyn Fn(&mut Cursive)>) -> Self {
35 let inner = LinearLayout::vertical().child(view).child(PaddedView::lrtb(
36 1,
37 1,
38 1,
39 0,
40 LinearLayout::horizontal()
41 .child(abort_install_button())
42 .child(DummyView.full_width())
43 .child(Button::new("Previous", switch_to_prev_screen))
44 .child(DummyView)
45 .child(Button::new("Next", next_cb)),
46 ));
47
48 Self::with_raw(inner)
49 }
50
51 pub fn with_raw<T: View>(view: T) -> Self {
8b43c2d3
CH
52 let inner = LinearLayout::vertical()
53 .child(PaddedView::lrtb(1, 1, 0, 1, TextView::new(LOGO).center()))
54 .child(Dialog::around(view).title(TITLE));
55
183e2a76 56 Self {
8b43c2d3
CH
57 // Limit the maximum to something reasonable, such that it won't get spread out much
58 // depending on the screen.
59 view: ResizedView::with_max_size((120, 40), inner),
183e2a76
CH
60 }
61 }
62}
63
64impl ViewWrapper for InstallerView {
8b43c2d3 65 cursive::wrap_impl!(self.view: ResizedView<LinearLayout>);
183e2a76
CH
66}
67
e9557e62
CH
68#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
69enum FsType {
70 #[default]
71 Ext4,
72 Xfs,
73}
74
75impl fmt::Display for FsType {
76 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77 let s = match self {
78 FsType::Ext4 => "ext4",
79 FsType::Xfs => "XFS",
80 };
81 write!(f, "{s}")
82 }
83}
84
48ce057c
CH
85const FS_TYPES: &[FsType] = &[FsType::Ext4, FsType::Xfs];
86
e9557e62
CH
87#[derive(Clone, Debug)]
88struct LvmBootdiskOptions {
89 disk: Disk,
90 total_size: u64,
91 swap_size: u64,
92 max_root_size: u64,
93 max_data_size: u64,
94 min_lvm_free: u64,
95}
96
97impl LvmBootdiskOptions {
98 fn defaults_from(disk: &Disk) -> Self {
99 let min_lvm_free = if disk.size > 128 * 1024 * 1024 {
100 16 * 1024 * 1024
101 } else {
102 disk.size / 8
103 };
104
105 Self {
106 disk: disk.clone(),
107 total_size: disk.size,
108 swap_size: 4 * 1024 * 1024, // TODO: value from installed memory
109 max_root_size: 0,
110 max_data_size: 0,
111 min_lvm_free,
112 }
113 }
114}
115
116#[derive(Clone, Debug)]
117enum AdvancedBootdiskOptions {
118 Lvm(LvmBootdiskOptions),
119}
120
121#[derive(Clone, Debug)]
122struct Disk {
123 path: String,
124 size: u64,
125}
126
127impl fmt::Display for Disk {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 // TODO: Format sizes properly with `proxmox-human-byte` once merged
130 // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.html
131 write!(f, "{} ({} B)", self.path, self.size)
132 }
133}
134
135#[derive(Clone, Debug)]
136struct BootdiskOptions {
137 disks: Vec<Disk>,
138 fstype: FsType,
139 advanced: AdvancedBootdiskOptions,
140}
141
a142f457
CH
142#[derive(Clone, Debug)]
143struct TimezoneOptions {
144 timezone: String,
145 kb_layout: String,
146}
147
148impl Default for TimezoneOptions {
149 fn default() -> Self {
150 Self {
151 timezone: "Europe/Vienna".to_owned(),
152 kb_layout: "en_US".to_owned(),
153 }
154 }
155}
156
c2eee468
CH
157#[derive(Clone, Debug)]
158struct PasswordOptions {
159 email: String,
160 root_password: String,
161}
162
163impl Default for PasswordOptions {
164 fn default() -> Self {
165 Self {
166 email: "mail@example.invalid".to_owned(),
167 root_password: String::new(),
168 }
169 }
170}
171
e9557e62
CH
172#[derive(Clone, Debug)]
173struct InstallerOptions {
174 bootdisk: BootdiskOptions,
a142f457 175 timezone: TimezoneOptions,
c2eee468 176 password: PasswordOptions,
e9557e62
CH
177}
178
183e2a76
CH
179fn main() {
180 let mut siv = cursive::termion();
181
ccf3b075
CH
182 siv.clear_global_callbacks(Event::CtrlChar('c'));
183 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
184
e9557e62
CH
185 let disks = vec![Disk {
186 path: "/dev/vda".to_owned(),
187 size: 17179869184,
188 }];
189 siv.set_user_data(InstallerOptions {
190 bootdisk: BootdiskOptions {
191 disks: disks.clone(),
192 fstype: FsType::default(),
193 advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(&disks[0])),
194 },
a142f457 195 timezone: TimezoneOptions::default(),
c2eee468 196 password: PasswordOptions::default(),
e9557e62
CH
197 });
198
183e2a76
CH
199 siv.add_active_screen();
200 siv.screen_mut().add_layer(license_dialog());
201 siv.run();
202}
203
e70f1b2f
CH
204fn add_next_screen(
205 constructor: &dyn Fn(&mut Cursive) -> InstallerView,
206) -> Box<dyn Fn(&mut Cursive) + '_> {
183e2a76 207 Box::new(|siv: &mut Cursive| {
e70f1b2f 208 let v = constructor(siv);
183e2a76 209 siv.add_active_screen();
e70f1b2f 210 siv.screen_mut().add_layer(v);
183e2a76
CH
211 })
212}
213
64220ff1
CH
214fn switch_to_prev_screen(siv: &mut Cursive) {
215 let id = siv.active_screen().saturating_sub(1);
216 siv.set_screen(id);
217}
218
183e2a76
CH
219fn yes_no_dialog(
220 siv: &mut Cursive,
221 title: &str,
222 text: &str,
223 callback: &'static dyn Fn(&mut Cursive),
224) {
225 siv.add_layer(
226 Dialog::around(TextView::new(text))
227 .title(title)
228 .dismiss_button("No")
229 .button("Yes", callback),
230 )
231}
232
ccf3b075 233fn trigger_abort_install_dialog(siv: &mut Cursive) {
58869243
CH
234 #[cfg(debug_assertions)]
235 siv.quit();
236
237 #[cfg(not(debug_assertions))]
ccf3b075
CH
238 yes_no_dialog(
239 siv,
240 "Abort installation?",
241 "Are you sure you want to abort the installation?",
242 &Cursive::quit,
243 )
244}
245
183e2a76 246fn abort_install_button() -> Button {
ccf3b075 247 Button::new("Abort", trigger_abort_install_dialog)
183e2a76
CH
248}
249
250fn get_eula() -> String {
8f5fdd21
CH
251 // TODO: properly using info from Proxmox::Install::Env::setup()
252 std::fs::read_to_string("/cdrom/EULA")
253 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
183e2a76
CH
254}
255
256fn license_dialog() -> InstallerView {
257 let inner = LinearLayout::vertical()
258 .child(PaddedView::lrtb(
259 0,
260 0,
261 1,
262 0,
263 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
264 ))
8b43c2d3
CH
265 .child(Panel::new(ScrollView::new(
266 TextView::new(get_eula()).center(),
183e2a76
CH
267 )))
268 .child(PaddedView::lrtb(
269 1,
270 1,
f522afb1 271 1,
183e2a76
CH
272 0,
273 LinearLayout::horizontal()
274 .child(abort_install_button())
275 .child(DummyView.full_width())
276 .child(Button::new("I agree", add_next_screen(&bootdisk_dialog))),
277 ));
278
f522afb1 279 InstallerView::with_raw(inner)
183e2a76
CH
280}
281
e70f1b2f 282fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
64220ff1
CH
283 let options = siv
284 .user_data::<InstallerOptions>()
285 .map(|o| o.clone())
286 .unwrap()
287 .bootdisk;
288
289 let AdvancedBootdiskOptions::Lvm(advanced) = options.advanced;
290
291 let fstype_select = LinearLayout::horizontal()
292 .child(TextView::new("Filesystem: "))
293 .child(DummyView.full_width())
294 .child(
295 SelectView::new()
296 .popup()
297 .with_all(FS_TYPES.iter().map(|t| (t.to_string(), t)))
298 .selected(
299 FS_TYPES
300 .iter()
301 .position(|t| *t == options.fstype)
302 .unwrap_or_default(),
303 )
304 .on_submit({
305 let disks = options.disks.clone();
306 let advanced = advanced.clone();
307 move |siv, fstype: &FsType| {
308 let view = match fstype {
309 FsType::Ext4 | FsType::Xfs => {
310 LvmBootdiskOptionsView::new(&disks, &advanced)
311 }
312 };
313
314 siv.call_on_name("bootdisk-options", |v: &mut LinearLayout| {
315 v.clear();
316 v.add_child(view);
317 });
318 }
319 })
320 .with_name("fstype")
321 .full_width(),
322 );
323
324 let inner = LinearLayout::vertical()
325 .child(fstype_select)
326 .child(DummyView)
327 .child(
328 LinearLayout::horizontal()
329 .child(LvmBootdiskOptionsView::new(&options.disks, &advanced))
330 .with_name("bootdisk-options"),
f522afb1 331 );
64220ff1 332
f522afb1
CH
333 InstallerView::new(
334 inner,
335 Box::new(|siv| {
336 let options = siv
337 .call_on_name("bootdisk-options", |v: &mut LinearLayout| {
338 v.get_child_mut(0)?
339 .downcast_mut::<LvmBootdiskOptionsView>()?
340 .get_values()
341 .map(AdvancedBootdiskOptions::Lvm)
342 })
343 .flatten()
344 .unwrap();
345
346 siv.with_user_data(|opts: &mut InstallerOptions| {
347 opts.bootdisk.advanced = options;
348 });
349
a142f457 350 add_next_screen(&timezone_dialog)(siv)
f522afb1
CH
351 }),
352 )
64220ff1
CH
353}
354
355struct LvmBootdiskOptionsView {
356 view: LinearLayout,
357}
358
359impl LvmBootdiskOptionsView {
360 fn new(disks: &[Disk], options: &LvmBootdiskOptions) -> Self {
361 let view = LinearLayout::vertical()
52e6b337
CH
362 .child(FormInputView::new(
363 "Target harddisk",
364 SelectView::new()
365 .popup()
366 .with_all(disks.iter().map(|d| (d.to_string(), d.clone())))
367 .with_name("bootdisk-disk"),
368 ))
369 .child(DiskSizeFormInputView::new("Total size").content(options.total_size))
370 .child(DiskSizeFormInputView::new("Swap size").content(options.swap_size))
64220ff1 371 .child(
52e6b337
CH
372 DiskSizeFormInputView::new("Maximum root volume size")
373 .content(options.max_root_size),
64220ff1 374 )
64220ff1 375 .child(
52e6b337
CH
376 DiskSizeFormInputView::new("Maximum data volume size")
377 .content(options.max_data_size),
64220ff1
CH
378 )
379 .child(
52e6b337
CH
380 DiskSizeFormInputView::new("Minimum free LVM space").content(options.min_lvm_free),
381 );
64220ff1
CH
382
383 Self { view }
384 }
385
386 fn get_values(&mut self) -> Option<LvmBootdiskOptions> {
387 let disk = self
388 .view
389 .call_on_name("bootdisk-disk", |view: &mut SelectView<Disk>| {
390 view.selection()
391 })?
392 .map(|d| (*d).clone())?;
393
394 let mut get_disksize_value = |i| {
395 self.view
396 .get_child_mut(i)?
52e6b337 397 .downcast_mut::<DiskSizeFormInputView>()?
64220ff1
CH
398 .get_content()
399 };
400
401 Some(LvmBootdiskOptions {
402 disk,
403 total_size: get_disksize_value(1)?,
404 swap_size: get_disksize_value(2)?,
405 max_root_size: get_disksize_value(3)?,
406 max_data_size: get_disksize_value(4)?,
407 min_lvm_free: get_disksize_value(5)?,
408 })
409 }
410}
411
412impl ViewWrapper for LvmBootdiskOptionsView {
413 cursive::wrap_impl!(self.view: LinearLayout);
414}
415
a142f457
CH
416fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
417 let options = siv
418 .user_data::<InstallerOptions>()
419 .map(|o| o.timezone.clone())
420 .unwrap_or_default();
421
422 let inner = LinearLayout::vertical()
423 .child(FormInputView::new(
424 "Country",
425 EditView::new().content("Austria"),
426 ))
427 .child(FormInputView::new(
428 "Timezone",
429 EditView::new()
430 .content(options.timezone)
431 .with_name("timezone-tzname"),
432 ))
433 .child(FormInputView::new(
434 "Keyboard layout",
435 EditView::new()
436 .content(options.kb_layout)
437 .with_name("timezone-kblayout"),
438 ));
439
440 InstallerView::new(
441 inner,
442 Box::new(|siv| {
443 let timezone = siv
444 .call_on_name("timezone-tzname", |v: &mut EditView| {
445 (*v.get_content()).clone()
446 })
447 .unwrap();
448
449 let kb_layout = siv
450 .call_on_name("timezone-kblayout", |v: &mut EditView| {
451 (*v.get_content()).clone()
452 })
453 .unwrap();
454
455 siv.with_user_data(|opts: &mut InstallerOptions| {
456 opts.timezone = TimezoneOptions {
457 timezone,
458 kb_layout,
459 };
a142f457 460 });
c2eee468
CH
461
462 add_next_screen(&password_dialog)(siv);
a142f457
CH
463 }),
464 )
183e2a76 465}
c2eee468
CH
466
467fn password_dialog(siv: &mut Cursive) -> InstallerView {
468 let options = siv
469 .user_data::<InstallerOptions>()
470 .map(|o| o.password.clone())
471 .unwrap_or_default();
472
473 let inner = LinearLayout::vertical()
474 .child(FormInputView::new(
475 "Root password",
476 EditView::new()
477 .secret()
478 .with_name("password-dialog-root-pw"),
479 ))
480 .child(FormInputView::new(
481 "Confirm root password",
482 EditView::new()
483 .secret()
484 .with_name("password-dialog-root-pw-confirm"),
485 ))
486 .child(FormInputView::new(
487 "Administator email",
488 EditView::new()
489 .content(options.email)
490 .with_name("password-dialog-email"),
491 ));
492
493 InstallerView::new(inner, Box::new(|_| {}))
494}