zng/examples/text.rs

932 lines
30 KiB
Rust

//! Demonstrates the `Text!` and `TextInput!` widgets. Text rendering, text editor.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use core::fmt;
use std::sync::Arc;
use zng::{
app::{NEW_CMD, OPEN_CMD, SAVE_AS_CMD, SAVE_CMD},
button,
clipboard::{COPY_CMD, CUT_CMD, PASTE_CMD},
color::{color_scheme_map, filter::opacity},
focus::{alt_focus_scope, focus_click_behavior, FocusClickBehavior},
font::{FontName, FontNames},
gesture::{click_shortcut, is_hovered},
icon::{self, Icon},
label::{self, Label},
layout::{align, margin, padding, Dip},
prelude::*,
rule_line,
scroll::ScrollMode,
text::{font_family, font_weight, UnderlinePosition, UnderlineSkip},
text_input,
undo::UNDO_CMD,
var::ArcVar,
widget::{background_color, corner_radius, enabled, visibility, LineStyle, Visibility},
window::{native_dialog, WindowRoot},
};
use zng::view_process::prebuilt as view_process;
fn main() {
examples_util::print_info();
view_process::init();
zng::app::crash_handler::init_debug();
//let rec = examples_util::record_profile("text");
// view_process::run_same_process(app_main);
app_main();
// rec.finish();
}
fn app_main() {
APP.defaults().run_window(async {
let fs = var(Length::Pt(11.0));
Window! {
title = fs.map(|s| formatx!("Text Example - font_size: {s}"));
child = Stack!(ui_vec![
Stack! {
text::font_size = fs.easing(150.ms(), easing::linear);
direction = StackDirection::left_to_right();
align = Align::CENTER;
spacing = 40;
children = ui_vec![
Stack! {
direction = StackDirection::top_to_bottom();
spacing = 20;
children = ui_vec![
basic(),
defaults(),
];
},
Stack! {
direction = StackDirection::top_to_bottom();
spacing = 20;
children = ui_vec![
line_height(),
line_spacing(),
word_spacing(),
letter_spacing(),
];
},
Stack! {
direction = StackDirection::top_to_bottom();
spacing = 20;
children = ui_vec![
decoration_lines(),
]
}
];
},
Container! {
align = Align::TOP;
margin = 10;
child = font_size_example(fs);
},
Stack! {
direction = StackDirection::top_to_bottom();
spacing = 5;
margin = 20;
align = Align::BOTTOM_RIGHT;
children_align = Align::RIGHT;
children = ui_vec![
text_editor(),
form_editor(),
];
},
])
}
})
}
fn font_size_example(font_size: ArcVar<Length>) -> impl UiNode {
fn change_size(font_size: &ArcVar<Length>, change: f32) {
font_size.modify(move |s| {
*s.to_mut() += Length::Pt(change);
});
}
Stack! {
button::style_fn = Style! { padding = (0, 5) };
direction = StackDirection::left_to_right();
spacing = 5;
corner_radius = 4;
background_color = color_scheme_map(rgba(0, 0, 0, 40.pct()), rgba(1., 1., 1., 40.pct()));
padding = 4;
children = ui_vec![
Button! {
child = Text!("-");
font_family = FontName::monospace();
font_weight = FontWeight::BOLD;
click_shortcut = [shortcut!('-')];
on_click = hn!(font_size, |_| {
change_size(&font_size, -1.0)
});
},
Text! {
txt = font_size.map(|s| formatx!("{s}"));
},
Button! {
child = Text!("+");
font_family = FontName::monospace();
font_weight = FontWeight::BOLD;
click_shortcut = [shortcut!('+')];
on_click = hn!(font_size, |_| {
change_size(&font_size, 1.0)
});
},
]
}
}
fn basic() -> impl UiNode {
section(
"basic",
ui_vec![
Text!("Basic Text"),
text::Strong!("Strong Text"),
text::Em!("Emphasis Text"),
Text! {
font_color = color_scheme_map(web_colors::LIGHT_GREEN, web_colors::DARK_GREEN);
txt = "Colored Text";
when *#is_hovered {
font_color = color_scheme_map(web_colors::YELLOW, web_colors::BROWN);
}
},
Text!("Emoticons 🔎👨‍💻🧐"),
],
)
}
fn line_height() -> impl UiNode {
section(
"line_height",
ui_vec![
Text! {
txt = "Default: 'Émp Giga Ç'";
background_color = web_colors::LIGHT_BLUE;
font_color = web_colors::BLACK;
},
Text! {
txt = "150%: 'Émp Giga Ç'";
background_color = web_colors::LIGHT_BLUE;
font_color = web_colors::BLACK;
line_height = 150.pct();
},
],
)
}
fn line_spacing() -> impl UiNode {
section(
"line_spacing",
ui_vec![Container! {
child = Text! {
txt = "Hello line 1!\nHello line 2!\nHover to change `line_spacing`";
background_color = rgba(0.5, 0.5, 0.5, 0.3);
txt_wrap = false;
when *#is_hovered {
#[easing(150.ms())]
line_spacing = 30.pct();
}
};
child_align = Align::TOP;
layout::min_height = 1.7.em() * 3.fct();
}],
)
}
fn word_spacing() -> impl UiNode {
section(
"word_spacing",
ui_vec![Text! {
txt = "Word spacing\n\thover to change";
background_color = rgba(0.5, 0.5, 0.5, 0.3);
when *#is_hovered {
#[easing(150.ms())]
word_spacing = 100.pct();
}
}],
)
}
fn letter_spacing() -> impl UiNode {
section(
"letter_spacing",
ui_vec![Text! {
txt = "Letter spacing\n\thover to change";
background_color = rgba(0.5, 0.5, 0.5, 0.3);
when *#is_hovered {
#[easing(150.ms())]
letter_spacing = 30.pct();
}
}],
)
}
fn decoration_lines() -> impl UiNode {
section(
"Decorations",
ui_vec![
Text! {
txt = "Overline, 1, Dotted,\ndefault color";
overline = 1, LineStyle::Dotted;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Strikethrough, 1, Solid,\ndefault color";
strikethrough = 1, LineStyle::Solid;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Strikethrough, 4, Double,\ndifferent color";
strikethrough = 4, LineStyle::Double;
strikethrough_color = web_colors::RED;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Underline, 1, Solid,\ndefault color";
underline = 1, LineStyle::Solid;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Underline, 1, Solid,\ndefault color, skip spaces";
underline = 1, LineStyle::Solid;
underline_skip = UnderlineSkip::SPACES;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Underline, 1, Solid,\ndefault color, descent";
underline = 1, LineStyle::Solid;
underline_position = UnderlinePosition::Descent;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
margin = (0, 0, 4, 0);
},
Text! {
txt = "Underline, 3, wavy,\ndifferent color, no skip";
underline = 3, LineStyle::Wavy(1.0);
underline_color = web_colors::GREEN;
underline_skip = UnderlineSkip::NONE;
background_color = rgba(0.5, 0.5, 0.5, 0.3);
}
],
)
}
fn defaults() -> impl UiNode {
fn demo(title: &str, font_family: impl Into<FontNames>) -> impl UiNode {
let font_family = font_family.into();
let font_name = zng::font::FONTS
.list(
&font_family,
FontStyle::Normal,
FontWeight::NORMAL,
FontStretch::NORMAL,
&lang!(und),
)
.map(|f| match f.done() {
Some(f) => f.best().family_name().to_txt(),
None => Txt::from_str(""),
});
Stack! {
direction = StackDirection::left_to_right();
children_align = Align::BASELINE_LEFT;
children = ui_vec![
Text!(if title.is_empty() {
formatx!("{font_family}: ")
} else {
formatx!("{title}: ")
}),
Text! {
txt = font_name;
font_family;
}
];
}
}
section(
"defaults",
ui_vec![
// Generic
demo("", FontName::serif()),
demo("", FontName::sans_serif()),
demo("", FontName::monospace()),
demo("", FontName::cursive()),
demo("", FontName::fantasy()),
demo("Fallback", "not-a-font-get-fallback"),
demo("UI", FontNames::default())
],
)
}
fn section(header: &'static str, items: impl UiNodeList) -> impl UiNode {
Stack! {
direction = StackDirection::top_to_bottom();
spacing = 5;
children = ui_vec![Text! {
txt = header;
font_weight = FontWeight::BOLD;
margin = (0, 4);
}].chain(items);
}
}
fn text_editor() -> impl UiNode {
let is_open = var(false);
Button! {
child = Text!(is_open.map(|&i| if i { "show text editor" } else { "open text editor" }.into()));
style_fn = button::LinkStyle!();
on_click = hn!(|_| {
let editor_id = WindowId::named("text-editor");
if is_open.get() {
if WINDOWS.focus(editor_id).is_err() {
is_open.set(false);
}
} else {
WINDOWS.open_id(editor_id, async_clmv!(is_open, {
text_editor_window(is_open)
}));
}
});
}
}
fn text_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
let editor = TextEditor::init();
Window! {
title = editor.title();
on_open = hn!(is_open, |_| {
is_open.set(true);
});
on_close = hn!(is_open, |_| {
is_open.set(false);
});
enabled = editor.enabled();
on_close_requested = async_hn!(editor, |args: WindowCloseRequestedArgs| {
editor.on_close_requested(args).await;
});
min_width = 450;
child_top = text_editor_menu(editor.clone()), 0;
child = Scroll! {
mode = ScrollMode::VERTICAL;
child_align = Align::FILL;
scroll_to_focused_mode = None;
// line numbers
child_start = Text! {
padding = (7, 4);
txt_align = Align::TOP_RIGHT;
opacity = 80.pct();
layout::min_width = 24;
txt = editor.lines.map(|s| {
use std::fmt::Write;
let mut txt = String::new();
match s {
text::LinesWrapCount::NoWrap(len) => {
for i in 1..=(*len).max(1) {
let _ = writeln!(&mut txt, "{i}");
}
},
text::LinesWrapCount::Wrap(counts) => {
for (i, &c) in counts.iter().enumerate() {
let _ = write!(&mut txt, "{}", i + 1);
for _ in 0..c {
txt.push('\n');
}
}
}
}
Txt::from_str(&txt)
});
}, 0;
// editor
child = TextInput! {
id = editor.input_wgt_id();
txt = editor.txt.clone();
accepts_tab = true;
accepts_enter = true;
get_caret_status = editor.caret_status.clone();
get_lines_wrap_count = editor.lines.clone();
widget::border = unset!;
};
};
child_bottom = Text! {
margin = (0, 4);
align = Align::RIGHT;
txt = editor.caret_status.map_to_txt();
}, 0;
}
}
fn text_editor_menu(editor: Arc<TextEditor>) -> impl UiNode {
let menu_width = var(Dip::MAX);
let gt_700 = menu_width.map(|&w| Visibility::from(w > Dip::new(700)));
let gt_600 = menu_width.map(|&w| Visibility::from(w > Dip::new(600)));
let gt_500 = menu_width.map(|&w| Visibility::from(w > Dip::new(500)));
let clipboard_btn = clmv!(gt_600, |cmd: zng::event::Command| {
let cmd = cmd.focus_scoped();
Button! {
child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_600.clone()), 4;
tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
visibility = true;
cmd;
}
});
let undo_combo = clmv!(gt_700, |op: zng::undo::UndoOp| {
let cmd = op.cmd().undo_scoped();
Toggle! {
style_fn = toggle::ComboStyle!();
widget::enabled = cmd.flat_map(|c| c.is_enabled());
child = Button! {
child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_700.clone()), 4;
tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
on_click = hn!(|a: &gesture::ClickArgs| {
a.propagation().stop();
cmd.get().notify();
});
};
checked_popup = wgt_fn!(|_| popup::Popup! {
child = zng::undo::history::UndoHistory!(op);
});
}
});
Stack! {
id = "menu";
align = Align::FILL_TOP;
alt_focus_scope = true;
focus_click_behavior = FocusClickBehavior::Exit;
spacing = 4;
direction = StackDirection::left_to_right();
padding = 4;
layout::actual_width = menu_width;
button::style_fn = Style! {
padding = (2, 4);
corner_radius = 2;
icon::ico_size = 16;
};
rule_line::vr::margin = 0;
children = ui_vec![
Button! {
child = Icon!(icon::material_sharp::INSERT_DRIVE_FILE);
child_right = Text!(txt = NEW_CMD.name(); visibility = gt_500.clone()), 4;
tooltip = Tip!(Text!(NEW_CMD.name_with_shortcut()));
click_shortcut = NEW_CMD.shortcut();
on_click = async_hn!(editor, |_| {
editor.create().await;
});
},
Button! {
child = Icon!(icon::material_sharp::FOLDER_OPEN);
child_right = Text!(txt = OPEN_CMD.name(); visibility = gt_500.clone()), 4;
tooltip = Tip!(Text!(OPEN_CMD.name_with_shortcut()));
click_shortcut = OPEN_CMD.shortcut();
on_click = async_hn!(editor, |_| {
editor.open().await;
});
},
Button! {
child = Icon!(icon::material_sharp::SAVE);
child_right = Text!(txt = SAVE_CMD.name(); visibility = gt_500.clone()), 4;
tooltip = Tip!(Text!(SAVE_CMD.name_with_shortcut()));
enabled = editor.unsaved();
click_shortcut = SAVE_CMD.shortcut();
on_click = async_hn!(editor, |_| {
editor.save().await;
});
},
Button! {
child = Text!(SAVE_AS_CMD.name());
when #{gt_500}.is_collapsed() {
child = Icon!(icon::material_sharp::SAVE_AS);
}
tooltip = Tip!(Text!(SAVE_AS_CMD.name_with_shortcut()));
click_shortcut = SAVE_AS_CMD.shortcut();
on_click = async_hn!(editor, |_| {
editor.save_as().await;
});
},
rule_line::vr::Vr!(),
clipboard_btn(CUT_CMD),
clipboard_btn(COPY_CMD),
clipboard_btn(PASTE_CMD),
rule_line::vr::Vr!(),
undo_combo(zng::undo::UndoOp::Undo),
undo_combo(zng::undo::UndoOp::Redo),
]
}
}
struct TextEditor {
input_wgt_id: WidgetId,
file: ArcVar<Option<std::path::PathBuf>>,
txt: ArcVar<Txt>,
txt_touched: ArcVar<bool>,
caret_status: ArcVar<text::CaretStatus>,
lines: ArcVar<text::LinesWrapCount>,
busy: ArcVar<u32>,
}
impl TextEditor {
pub fn init() -> Arc<Self> {
let txt = var(Txt::from_static(""));
let unsaved = var(false);
txt.bind_map(&unsaved, |_| true).perm();
Arc::new(Self {
input_wgt_id: WidgetId::new_unique(),
file: var(None),
txt,
txt_touched: unsaved,
caret_status: var(text::CaretStatus::none()),
lines: var(text::LinesWrapCount::NoWrap(0)),
busy: var(0),
})
}
pub fn input_wgt_id(&self) -> WidgetId {
self.input_wgt_id
}
pub fn title(&self) -> impl Var<Txt> {
merge_var!(self.unsaved(), self.file.clone(), |u, f| {
let mut t = "Text Example - Editor".to_owned();
if *u {
t.push('*');
}
if let Some(f) = f {
use std::fmt::Write;
let _ = write!(&mut t, " - {}", f.display());
}
Txt::from_str(&t)
})
}
pub fn unsaved(&self) -> impl Var<bool> {
let can_undo = UNDO_CMD.scoped(self.input_wgt_id).is_enabled();
merge_var!(self.txt_touched.clone(), can_undo, |&t, &u| t && u)
}
pub fn enabled(&self) -> impl Var<bool> {
self.busy.map(|&b| b == 0)
}
pub async fn create(&self) {
let _busy = self.enter_busy();
if self.handle_unsaved().await {
self.txt.set(Txt::from_static(""));
self.file.set(None);
self.txt_touched.set(false);
}
}
pub async fn open(&self) {
let _busy = self.enter_busy();
if !self.handle_unsaved().await {
return;
}
let mut dlg = native_dialog::FileDialog {
title: "Open Text".into(),
kind: native_dialog::FileDialogKind::OpenFile,
..Default::default()
};
dlg.push_filter("Text Files", &["txt", "md"]).push_filter("All Files", &["*"]);
let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
match r {
native_dialog::FileDialogResponse::Selected(mut s) => {
let file = s.remove(0);
let r = task::wait(clmv!(file, || std::fs::read_to_string(file))).await;
match r {
Ok(t) => {
self.txt.set(Txt::from_str(&t));
self.txt_touched.set(false);
self.file.set(file);
}
Err(e) => {
self.handle_error("reading file", e.to_txt()).await;
}
}
}
native_dialog::FileDialogResponse::Cancel => {}
native_dialog::FileDialogResponse::Error(e) => {
self.handle_error("opening file", e).await;
}
}
}
pub async fn save(&self) -> bool {
if let Some(file) = self.file.get() {
let _busy = self.enter_busy();
let ok = self.write(file).await;
self.txt_touched.set(!ok);
ok
} else {
self.save_as().await
}
}
pub async fn save_as(&self) -> bool {
let _busy = self.enter_busy();
let mut dlg = native_dialog::FileDialog {
title: "Save Text".into(),
kind: native_dialog::FileDialogKind::SaveFile,
..Default::default()
};
dlg.push_filter("Text", &["txt"])
.push_filter("Markdown", &["md"])
.push_filter("All Files", &["*"]);
let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
match r {
native_dialog::FileDialogResponse::Selected(mut s) => {
if let Some(file) = s.pop() {
let ok = self.write(file.clone()).await;
self.txt_touched.set(!ok);
if ok {
self.file.set(Some(file));
}
return ok;
}
}
native_dialog::FileDialogResponse::Cancel => {}
native_dialog::FileDialogResponse::Error(e) => {
self.handle_error("saving file", e.to_txt()).await;
}
}
false // cancel
}
pub async fn on_close_requested(&self, args: WindowCloseRequestedArgs) {
if self.unsaved().get() {
args.propagation().stop();
if self.handle_unsaved().await {
self.txt_touched.set(false);
WINDOW.close();
}
}
}
async fn write(&self, file: std::path::PathBuf) -> bool {
let txt = self.txt.clone();
let r = task::wait(move || txt.with(move |txt| std::fs::write(file, txt.as_bytes()))).await;
match r {
Ok(()) => true,
Err(e) => {
self.handle_error("writing file", e.to_txt()).await;
false
}
}
}
async fn handle_unsaved(&self) -> bool {
if !self.unsaved().get() {
return true;
}
let dlg = native_dialog::MsgDialog {
title: "Save File?".into(),
message: "Save file? All unsaved changes will be lost.".into(),
icon: native_dialog::MsgDialogIcon::Warn,
buttons: native_dialog::MsgDialogButtons::YesNo,
};
let r = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
match r {
native_dialog::MsgDialogResponse::Yes => self.save().await,
native_dialog::MsgDialogResponse::No => true,
_ => false,
}
}
async fn handle_error(&self, context: &'static str, e: Txt) {
tracing::error!("error {context}, {e}");
let dlg = native_dialog::MsgDialog {
title: "Error".into(),
message: formatx!("Error {context}.\n\n{e}"),
icon: native_dialog::MsgDialogIcon::Error,
buttons: native_dialog::MsgDialogButtons::Ok,
};
let _ = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
}
fn enter_busy(&self) -> impl Drop {
struct BusyTracker(ArcVar<u32>);
impl Drop for BusyTracker {
fn drop(&mut self) {
self.0.modify(|b| *b.to_mut() -= 1);
}
}
self.busy.modify(|b| *b.to_mut() += 1);
BusyTracker(self.busy.clone())
}
}
fn form_editor() -> impl UiNode {
let is_open = var(false);
Button! {
child = Text!(is_open.map(|&i| if i { "show form editor" } else { "open form editor" }.into()));
style_fn = button::LinkStyle!();
on_click = hn!(|_| {
let editor_id = WindowId::named("form-editor");
if is_open.get() {
if WINDOWS.focus(editor_id).is_err() {
is_open.set(false);
}
} else {
WINDOWS.open_id(editor_id, async_clmv!(is_open, {
form_editor_window(is_open)
}));
}
});
}
}
fn form_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
Window! {
title = "Form";
on_open = hn!(is_open, |_| {
is_open.set(true);
});
on_close = hn!(is_open, |_| {
is_open.set(false);
});
size = (400, 500);
child = Grid! {
id = "form";
columns = ui_vec![grid::Column!(), grid::Column!(1.lft())];
spacing = (5, 10);
padding = 20;
label::style_fn = Style! { text::txt_align = Align::END; };
text_input::style_fn = style_fn!(|_| text_input::FieldStyle!());
cells = ui_vec![
Label! {
txt = "Name";
target = "field-name";
},
TextInput! {
grid::cell::column = 1;
id = "field-name";
txt = var_from("my-crate");
max_chars_count = 50;
},
Label! {
grid::cell::row = 1;
txt = "Authors";
target = "field-authors";
},
TextInput! {
grid::cell::row = 1;
grid::cell::column = 1;
id = "field-authors";
txt = var_from("John Doe");
},
Label! {
grid::cell::row = 2;
txt = "Version";
target = "field-version";
},
TextInput! {
id = "field-version";
grid::cell::row = 2;
grid::cell::column = 1;
txt_parse = var(Version::default());
text_input::field_help = "help text";
// txt_parse_on_stop = true;
},
Label! {
grid::cell::row = 3;
txt = "Password";
target = "field-password";
},
TextInput! {
grid::cell::row = 3;
grid::cell::column = 1;
id = "field-password";
txt = var_from("pass");
obscure_txt = true;
},
];
};
child_bottom = {
node: Stack! {
direction = StackDirection::start_to_end();
padding = 10;
align = Align::END;
spacing = 5;
children = ui_vec![
Button! {
child = Text!("Cancel");
on_click = hn!(|_| {
WINDOW.close();
});
},
Button! {
font_weight = FontWeight::BOLD;
child = Text!("Validate");
on_click = hn!(|_| {
zng::text::cmd::PARSE_CMD
.notify_descendants(&WINDOW.info().get("form").unwrap());
});
}
]
},
spacing: 10,
};
}
}
/// Basic version type for input validation demo.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
struct Version {
major: u32,
minor: u32,
rev: u32,
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.rev)
}
}
impl std::str::FromStr for Version {
type Err = Txt;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut r = Self::default();
let mut split = s.split('.');
if let Some(major) = split.next() {
if !major.is_empty() {
r.major = u32::from_str(major).map_err(|e| e.to_txt())?;
}
}
if let Some(minor) = split.next() {
if !minor.is_empty() {
r.minor = u32::from_str(minor).map_err(|e| e.to_txt())?;
}
}
if let Some(rev) = split.next() {
if !rev.is_empty() {
r.rev = u32::from_str(rev).map_err(|e| e.to_txt())?;
}
}
if split.next().is_some() {
return Err("expected maximum of 3 version numbers".into());
}
Ok(r)
}
}