WIP
This commit is contained in:
parent
50569a3c56
commit
a25a6b2917
7 changed files with 356 additions and 65 deletions
47
crates/dtmm/src/controller.rs
Normal file
47
crates/dtmm/src/controller.rs
Normal file
|
@ -0,0 +1,47 @@
|
|||
use druid::widget::{Button, Controller};
|
||||
use druid::{Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, UpdateCtx, Widget};
|
||||
|
||||
pub struct DisabledButtonController;
|
||||
|
||||
impl<T: Data> Controller<T, Button<T>> for DisabledButtonController {
|
||||
fn event(
|
||||
&mut self,
|
||||
child: &mut Button<T>,
|
||||
ctx: &mut EventCtx,
|
||||
event: &Event,
|
||||
data: &mut T,
|
||||
env: &Env,
|
||||
) {
|
||||
if !ctx.is_disabled() {
|
||||
ctx.set_disabled(true);
|
||||
ctx.request_paint();
|
||||
}
|
||||
child.event(ctx, event, data, env)
|
||||
}
|
||||
|
||||
fn lifecycle(
|
||||
&mut self,
|
||||
child: &mut Button<T>,
|
||||
ctx: &mut LifeCycleCtx,
|
||||
event: &LifeCycle,
|
||||
data: &T,
|
||||
env: &Env,
|
||||
) {
|
||||
child.lifecycle(ctx, event, data, env)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
child: &mut Button<T>,
|
||||
ctx: &mut UpdateCtx,
|
||||
old_data: &T,
|
||||
data: &T,
|
||||
env: &Env,
|
||||
) {
|
||||
if !ctx.is_disabled() {
|
||||
ctx.set_disabled(true);
|
||||
ctx.request_paint();
|
||||
}
|
||||
child.update(ctx, old_data, data, env)
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
#![feature(let_chains)]
|
||||
|
||||
use clap::command;
|
||||
use color_eyre::Report;
|
||||
use color_eyre::Result;
|
||||
|
@ -8,6 +10,7 @@ use tracing_subscriber::EnvFilter;
|
|||
|
||||
use crate::state::State;
|
||||
|
||||
mod controller;
|
||||
mod main_window;
|
||||
mod state;
|
||||
mod theme;
|
||||
|
|
|
@ -5,19 +5,21 @@ use druid::widget::{
|
|||
};
|
||||
use druid::{lens, Insets, LensExt, Widget, WidgetExt, WindowDesc};
|
||||
|
||||
use crate::state::{ModInfo, State, View};
|
||||
use crate::state::{
|
||||
ModInfo, State, StateController, View, ACTION_DELETE_SELECTED_MOD, ACTION_SELECTED_MOD_DOWN,
|
||||
ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD,
|
||||
};
|
||||
use crate::theme;
|
||||
use crate::widget::ExtraWidgetExt;
|
||||
|
||||
const TITLE: &str = "Darktide Mod Manager";
|
||||
const WINDOW_WIDTH: f64 = 800.0;
|
||||
const WINDOW_HEIGHT: f64 = 600.0;
|
||||
const WINDOW_SIZE: (f64, f64) = (800.0, 600.0);
|
||||
const MOD_DETAILS_MIN_WIDTH: f64 = 325.0;
|
||||
|
||||
pub(crate) fn new() -> WindowDesc<State> {
|
||||
WindowDesc::new(build_window())
|
||||
.title(TITLE)
|
||||
.window_size((WINDOW_WIDTH, WINDOW_HEIGHT))
|
||||
.window_size(WINDOW_SIZE)
|
||||
}
|
||||
|
||||
fn build_top_bar() -> impl Widget<State> {
|
||||
|
@ -33,9 +35,11 @@ fn build_top_bar() -> impl Widget<State> {
|
|||
)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("Settings").on_click(|_ctx, state: &mut State, _env| {
|
||||
Button::new("Settings")
|
||||
.on_click(|_ctx, state: &mut State, _env| {
|
||||
state.set_current_view(View::Settings)
|
||||
}),
|
||||
})
|
||||
.hidden_if(|_, _| true),
|
||||
)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
|
@ -68,32 +72,21 @@ fn build_mod_list() -> impl Widget<State> {
|
|||
let list = List::new(|| {
|
||||
Flex::row()
|
||||
.must_fill_main_axis(true)
|
||||
// .with_child(
|
||||
// Label::dynamic(|enabled, _env| {
|
||||
// if *enabled {
|
||||
// "Enabled".into()
|
||||
// } else {
|
||||
// "Disabled".into()
|
||||
// }
|
||||
// })
|
||||
// .lens(
|
||||
// lens::Identity
|
||||
// .map(
|
||||
// |(i, info)| info,
|
||||
// |(i, info), new_info| {
|
||||
// todo!();
|
||||
// },
|
||||
// )
|
||||
// .then(ModInfo::enabled),
|
||||
// ),
|
||||
// )
|
||||
// .with_child(Label::raw().lens(ModInfo::name))
|
||||
.on_click(|_ctx, state, _env| {
|
||||
todo!();
|
||||
.with_child(
|
||||
Label::dynamic(|enabled, _env| {
|
||||
if *enabled {
|
||||
"Enabled".into()
|
||||
} else {
|
||||
"Disabled".into()
|
||||
}
|
||||
})
|
||||
.lens(lens!((usize, ModInfo), 1).then(ModInfo::enabled)),
|
||||
)
|
||||
.with_child(Label::raw().lens(lens!((usize, ModInfo), 1).then(ModInfo::name)))
|
||||
.on_click(|ctx, (i, _info), _env| ctx.submit_notification(ACTION_SELECT_MOD.with(*i)))
|
||||
});
|
||||
|
||||
Scroll::new(list)
|
||||
let scroll = Scroll::new(list)
|
||||
.vertical()
|
||||
.lens(State::mods.map(
|
||||
|mods| {
|
||||
|
@ -108,7 +101,12 @@ fn build_mod_list() -> impl Widget<State> {
|
|||
});
|
||||
},
|
||||
))
|
||||
.content_must_fill()
|
||||
.content_must_fill();
|
||||
|
||||
Flex::column()
|
||||
.must_fill_main_axis(true)
|
||||
.with_child(Flex::row())
|
||||
.with_flex_child(scroll, 1.0)
|
||||
}
|
||||
|
||||
fn build_mod_details() -> impl Widget<State> {
|
||||
|
@ -121,23 +119,16 @@ fn build_mod_details() -> impl Widget<State> {
|
|||
},
|
||||
Flex::column,
|
||||
)
|
||||
.padding(Insets::uniform_xy(5.0, 1.0))
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let button_move_up = Button::new("Move Up")
|
||||
.on_click(|_ctx, index: &mut Option<usize>, _env| {
|
||||
if let Some(i) = index.as_mut() {
|
||||
*i = i.saturating_sub(1)
|
||||
}
|
||||
})
|
||||
.lens(State::selected_mod_index);
|
||||
.on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_SELECTED_MOD_UP))
|
||||
.disabled_if(|state: &State, _env: &druid::Env| state.can_move_mod_up());
|
||||
|
||||
let button_move_down = Button::new("Move Down")
|
||||
.on_click(|_ctx, index: &mut Option<usize>, _env| {
|
||||
if let Some(i) = index.as_mut() {
|
||||
*i = i.saturating_add(1)
|
||||
}
|
||||
})
|
||||
.lens(State::selected_mod_index);
|
||||
.on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_SELECTED_MOD_DOWN))
|
||||
.disabled_if(|state: &State, _env: &druid::Env| state.can_move_mod_down());
|
||||
|
||||
let button_toggle_mod = Maybe::new(
|
||||
|| {
|
||||
|
@ -145,17 +136,18 @@ fn build_mod_details() -> impl Widget<State> {
|
|||
if *enabled {
|
||||
"Disable Mod".into()
|
||||
} else {
|
||||
"Enabled Mod".into()
|
||||
"Enable Mod".into()
|
||||
}
|
||||
})
|
||||
.on_click(|_ctx, info: &mut bool, _env| {
|
||||
*info = !*info;
|
||||
.on_click(|_ctx, enabled: &mut bool, _env| {
|
||||
*enabled = !(*enabled);
|
||||
})
|
||||
.lens(ModInfo::enabled)
|
||||
},
|
||||
// TODO: Gray out
|
||||
|| Button::new("Enable Mod"),
|
||||
)
|
||||
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let button_add_mod = Button::new("Add Mod").on_click(|_ctx, state: &mut State, _env| {
|
||||
|
@ -165,7 +157,9 @@ fn build_mod_details() -> impl Widget<State> {
|
|||
});
|
||||
|
||||
let button_delete_mod = Button::new("Delete Mod")
|
||||
.on_click(|_ctx, data: &mut State, _env| data.delete_selected_mod());
|
||||
.on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_DELETE_SELECTED_MOD))
|
||||
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let buttons = Flex::column()
|
||||
.with_child(
|
||||
|
@ -234,4 +228,5 @@ fn build_window() -> impl Widget<State> {
|
|||
.must_fill_main_axis(true)
|
||||
.with_child(build_top_bar())
|
||||
.with_flex_child(build_main(), 1.0)
|
||||
.controller(StateController::new())
|
||||
}
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use druid::im::Vector;
|
||||
use druid::{Data, Lens};
|
||||
use druid::widget::Controller;
|
||||
use druid::{Data, Env, Event, EventCtx, Lens, Selector, Widget};
|
||||
|
||||
pub const ACTION_SELECT_MOD: Selector<usize> = Selector::new("dtmm.action..select-mod");
|
||||
pub const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up");
|
||||
pub const ACTION_SELECTED_MOD_DOWN: Selector = Selector::new("dtmm.action.selected-mod-down");
|
||||
pub const ACTION_DELETE_SELECTED_MOD: Selector = Selector::new("dtmm.action.delete-selected-mod");
|
||||
|
||||
#[derive(Copy, Clone, Data, PartialEq)]
|
||||
pub(crate) enum View {
|
||||
|
@ -48,6 +54,7 @@ pub(crate) struct State {
|
|||
pub(crate) struct SelectedModLens;
|
||||
|
||||
impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
||||
#[tracing::instrument(name = "SelectedModLens::with", skip_all)]
|
||||
fn with<V, F: FnOnce(&Option<ModInfo>) -> V>(&self, data: &State, f: F) -> V {
|
||||
let info = data
|
||||
.selected_mod_index
|
||||
|
@ -56,11 +63,25 @@ impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
|||
f(&info)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "SelectedModLens::with_mut", skip_all)]
|
||||
fn with_mut<V, F: FnOnce(&mut Option<ModInfo>) -> V>(&self, data: &mut State, f: F) -> V {
|
||||
let mut info = data
|
||||
.selected_mod_index
|
||||
.and_then(|i| data.mods.get_mut(i).cloned());
|
||||
f(&mut info)
|
||||
match data.selected_mod_index {
|
||||
Some(i) => {
|
||||
let mut info = data.mods.get_mut(i).cloned();
|
||||
let ret = f(&mut info);
|
||||
|
||||
if let Some(info) = info {
|
||||
// TODO: Figure out a way to check for equality and
|
||||
// only update when needed
|
||||
data.mods.set(i, info);
|
||||
} else {
|
||||
data.selected_mod_index = None;
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
None => f(&mut None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,21 +91,33 @@ impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
|||
pub(crate) struct IndexedVectorLens;
|
||||
|
||||
impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
|
||||
fn with<V, F: FnOnce(&Vector<(usize, T)>) -> V>(&self, data: &Vector<T>, f: F) -> V {
|
||||
let data = data
|
||||
#[tracing::instrument(name = "IndexedVectorLens::with", skip_all)]
|
||||
fn with<V, F: FnOnce(&Vector<(usize, T)>) -> V>(&self, values: &Vector<T>, f: F) -> V {
|
||||
let indexed = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, val)| (i, val.clone()))
|
||||
.collect();
|
||||
f(&data)
|
||||
f(&indexed)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "IndexedVectorLens::with_mut", skip_all)]
|
||||
fn with_mut<V, F: FnOnce(&mut Vector<(usize, T)>) -> V>(
|
||||
&self,
|
||||
data: &mut Vector<T>,
|
||||
values: &mut Vector<T>,
|
||||
f: F,
|
||||
) -> V {
|
||||
todo!()
|
||||
let mut indexed = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, val)| (i, val.clone()))
|
||||
.collect();
|
||||
let ret = f(&mut indexed);
|
||||
tracing::trace!("with_mut: {}", indexed.len());
|
||||
|
||||
*values = indexed.into_iter().map(|(_i, val)| val).collect();
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,16 +137,90 @@ impl State {
|
|||
self.current_view = view;
|
||||
}
|
||||
|
||||
pub fn delete_selected_mod(&mut self) {
|
||||
let Some(index) = self.selected_mod_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.mods.remove(index);
|
||||
pub fn select_mod(&mut self, index: usize) {
|
||||
self.selected_mod_index = Some(index);
|
||||
}
|
||||
|
||||
pub fn add_mod(&mut self, info: ModInfo) {
|
||||
self.mods.push_back(info);
|
||||
self.selected_mod_index = Some(self.mods.len() - 1);
|
||||
}
|
||||
|
||||
pub fn can_move_mod_down(&self) -> bool {
|
||||
self.selected_mod_index
|
||||
.map(|i| i >= (self.mods.len().saturating_sub(1)))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn can_move_mod_up(&self) -> bool {
|
||||
self.selected_mod_index.map(|i| i == 0).unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StateController {}
|
||||
|
||||
impl StateController {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Widget<State>> Controller<State, W> for StateController {
|
||||
#[tracing::instrument(name = "StateController::event", skip_all)]
|
||||
fn event(
|
||||
&mut self,
|
||||
child: &mut W,
|
||||
ctx: &mut EventCtx,
|
||||
event: &Event,
|
||||
state: &mut State,
|
||||
env: &Env,
|
||||
) {
|
||||
match event {
|
||||
Event::Notification(notif) if notif.is(ACTION_SELECT_MOD) => {
|
||||
ctx.set_handled();
|
||||
let index = notif
|
||||
.get(ACTION_SELECT_MOD)
|
||||
.expect("notification type didn't match after check");
|
||||
|
||||
state.select_mod(*index);
|
||||
}
|
||||
Event::Notification(notif) if notif.is(ACTION_SELECTED_MOD_UP) => {
|
||||
ctx.set_handled();
|
||||
let Some(i) = state.selected_mod_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let len = state.mods.len();
|
||||
if len == 0 || i == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
state.mods.swap(i, i - 1);
|
||||
state.selected_mod_index = Some(i - 1);
|
||||
}
|
||||
Event::Notification(notif) if notif.is(ACTION_SELECTED_MOD_DOWN) => {
|
||||
ctx.set_handled();
|
||||
let Some(i) = state.selected_mod_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let len = state.mods.len();
|
||||
if len == 0 || i == usize::MAX || i >= len - 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
state.mods.swap(i, i + 1);
|
||||
state.selected_mod_index = Some(i + 1);
|
||||
}
|
||||
Event::Notification(notif) if notif.is(ACTION_DELETE_SELECTED_MOD) => {
|
||||
ctx.set_handled();
|
||||
let Some(index) = state.selected_mod_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.mods.remove(index);
|
||||
}
|
||||
_ => child.event(ctx, event, state, env),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
60
crates/dtmm/src/widget/hidden_if.rs
Normal file
60
crates/dtmm/src/widget/hidden_if.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use druid::widget::prelude::*;
|
||||
use druid::{Point, WidgetPod};
|
||||
|
||||
pub struct HiddenIf<T, W> {
|
||||
child: WidgetPod<T, W>,
|
||||
hidden_if: Box<dyn Fn(&T, &Env) -> bool>,
|
||||
}
|
||||
|
||||
impl<T: Data, W: Widget<T>> HiddenIf<T, W> {
|
||||
pub fn new(child: W, hidden_if: impl Fn(&T, &Env) -> bool + 'static) -> Self {
|
||||
Self {
|
||||
hidden_if: Box::new(hidden_if),
|
||||
child: WidgetPod::new(child),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data, W: Widget<T>> Widget<T> for HiddenIf<T, W> {
|
||||
#[tracing::instrument(name = "HideContainer", level = "trace", skip_all)]
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
|
||||
let hidden = (self.hidden_if)(data, env);
|
||||
ctx.set_disabled(hidden);
|
||||
self.child.event(ctx, event, data, env);
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "HideContainer", level = "trace", skip_all)]
|
||||
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
|
||||
let hidden = (self.hidden_if)(data, env);
|
||||
ctx.set_disabled(hidden);
|
||||
self.child.lifecycle(ctx, event, data, env)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "HideContainer", level = "trace", skip_all)]
|
||||
fn update(&mut self, ctx: &mut UpdateCtx, _: &T, data: &T, env: &Env) {
|
||||
self.child.update(ctx, data, env);
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "HideContainer", level = "trace", skip_all)]
|
||||
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
|
||||
bc.debug_check("HideContainer");
|
||||
let hidden = (self.hidden_if)(data, env);
|
||||
if hidden {
|
||||
return Size::ZERO;
|
||||
}
|
||||
|
||||
let child_size = self.child.layout(ctx, bc, data, env);
|
||||
self.child.set_origin(ctx, Point::new(0.0, 0.0));
|
||||
child_size
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "HideContainer", level = "trace", skip_all)]
|
||||
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
|
||||
let hidden = (self.hidden_if)(data, env);
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
|
||||
self.child.paint(ctx, data, env);
|
||||
}
|
||||
}
|
|
@ -1,14 +1,20 @@
|
|||
use druid::{Data, Widget};
|
||||
use druid::{Data, Env, Widget};
|
||||
|
||||
use self::fill_container::FillContainer;
|
||||
use self::hidden_if::HiddenIf;
|
||||
|
||||
pub mod container;
|
||||
pub mod fill_container;
|
||||
pub mod hidden_if;
|
||||
|
||||
pub trait ExtraWidgetExt<T: Data>: Widget<T> + Sized + 'static {
|
||||
fn content_must_fill(self) -> FillContainer<T> {
|
||||
FillContainer::new(self)
|
||||
}
|
||||
|
||||
fn hidden_if(self, hidden_if: impl Fn(&T, &Env) -> bool + 'static) -> HiddenIf<T, Self> {
|
||||
HiddenIf::new(self, hidden_if)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data, W: Widget<T> + 'static> ExtraWidgetExt<T> for W {}
|
||||
|
|
73
crates/dtmm/src/widget/table_select.rs
Normal file
73
crates/dtmm/src/widget/table_select.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use druid::widget::{Controller, Flex};
|
||||
use druid::{Data, Widget};
|
||||
|
||||
pub struct TableSelect<T> {
|
||||
widget: Flex<T>,
|
||||
controller: TableSelectController<T>,
|
||||
}
|
||||
|
||||
impl<T: Data> TableSelect<T> {
|
||||
pub fn new(values: impl IntoIterator<Item = (impl Widget<T> + 'static)>) -> Self {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data> Widget<T> for TableSelect<T> {
|
||||
fn event(
|
||||
&mut self,
|
||||
ctx: &mut druid::EventCtx,
|
||||
event: &druid::Event,
|
||||
data: &mut T,
|
||||
env: &druid::Env,
|
||||
) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn lifecycle(
|
||||
&mut self,
|
||||
ctx: &mut druid::LifeCycleCtx,
|
||||
event: &druid::LifeCycle,
|
||||
data: &T,
|
||||
env: &druid::Env,
|
||||
) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &mut druid::UpdateCtx, old_data: &T, data: &T, env: &druid::Env) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
ctx: &mut druid::LayoutCtx,
|
||||
bc: &druid::BoxConstraints,
|
||||
data: &T,
|
||||
env: &druid::Env,
|
||||
) -> druid::Size {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn paint(&mut self, ctx: &mut druid::PaintCtx, data: &T, env: &druid::Env) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
struct TableSelectController<T> {
|
||||
inner: T,
|
||||
}
|
||||
|
||||
impl<T: Data> TableSelectController<T> {}
|
||||
|
||||
impl<T: Data> Controller<T, Flex<T>> for TableSelectController<T> {}
|
||||
|
||||
pub struct TableItem<T> {
|
||||
inner: dyn Widget<T>,
|
||||
}
|
||||
|
||||
impl<T: Data> TableItem<T> {
|
||||
pub fn new(inner: impl Widget<T>) -> Self {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data> Widget<T> for TableItem<T> {}
|
Loading…
Add table
Reference in a new issue