Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
c5425e8fc2
WIP 2023-01-18 21:44:18 +01:00
a25a6b2917
WIP 2023-01-18 21:41:09 +01:00
50569a3c56
feat(dtmm): Create initial mod manager window 2023-01-17 13:42:45 +01:00
15 changed files with 1936 additions and 0 deletions

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "lib/serde_sjson"] [submodule "lib/serde_sjson"]
path = lib/serde_sjson path = lib/serde_sjson
url = git@git.sclu1034.dev:lucas/serde_sjson.git url = git@git.sclu1034.dev:lucas/serde_sjson.git
[submodule "lib/druid-widget-extra"]
path = lib/druid-widget-extra
url = git@git.sclu1034.dev:lucas/druid-widget-extra.git

1087
Cargo.lock generated

File diff suppressed because it is too large Load diff

19
crates/dtmm/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "dtmm"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "unicode"] }
color-eyre = "0.6.2"
confy = "0.5.1"
druid = { git = "https://github.com/linebender/druid.git", features = ["im"] }
sdk = { path = "../../lib/sdk", version = "0.2.0" }
serde = "1.0.152"
tokio = "1.23.0"
toml = "0.5.10"
tracing = "0.1.37"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }

49
crates/dtmm/notes.adoc Normal file
View file

@ -0,0 +1,49 @@
= Notes
== Layout
- top bar:
- left aligned: a tab bar with "mods", "settings", "about"
- right aligned: a button to start the game
- in the future: center aligned a dropdown to select profiles, and button to edit them
- main view:
- left side: list view of mods
- right side: details pane and buttons
- always visible, first mod in list is selected by default
- buttons:
- add mod
- deploy mods
- remove selected mod
- enable/disable (text changes based on state)
== Mod list
- name
- description?
- image?
- click to get details pane?
== Managing mods
- for each mod in the list, have a checkbox
- need a button to remove mods
- need a button to add mods from downloaded files
- search
== Misc
- settings
- open mod storage
== Managing the game
- deploy mods
-
== Preparing the game
- click "Install mods" to prepare the game files with the enabled mods
== Playing the game
- if overlay file systems are used, the game has to be started through the mod manager

View 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)
}
}

45
crates/dtmm/src/main.rs Normal file
View file

@ -0,0 +1,45 @@
#![feature(let_chains)]
use clap::command;
use color_eyre::Report;
use color_eyre::Result;
use druid::AppLauncher;
use tracing_error::ErrorLayer;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
use crate::state::State;
mod controller;
mod main_window;
mod state;
mod theme;
mod widget;
#[tracing::instrument]
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let _matches = command!().get_matches();
{
let fmt_layer = tracing_subscriber::fmt::layer().pretty();
let filter_layer =
EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.with(ErrorLayer::new(
tracing_subscriber::fmt::format::Pretty::default(),
))
.init();
}
let initial_state = State::new();
AppLauncher::with_window(main_window::new())
.launch(initial_state)
.map_err(Report::new)
}

View file

@ -0,0 +1,232 @@
use druid::im::Vector;
use druid::widget::{
Align, Button, CrossAxisAlignment, Flex, Label, List, MainAxisAlignment, Maybe, Scroll, Split,
ViewSwitcher,
};
use druid::{lens, Insets, LensExt, Widget, WidgetExt, WindowDesc};
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_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_SIZE)
}
fn build_top_bar() -> impl Widget<State> {
Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Flex::row()
.with_child(
Button::new("Mods").on_click(|_ctx, state: &mut State, _env| {
state.set_current_view(View::Mods)
}),
)
.with_default_spacer()
.with_child(
Button::new("Settings")
.on_click(|_ctx, state: &mut State, _env| {
state.set_current_view(View::Settings)
})
.hidden_if(|_, _| true),
)
.with_default_spacer()
.with_child(
Button::new("About").on_click(|_ctx, state: &mut State, _env| {
state.set_current_view(View::About)
}),
),
)
.with_child(
Flex::row()
.with_child(Button::new("Deploy Mods").on_click(
|_ctx, _state: &mut State, _env| {
todo!();
},
))
.with_default_spacer()
.with_child(
Button::new("Run Game").on_click(|_ctx, _state: &mut State, _env| {
todo!();
}),
),
)
.padding(theme::TOP_BAR_INSETS)
.background(theme::TOP_BAR_BACKGROUND_COLOR)
// TODO: Add bottom border. Need a custom widget for that, as the built-in only provides
// uniform borders on all sides
}
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!((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)))
});
let scroll = Scroll::new(list)
.vertical()
.lens(State::mods.map(
|mods| {
mods.iter()
.enumerate()
.map(|(i, val)| (i, val.clone()))
.collect::<Vector<_>>()
},
|mods, infos| {
infos.into_iter().for_each(|(i, info)| {
mods.set(i, info);
});
},
))
.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> {
let details_container = Maybe::new(
|| {
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Label::raw().lens(ModInfo::name))
.with_flex_child(Label::raw().lens(ModInfo::description), 1.0)
},
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, _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, _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(
|| {
Button::dynamic(|enabled, _env| {
if *enabled {
"Disable Mod".into()
} else {
"Enable Mod".into()
}
})
.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| {
// TODO: Implement properly
let info = ModInfo::new();
state.add_mod(info);
});
let button_delete_mod = Button::new("Delete 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(
Flex::row()
.main_axis_alignment(MainAxisAlignment::End)
.with_child(button_move_up)
.with_default_spacer()
.with_child(button_move_down)
.padding(Insets::uniform_xy(5.0, 2.0)),
)
.with_child(
Flex::row()
.main_axis_alignment(MainAxisAlignment::End)
.with_child(button_toggle_mod)
.with_default_spacer()
.with_child(button_add_mod)
.with_default_spacer()
.with_child(button_delete_mod)
.padding(Insets::uniform_xy(5.0, 2.0)),
)
.with_default_spacer();
Flex::column()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_flex_child(details_container, 1.0)
.with_child(buttons)
}
fn build_view_mods() -> impl Widget<State> {
Split::columns(build_mod_list(), build_mod_details())
.split_point(0.75)
.min_size(0.0, MOD_DETAILS_MIN_WIDTH)
.solid_bar(true)
.bar_size(2.0)
.draggable(true)
}
fn build_view_settings() -> impl Widget<State> {
Label::new("Settings")
}
fn build_view_about() -> impl Widget<State> {
Align::centered(
Flex::column()
.with_child(Label::new("Darktide Mod Manager"))
.with_child(Label::new(
"Website: https://git.sclu1034.dev/bitsquid_dt/dtmt",
)),
)
}
fn build_main() -> impl Widget<State> {
ViewSwitcher::new(
|state: &State, _env| state.get_current_view(),
|selector, _state, _env| match selector {
View::Mods => Box::new(build_view_mods()),
View::Settings => Box::new(build_view_settings()),
View::About => Box::new(build_view_about()),
},
)
}
fn build_window() -> impl Widget<State> {
Flex::column()
.must_fill_main_axis(true)
.with_child(build_top_bar())
.with_flex_child(build_main(), 1.0)
.controller(StateController::new())
}

226
crates/dtmm/src/state.rs Normal file
View file

@ -0,0 +1,226 @@
use std::sync::Arc;
use druid::im::Vector;
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 {
Mods,
Settings,
About,
}
impl Default for View {
fn default() -> Self {
Self::Mods
}
}
#[derive(Clone, Data, Lens)]
pub(crate) struct ModInfo {
name: String,
description: Arc<String>,
enabled: bool,
}
impl ModInfo {
pub fn new() -> Self {
Self {
name: format!("Test Mod: {:?}", std::time::SystemTime::now()),
description: Arc::new(String::from("A test dummy")),
enabled: false,
}
}
}
impl PartialEq for ModInfo {
fn eq(&self, other: &Self) -> bool {
self.name.eq(&other.name)
}
}
#[derive(Clone, Data, Default, Lens)]
pub(crate) struct State {
current_view: View,
mods: Vector<ModInfo>,
selected_mod_index: Option<usize>,
}
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
.and_then(|i| data.mods.get(i).cloned());
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 {
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),
}
}
}
/// A Lens that maps an `im::Vector<T>` to `im::Vector<(usize, T)>`,
/// where each element in the destination vector includes its index in the
/// source vector.
pub(crate) struct IndexedVectorLens;
impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
#[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(&indexed)
}
#[tracing::instrument(name = "IndexedVectorLens::with_mut", skip_all)]
fn with_mut<V, F: FnOnce(&mut Vector<(usize, T)>) -> V>(
&self,
values: &mut Vector<T>,
f: F,
) -> V {
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
}
}
impl State {
#[allow(non_upper_case_globals)]
pub const selected_mod: SelectedModLens = SelectedModLens;
pub fn new() -> Self {
Default::default()
}
pub fn get_current_view(&self) -> View {
self.current_view
}
pub fn set_current_view(&mut self, view: View) {
self.current_view = view;
}
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),
}
}
}

4
crates/dtmm/src/theme.rs Normal file
View file

@ -0,0 +1,4 @@
use druid::{Color, Insets};
pub const TOP_BAR_BACKGROUND_COLOR: Color = Color::rgba8(255, 255, 255, 50);
pub const TOP_BAR_INSETS: Insets = Insets::uniform(5.0);

View file

@ -0,0 +1,7 @@
use druid::{Data, Widget, WidgetPod};
pub struct Container<T> {
child: WidgetPod<T, Box<dyn Widget<T>>>,
}
impl<T: Data> Container<T> {}

View file

@ -0,0 +1,63 @@
use std::f64::INFINITY;
use druid::widget::prelude::*;
use druid::{Point, WidgetPod};
pub struct FillContainer<T> {
child: WidgetPod<T, Box<dyn Widget<T>>>,
}
impl<T: Data> FillContainer<T> {
pub fn new(child: impl Widget<T> + 'static) -> Self {
Self {
child: WidgetPod::new(child).boxed(),
}
}
}
impl<T: Data> Widget<T> for FillContainer<T> {
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
self.child.event(ctx, event, data, env);
}
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
self.child.lifecycle(ctx, event, data, env)
}
#[tracing::instrument(name = "FillContainer", 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 = "FillContainer", level = "trace", skip_all)]
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
bc.debug_check("FillContainer");
let child_size = self.child.layout(ctx, bc, data, env);
let w = if bc.is_width_bounded() {
INFINITY
} else {
child_size.width
};
let h = if bc.is_height_bounded() {
INFINITY
} else {
child_size.height
};
let my_size = bc.constrain(Size::new(w, h));
self.child.set_origin(ctx, Point::new(0.0, 0.0));
tracing::trace!("Computed layout: size={}", my_size);
my_size
}
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
self.child.paint(ctx, data, env);
}
}

View 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);
}
}

View file

@ -0,0 +1,20 @@
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 {}

View 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> {}

@ -0,0 +1 @@
Subproject commit fd069ccf62af95bbbe204283e79cab1eeb83ecf1