refactor and added movable keyframe tracker

master
skybldev 3 years ago
parent 75fc4d6bf0
commit 17769a7865

@ -34,7 +34,10 @@ fn main() {
let mut main_storage = Storage::init(); let mut main_storage = Storage::init();
main_storage.add_bpm_change(0, 120); main_storage.add_bpm_change(0, 120.0);
main_storage.add_keyframe_channel(String::from("keyframe channel 0"));
main_storage.add_keyframe_channel(String::from("keyframe channel 1"));
main_storage.add_keyframe_channel(String::from("keyframe channel 2"));
main_app.main_loop(move |_, ui, display| { main_app.main_loop(move |_, ui, display| {
main_window.build(ui, display, &mut main_storage); main_window.build(ui, display, &mut main_storage);

@ -10,7 +10,7 @@ const DRAG_AREA_MIN_SIZE: [f32; 2] = [300.0, 300.0];
const WIN_SIZE: [f32; 2] = [ const WIN_SIZE: [f32; 2] = [
DRAG_AREA_MIN_SIZE[0] + 15.0, DRAG_AREA_MIN_SIZE[0] + 15.0,
DRAG_AREA_MIN_SIZE[1] + 100.0 DRAG_AREA_MIN_SIZE[1] + 120.0
]; ];
const WIN_MIN_SIZE: [f32; 2] = WIN_SIZE; const WIN_MIN_SIZE: [f32; 2] = WIN_SIZE;
@ -40,9 +40,19 @@ impl MainWindow {
display: &Display, display: &Display,
storage: &mut Storage storage: &mut Storage
) { ) {
let d_size = display
.gl_window()
.window()
.inner_size();
Window::new("Main Window") Window::new("Main Window")
.size(self.size, Condition::FirstUseEver) .position([0.0, 0.0], Condition::FirstUseEver)
.size_constraints(self.min_size, [1000.0, 1000.0]) .size([d_size.width as f32, d_size.height as f32], Condition::Always)
//.size_constraints(self.min_size, [s000.0, 1000.0])
.scrollable(false)
.title_bar(false)
.resizable(false)
.movable(false)
.build(&ui, || { .build(&ui, || {
MainWindow::build_window_contents( MainWindow::build_window_contents(
self, self,
@ -63,7 +73,7 @@ impl MainWindow {
self.editor.drag_area.size = [ self.editor.drag_area.size = [
win_size[0] - 20.0, win_size[0] - 20.0,
win_size[1] - 100.0 win_size[1] - 120.0
]; ];
ui.same_line(); ui.same_line();
@ -75,8 +85,8 @@ impl MainWindow {
ui.same_line(); ui.same_line();
let inc_bpm = ui.button("+"); let inc_bpm = ui.button("+");
if dec_bpm { storage.bpm_changes[0].bpm -= 1; } if dec_bpm { storage.bpm_changes[0].bpm -= 1.0; }
if inc_bpm { storage.bpm_changes[0].bpm += 1; } if inc_bpm { storage.bpm_changes[0].bpm += 1.0; }
ui.same_line(); ui.same_line();
let kf_name_input = ui let kf_name_input = ui
@ -99,14 +109,15 @@ impl MainWindow {
ui.separator(); ui.separator();
self.editor.build(ui, display, storage); self.editor.build(ui, display, storage);
draw_mouse_pos_text(ui); draw_info_text(ui);
} }
} }
fn draw_mouse_pos_text(ui: &Ui) { fn draw_info_text(ui: &Ui) {
let mouse_pos = ui.io().mouse_pos; let mouse_pos = ui.io().mouse_pos;
ui.text(format!( ui.text(format!(
"mouse pos: ({:.1}, {:.1})", "mouse pos: ({:.1}, {:.1}), {} fps",
mouse_pos[0], mouse_pos[1] mouse_pos[0], mouse_pos[1],
ui.io().framerate
)); ));
} }

@ -2,7 +2,7 @@
pub struct Storage { pub struct Storage {
// TODO: add custom timestamp class which can be queried by // TODO: add custom timestamp class which can be queried by
// beats/measures/etc. // beats/measures/etc.
pub timestamp: u64, pub timestamp: i64,
pub playback_mode: PlaybackMode, pub playback_mode: PlaybackMode,
pub bpm_changes: Vec<BpmChange>, pub bpm_changes: Vec<BpmChange>,
pub time_changes: Vec<TimeChange>, pub time_changes: Vec<TimeChange>,
@ -16,12 +16,12 @@ pub enum PlaybackMode {
} }
pub struct BpmChange { pub struct BpmChange {
pub timestamp: u64, pub timestamp: i64,
pub bpm: u32 pub bpm: f32
} }
pub struct TimeChange { pub struct TimeChange {
pub timestamp: u64, pub timestamp: i64,
pub time: (u8, u8) pub time: (u8, u8)
} }
@ -37,7 +37,7 @@ pub enum KeyframeType {
pub struct Keyframe { pub struct Keyframe {
pub kf_type: KeyframeType, pub kf_type: KeyframeType,
pub timestamp: u64 pub timestamp: i64
} }
impl Storage { impl Storage {
@ -51,7 +51,7 @@ impl Storage {
} }
} }
pub fn add_bpm_change(&mut self, timestamp: u64, bpm: u32) { pub fn add_bpm_change(&mut self, timestamp: i64, bpm: f32) {
self.bpm_changes.push(BpmChange::new(timestamp, bpm)); self.bpm_changes.push(BpmChange::new(timestamp, bpm));
} }
@ -59,7 +59,7 @@ impl Storage {
self.bpm_changes.pop(); self.bpm_changes.pop();
} }
pub fn get_bpm_change_index(&self, timestamp: u64) -> Option<usize> { pub fn get_bpm_change_index(&self, timestamp: i64) -> Option<usize> {
for (idx, bc) in self.bpm_changes.iter().enumerate() { for (idx, bc) in self.bpm_changes.iter().enumerate() {
if (bc.timestamp) == timestamp { if (bc.timestamp) == timestamp {
return Some(idx); return Some(idx);
@ -68,11 +68,11 @@ impl Storage {
None None
} }
pub fn initial_bpm(&self) -> u32 { pub fn initial_bpm(&self) -> f32 {
self.bpm_changes[0].bpm self.bpm_changes[0].bpm
} }
pub fn add_time_change(&mut self, timestamp: u64, time: (u8, u8)) { pub fn add_time_change(&mut self, timestamp: i64, time: (u8, u8)) {
self.time_changes.push(TimeChange::new(timestamp, time)); self.time_changes.push(TimeChange::new(timestamp, time));
} }
@ -94,13 +94,13 @@ impl Storage {
} }
impl BpmChange { impl BpmChange {
pub fn new(timestamp: u64, bpm: u32) -> BpmChange { pub fn new(timestamp: i64, bpm: f32) -> BpmChange {
BpmChange { timestamp, bpm } BpmChange { timestamp, bpm }
} }
} }
impl TimeChange { impl TimeChange {
pub fn new(timestamp: u64, time: (u8, u8)) -> TimeChange { pub fn new(timestamp: i64, time: (u8, u8)) -> TimeChange {
TimeChange { timestamp, time } TimeChange { timestamp, time }
} }
} }
@ -114,7 +114,7 @@ impl KeyframeChannel {
} }
} }
pub fn add_keyframe(&mut self, timestamp: u64) { pub fn add_keyframe(&mut self, timestamp: i64) {
self.keyframes.push(Keyframe { self.keyframes.push(Keyframe {
kf_type: KeyframeType::Tick, kf_type: KeyframeType::Tick,
timestamp timestamp
@ -122,7 +122,7 @@ impl KeyframeChannel {
} }
// TODO: improve efficiency of this method // TODO: improve efficiency of this method
pub fn keyframe_at_timestamp(&self, timestamp: u64) -> Option<&Keyframe> { pub fn keyframe_at_timestamp(&self, timestamp: i64) -> Option<&Keyframe> {
for keyframe in self.keyframes.iter() { for keyframe in self.keyframes.iter() {
if keyframe.timestamp == timestamp { if keyframe.timestamp == timestamp {
return Some(keyframe); return Some(keyframe);

@ -17,6 +17,14 @@ pub struct DragArea {
active: bool active: bool
} }
pub struct DragAreaMut<'a> {
pub draw_list: &'a DrawListMut<'a>,
pub area_min: [f32; 2],
pub area_max: [f32; 2],
pub offset: &'a mut [f32; 2],
pub hovered: bool
}
impl DragArea { impl DragArea {
pub fn new(size: [f32; 2], offset: [f32; 2]) -> DragArea { pub fn new(size: [f32; 2], offset: [f32; 2]) -> DragArea {
DragArea { DragArea {
@ -30,7 +38,7 @@ impl DragArea {
} }
pub fn build pub fn build
<F: FnMut(&DrawListMut, &[f32; 2], &[f32; 2], &[f32; 2])> <F: FnMut(DragAreaMut)>
(&mut self, ui: &Ui, window: &GlutinWindow, mut build_children: F) (&mut self, ui: &Ui, window: &GlutinWindow, mut build_children: F)
{ {
let mouse_delta = ui.io().mouse_delta; let mouse_delta = ui.io().mouse_delta;
@ -45,13 +53,12 @@ impl DragArea {
let rect_min = ui.item_rect_min(); let rect_min = ui.item_rect_min();
let rect_max = ui.item_rect_max(); let rect_max = ui.item_rect_max();
let lmb_down = ui.is_mouse_dragging(MouseButton::Left);
let mmb_down = ui.is_mouse_dragging(MouseButton::Middle); let mmb_down = ui.is_mouse_dragging(MouseButton::Middle);
let hovered = ui.is_item_hovered(); let hovered = ui.is_item_hovered();
if !self.active && ((lmb_down || mmb_down) && hovered) { if !self.active && mmb_down && hovered {
self.active = true; self.active = true;
} else if self.active && !(lmb_down || mmb_down) { } else if self.active && !mmb_down {
self.active = false; self.active = false;
} }
@ -91,8 +98,18 @@ impl DragArea {
let draw_list = ui.get_window_draw_list(); let draw_list = ui.get_window_draw_list();
draw_list.with_clip_rect(rect_min, rect_max, || { draw_list.with_clip_rect(rect_min, rect_max, || {
build_children(&draw_list, &rect_min, &rect_max, &self.offset);
draw_list.add_rect(rect_min, rect_max, self.border_color).build(); draw_list.add_rect(rect_min, rect_max, self.border_color).build();
build_children(
DragAreaMut {
draw_list: &draw_list,
area_min: rect_min,
area_max: rect_max,
offset: &mut self.offset,
hovered
}
);
}); });
// ui.set_cursor_screen_pos([rect_min[0], rect_max[1] + 10.0]);
} }
} }

@ -1,28 +1,55 @@
use glium::Display; use glium::Display;
use imgui::Ui; use imgui::{Ui, MouseButton};
use imgui::color::ImColor32; use imgui::color::ImColor32;
use imgui::draw_list::DrawListMut;
use crate::ui_generic::drag_area::DragArea; use crate::ui_generic::drag_area::{DragArea, DragAreaMut};
use crate::storage::Storage; use crate::storage::Storage;
const MIN_SCALE: f32 = 0.05;
const MAX_SCALE: f32 = 2.0;
const KF_CHAN_HEIGHT: f32 = 20.0; const KF_CHAN_HEIGHT: f32 = 20.0;
const KF_CHAN_TITLE_DEFAULT_COLOR: ImColor32 = const KF_CHAN_TITLE_BAR_WIDTH: f32 = 200.0;
const TS_BAR_HEIGHT: f32 = 20.0;
const TS_TICK_LINE_DIST: f32 = 5.0;
const TS_TICK_LINE_HEIGHT: f32 = 4.0;
const TS_TICK_LINE_TALL_HEIGHT: f32 = 18.0;
const KF_CHAN_TITLE_DEFAULT_FILL: ImColor32 =
ImColor32::from_rgb(38, 38, 38); ImColor32::from_rgb(38, 38, 38);
const KF_CHAN_TITLE_HOVER_COLOR: ImColor32 = const KF_CHAN_TITLE_HOVER_FILL: ImColor32 =
ImColor32::from_rgb(79, 79, 79); ImColor32::from_rgb(79, 79, 79);
const KF_CHAN_CONTENT_DEFAULT_COLOR: ImColor32 = const KF_CHAN_CONTENT_DEFAULT_FILL: ImColor32 =
ImColor32::from_rgb(16, 16, 16); ImColor32::from_rgb(16, 16, 16);
const KF_CHAN_CONTENT_HOVER_COLOR: ImColor32 = const KF_CHAN_CONTENT_HOVER_FILL: ImColor32 =
ImColor32::from_rgb(54, 54, 54); ImColor32::from_rgb(54, 54, 54);
const TS_BAR_BG_FILL: ImColor32 =
ImColor32::from_rgb(10, 10, 10);
const TS_TRACKER_RECT_FILL: ImColor32 =
ImColor32::from_rgba(237, 66, 69, 60);
const TS_TRACKER_HDL_FILL: ImColor32 =
ImColor32::from_rgb(237, 66, 69);
pub struct KeyframeEditor { pub struct KeyframeEditor {
pub drag_area: DragArea pub drag_area: DragArea,
pub first_timestamp_in_view: i64,
pub scale: f32,
pub ts_bar_active: bool
}
struct KeyframeEditorMut {
pub first_timestamp_in_view: i64,
pub scale: f32,
pub ts_bar_active: bool
} }
impl KeyframeEditor { impl KeyframeEditor {
pub fn new(size: [f32; 2]) -> KeyframeEditor { pub fn new(size: [f32; 2]) -> KeyframeEditor {
KeyframeEditor { KeyframeEditor {
drag_area: DragArea::new(size, [0.0, 0.0]) drag_area: DragArea::new(size, [0.0, 0.0]),
first_timestamp_in_view: 0,
scale: 1.0,
ts_bar_active: false
} }
} }
@ -32,122 +59,316 @@ impl KeyframeEditor {
display: &Display, display: &Display,
storage: &mut Storage storage: &mut Storage
) { ) {
let mut k = KeyframeEditorMut {
first_timestamp_in_view: self.first_timestamp_in_view,
scale: self.scale,
ts_bar_active: self.ts_bar_active
};
self.drag_area.build( self.drag_area.build(
&ui, &ui,
&display.gl_window().window(), &display.gl_window().window(),
|draw_list, area_min, area_max, offset| { |d| {
let mouse_y = ui.io().mouse_pos[1]; KeyframeEditor::build_drag_area_contents(
ui,
storage,
&mut k,
d
);
}
);
self.first_timestamp_in_view = k.first_timestamp_in_view;
self.scale = k.scale;
self.ts_bar_active = k.ts_bar_active;
}
fn build_drag_area_contents(
ui: &Ui,
storage: &mut Storage,
k: &mut KeyframeEditorMut,
mut d: DragAreaMut
) {
// Establish ia = inner area
let ia_min = [d.area_min[0] + 1.0, d.area_min[1] + 1.0];
let ia_max = [d.area_max[0] - 1.0, d.area_max[1] - 1.0];
// No keyframes text
if storage.keyframe_channels.len() == 0 {
let text = "no keyframes to show.";
let text_size = ui.calc_text_size(text);
let text_pos: [f32; 2] = [
(ia_max[0] - ia_min[0]) / 2.0 + (text_size[0] / 2.0),
(ia_max[1] - ia_min[1]) / 2.0 + (text_size[1] / 2.0)
];
d.draw_list.add_text(text_pos, KF_CHAN_TITLE_HOVER_FILL, text);
return;
}
// Movement processing
let mouse_pos = ui.io().mouse_pos;
let scaled_ts_dist = TS_TICK_LINE_DIST * k.scale;
if ui.is_item_hovered() {
let wheel = ui.io().mouse_wheel;
let ctrl = ui.io().key_ctrl;
let shift = ui.io().key_shift;
if ctrl {
k.scale += wheel * 0.05;
if k.scale < MIN_SCALE {
k.scale = MIN_SCALE
} else if k.scale > MAX_SCALE {
k.scale = MAX_SCALE
};
} else if shift {
let inverse_scale = MAX_SCALE - k.scale;
d.offset[0] -= wheel * scaled_ts_dist * inverse_scale;
} else {
d.offset[1] += wheel * 10.0;
}
}
if d.offset[1] > 0.0 {
d.offset[1] = 0.0;
}
if d.offset[0] > 0.0 {
d.offset[0] = 0.0;
}
k.first_timestamp_in_view =
(-d.offset[0] / scaled_ts_dist) as i64;
let origin = [ia_min[0] + d.offset[0], ia_min[1] + d.offset[1]];
// Draw each keyframe channel
let mut chan_y: f32 = origin[1] + TS_BAR_HEIGHT;
let mut next_chan_y: f32 = chan_y + KF_CHAN_HEIGHT;
for (idx, chan) in storage.keyframe_channels.iter().enumerate() {
// Determine colors
let title_bg: ImColor32;
let content_bg: ImColor32;
if mouse_pos[1] <= next_chan_y && mouse_pos[1] > chan_y {
content_bg = KF_CHAN_CONTENT_HOVER_FILL;
title_bg = KF_CHAN_TITLE_HOVER_FILL;
} else {
content_bg = KF_CHAN_CONTENT_DEFAULT_FILL;
title_bg = KF_CHAN_TITLE_DEFAULT_FILL;
}
// Draw keyframe channel title bar and text
let chan_title_start = [ia_min[0], chan_y];
let chan_title_end = [
ia_min[0] + KF_CHAN_TITLE_BAR_WIDTH,
next_chan_y - 1.0
];
d.draw_list
.add_rect(chan_title_start, chan_title_end, title_bg)
.filled(true)
.thickness(0.0)
.build();
let title_pos = [
chan_title_start[0] + 3.0,
chan_title_start[1] + 3.0
];
d.draw_list.add_text(
title_pos,
ImColor32::WHITE,
String::from(&chan.name)
);
let origin: [f32; 2]; // Draw keyframe content bar
let chan_content_end = [ia_max[0], chan_title_start[1]];
if storage.keyframe_channels.len() == 0 { d.draw_list
let text_pos: [f32; 2] = [ .add_rect(chan_title_end, chan_content_end, content_bg)
area_min[0] + (area_max[0] - area_min[0]) / 2.0 - 50.0, .filled(true)
area_min[1] + (area_max[1] - area_min[1]) / 2.0 - 2.5 .thickness(0.0)
]; .build();
draw_list.add_text( // Draw keyframe channel border line
text_pos, if idx < storage.keyframe_channels.len() - 1 {
KF_CHAN_TITLE_HOVER_COLOR, let border_start = [chan_title_start[0], next_chan_y];
"No keyframes to show." let border_end = [ia_max[0], next_chan_y];
);
return; d.draw_list
.add_line(border_start, border_end, title_bg)
.build();
}
chan_y += KF_CHAN_HEIGHT;
next_chan_y += KF_CHAN_HEIGHT;
}
// Draw time marker bar
let ts_bar_start = [
ia_min[0] + KF_CHAN_TITLE_BAR_WIDTH,
ia_min[1]
];
let ts_bar_end = [
ia_max[0],
ia_min[1] + TS_BAR_HEIGHT
];
d.draw_list
.add_rect(
ts_bar_start,
ts_bar_end,
TS_BAR_BG_FILL
)
.filled(true)
.thickness(0.0)
.build();
// Process interactivity for timestamp bar
let ts_bar_button_size = [
ts_bar_end[0] - ts_bar_start[0],
ts_bar_end[1] - ts_bar_start[1]
];
let ts_bar_hovered =
ts_bar_start[0] <= mouse_pos[0]
&& ts_bar_end[0] >= mouse_pos[0]
&& ts_bar_start[1] <= mouse_pos[1]
&& ts_bar_end[1] >= mouse_pos[1];
let lmb_down = ui.is_mouse_down(MouseButton::Left);
if !k.ts_bar_active && lmb_down && ts_bar_hovered {
k.ts_bar_active = true;
} else if k.ts_bar_active && !lmb_down {
k.ts_bar_active = false;
}
if k.ts_bar_active {
let new_ts = k.first_timestamp_in_view
+ (
(mouse_pos[0] - ts_bar_start[0])
/ scaled_ts_dist
) as i64;
if new_ts >= 0 {
storage.timestamp = new_ts;
} else {
storage.timestamp = 0;
}
}
// Draw time marker ticks and numbers
let mut ts_x = ts_bar_start[0];
let mut timestamp_inc = k.first_timestamp_in_view;
while ts_x < ia_max[0] {
let is_10_mult = timestamp_inc % 10 == 0;
let line_start = [ts_x, ts_bar_end[1]];
let line_end = [
ts_x,
if is_10_mult {
ts_bar_end[1] - TS_TICK_LINE_TALL_HEIGHT
} else {
ts_bar_end[1] - TS_TICK_LINE_HEIGHT
} }
];
d.draw_list
.add_line(line_start, line_end, KF_CHAN_TITLE_HOVER_FILL)
.build();
let origin = [ if is_10_mult {
area_min[0] + offset[0], let text = format!("{}", timestamp_inc);
area_min[1] + offset[1] let text_pos = [
ts_x + 2.0,
ts_bar_end[1] - TS_TICK_LINE_TALL_HEIGHT
]; ];
let mut chan_y: f32 = 1.0; d.draw_list.add_text(
let mut next_chan_y: f32 = chan_y + KF_CHAN_HEIGHT; text_pos,
KF_CHAN_TITLE_HOVER_FILL,
for (idx, chan) in storage.keyframe_channels.iter().enumerate() { text
let title_bg: ImColor32; );
let content_bg: ImColor32;
if (
(mouse_y < origin[1] + next_chan_y) &&
(mouse_y > origin[1] + chan_y)
) {
content_bg = KF_CHAN_CONTENT_HOVER_COLOR;
title_bg = KF_CHAN_TITLE_HOVER_COLOR;
} else {
content_bg = KF_CHAN_CONTENT_DEFAULT_COLOR;
title_bg = KF_CHAN_TITLE_DEFAULT_COLOR;
}
let chan_title_start: [f32; 2] = [
origin[0] + 1.0,
origin[1] + chan_y + 1.0
];
let chan_title_end: [f32; 2] = [
origin[0] + 100.0,
origin[1] + next_chan_y + 1.0
];
draw_list
.add_rect(
chan_title_start,
chan_title_end,
title_bg
)
.filled(true)
.thickness(0.0)
.build();
let title_pos: [f32; 2] = [
origin[0] + 1.0 + 3.0,
origin[1] + chan_y + 2.0
];
draw_list.add_text(
title_pos,
ImColor32::WHITE,
String::from(&chan.name)
);
let chan_content_end: [f32; 2] = [
area_max[0],
chan_title_start[1]
];
draw_list
.add_rect(
chan_title_end,
chan_content_end,
content_bg
)
.filled(true)
.thickness(0.0)
.build();
if true {
let border_start = [
origin[0],
origin[1] + next_chan_y
];
let border_end = [
area_max[0],
origin[1] + next_chan_y
];
draw_list
.add_line(
border_start,
border_end,
title_bg
)
.build();
}
chan_y += KF_CHAN_HEIGHT;
next_chan_y += KF_CHAN_HEIGHT;
}
} }
timestamp_inc += 1;
ts_x += TS_TICK_LINE_DIST * k.scale;
}
if storage.timestamp >= k.first_timestamp_in_view {
// Draw timestamp tracker rect
let current_ts_offset =
storage.timestamp - k.first_timestamp_in_view;
let ts_rect_start = [
ts_bar_start[0]
+ (scaled_ts_dist * current_ts_offset as f32),
ts_bar_end[1]
];
let ts_rect_end = [
ts_rect_start[0] + scaled_ts_dist,
chan_y
];
d.draw_list
.add_rect(
ts_rect_start,
ts_rect_end,
TS_TRACKER_RECT_FILL
)
.filled(true)
.thickness(0.0)
.build();
// Draw timestamp tracker handle thingy
let text = format!("{}", storage.timestamp);
let text_width = ui.calc_text_size(&text)[0];
let ts_hdl_p1 = [ts_rect_start[0], ia_min[1]];
let ts_hdl_p2 = [
ts_rect_start[0] + text_width + 6.0,
ts_bar_end[1]
];
d.draw_list
.add_rect(
ts_hdl_p1,
ts_hdl_p2,
TS_TRACKER_HDL_FILL
)
.rounding(3.0)
.round_top_left(true)
.round_top_right(true)
.round_bot_right(true)
.round_bot_left(false)
.filled(true)
.thickness(0.0)
.build();
let ts_hdl_text_pos = [
ts_rect_start[0] + 3.0,
ts_bar_end[1] - TS_TICK_LINE_TALL_HEIGHT
];
d.draw_list.add_text(
ts_hdl_text_pos,
ImColor32::WHITE,
text
);
}
// Draw scale text
let scale_text_pos = [ia_max[0] - 100.0, ia_max[1] - 50.0];
d.draw_list.add_text(
scale_text_pos,
KF_CHAN_TITLE_HOVER_FILL,
format!("{:.2}x scale", k.scale)
); );
} }
} }

Loading…
Cancel
Save