kikikoz/src/main.rs

398 lines
13 KiB
Rust

use futures::future;
use futures::stream::StreamExt;
use gloo_timers::future::IntervalStream;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use sycamore::builder::prelude::*;
use sycamore::futures::spawn_local_scoped;
use sycamore::prelude::*;
use sycamore::rt::JsCast;
#[derive(Clone, Copy, PartialEq, Eq)]
enum ModeQueue {
Chrono,
Aléatoire,
}
impl ModeQueue {
fn set_aléatoire(&mut self, b: bool) {
match b {
false => *self = ModeQueue::Chrono,
true => *self = ModeQueue::Aléatoire,
}
}
}
impl std::fmt::Display for ModeQueue {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
let s = match self {
ModeQueue::Chrono => "Chrono",
ModeQueue::Aléatoire => "Aléatoire",
};
write!(fmt, "{}", s)
}
}
#[derive(Clone)]
struct ÉtatQueue<'a> {
nom: String,
id: usize,
mode: &'a Signal<ModeQueue>,
contenu: &'a Signal<Vec<String>>,
nouvelleau: &'a Signal<String>,
priorité: &'a Signal<u8>,
crédit_parole: &'a Signal<f32>,
à_détruire: &'a Signal<bool>,
}
impl<'a> PartialEq for ÉtatQueue<'a> {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl<'a> Eq for ÉtatQueue<'a> {}
impl<'a> ÉtatQueue<'a> {
fn new<'b>(cx: BoundedScope<'a, 'b>, nom: &str) -> Self {
ÉtatQueue {
nom: nom.into(),
mode: create_signal(cx, ModeQueue::Aléatoire),
contenu: create_signal(cx, vec![]),
nouvelleau: create_signal(cx, "".into()),
priorité: create_signal(cx, 1),
crédit_parole: create_signal(cx, 0.0),
id: thread_rng().gen(),
à_détruire: create_signal(cx, false),
}
}
fn len(&self) -> usize {
return self.contenu.get().len();
}
fn enqueue(&mut self, rng: &mut ThreadRng) {
//
let s = &self.nouvelleau;
let n = self.len();
let k = match *self.mode.get() {
ModeQueue::Chrono => n,
ModeQueue::Aléatoire => rng.gen_range(0, n + 1),
};
self.contenu.modify().insert(k, s.to_string())
}
fn dequeue(&mut self) -> Option<String> {
*self.crédit_parole.modify() -= 1.0;
if self.len() > 0 {
Some(self.contenu.modify().remove(0))
} else {
None
}
}
}
#[derive(Prop)]
struct QueueProp<'a> {
q: ÉtatQueue<'a>,
}
fn validate_queue<'a>(mut queue: ÉtatQueue<'a>) -> impl FnMut(web_sys::Event) + 'a {
move |ev: web_sys::Event| {
let mut rng = thread_rng();
let event: web_sys::KeyboardEvent = ev.unchecked_into();
if event.key_code() == 13 && !event.ctrl_key() {
queue.enqueue(&mut rng);
queue.nouvelleau.set(String::new())
}
}
}
#[component]
fn ConfigQueue<'a, G: Html>(cx: Scope<'a>, q: QueueProp<'a>) -> View<G> {
let queue: ÉtatQueue = q.q;
let prio_str = create_signal(cx, String::new());
let est_aléatoire = create_signal(cx, true);
let nom = queue.nom;
let suffix = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(15)
.map(char::from)
.collect::<String>();
create_effect(cx, || {
if let Ok(p) = prio_str.get().parse::<u8>() {
queue.priorité.set(p)
}
});
create_effect(cx, || prio_str.set(format!("{}", queue.priorité.get())));
create_effect(cx, || {
queue.mode.modify().set_aléatoire(*est_aléatoire.get())
});
create_effect(cx, || {
est_aléatoire.set(*queue.mode.get() == ModeQueue::Aléatoire)
});
section()
.c(h3().dyn_t(move || format!("{} ", nom)).c(button()
.t("Supprimer")
.on("click", move |_ev| (queue.à_détruire.set(true)))))
.c(input()
.attr("type", "checkbox")
.dyn_attr("id", {
let suffix = suffix.clone();
move || Some(format!("mode_queue_{}", &suffix.clone()))
})
.bind_checked(est_aléatoire))
.c(label()
.dyn_attr("for", {
let suffix = suffix.clone();
move || Some(format!("mode_queue_{}", &suffix.clone()))
})
.t("Entrée aléatoire"))
.c(br())
.c(input()
.dyn_attr("id", {
let suffix = suffix.clone();
move || Some(format!("prio_queue_{}", &suffix.clone()))
})
.attr("type", "range")
.attr("min", "1")
.attr("max", "7")
.bind_value(prio_str))
.c(label()
.dyn_attr("for", {
let suffix = suffix.clone();
move || Some(format!("prio_queue_{}", &suffix.clone()))
})
.dyn_t(|| format!("Priorité: {}", queue.priorité.get())))
.view(cx)
}
#[component]
fn Queue<'a, G: Html>(cx: Scope<'a>, queue: QueueProp<'a>) -> View<G> {
let ok_queue = validate_queue(queue.q.clone());
view! { cx,
div {
section(class="queue") {
h3 {(format!("{}", queue.q.nom))}
input(type="text", bind:value = queue.q.nouvelleau, on:keypress=ok_queue) {}
ul {
Keyed {
iterable: queue.q.contenu,
view: |cx, item: String| view! {cx, li { (item) }},
key: |x| x.clone()
}
}
}
}
}
}
#[derive(Prop, Clone, Copy)]
struct BarreCôtéProp<'a> {
queues: &'a Signal<Vec<ÉtatQueue<'a>>>,
}
fn classe_visible(b: bool) -> &'static str {
if b {
"visible"
} else {
"hidden"
}
}
#[component]
fn Explications<'a, G: Html>(cx: Scope<'a>) -> View<G> {
details()
.class("aide")
.c(summary().t("Comment utiliser Kikikoz"))
.c(p().t("Kikikoz est un outil qui vous aide à répartir la parole dans une réunion. Il s'exécute en toute discrétion dans votre navigateur: ").c(strong().t("il n'envoie aucune donnée où que ce soit.")))
.c(p().t("Quand vous lancez Kikikoz, l'interface que vous voyez correspond à une réunion avec un tour de parole divisé en deux files. C'est une pratique que l'on observe souvent, notamment pour répartir équitablement la parole entre femmes et hommes."))
.c(p().t("Quand une personne lève la main, inscrivez son nom dans le champ de texte correspondant à la file d'attente idoine, puis pressez ENTRÉE."))
.c(p().t("Pour donner la parole à la personne suivante, il suffit de cliquer sur le bouton \"suivantə\", ou de taper CTRL+ENTRÉE. Son nom et son temps de parole apparaissent alors en gros caractères"))
.c(p().t("La parole alterne entre les deux files. La prochaine personne à parler est donc celle qui est en tête de la file avec un fond vert et un titre en italique. Vous l'avez peut-être remarqué, quand une personne lève la main, elle ne va pas à la fin de la file, mais à une position aléatoire. C'est inhabituel, mais pas moins juste. Il y a des chances que ce qu'elle à a dire soit plus pertinent, puisqu'elle lève la main après avoir entendu plus de choses. Vous pouvez changer ce comportement pour chaque file."))
.c(p().t("Pour faire une pause dans la réunion (quand personne n'a la parole), cliquez sur \"pause\"."))
.c(p().t("Vous pouvez configurer toutes sortes de détails via le panneau configaration. Vous pouvez y ajouter ou supprimer des files, décider si l'ajout de personnes dans chaque file doit se faire à une position aléatoire ou à la fin. Vous pouvez également attribuer une priorité différente aux files, si vous décidez que la parole doit revenir à l'une d'entre elles plus souvent qu'aux autres."))
.view(cx)
}
#[component]
fn BarreCôté<'a, G: Html>(cx: Scope<'a>, p: BarreCôtéProp<'a>) -> View<G> {
let conf_visible = create_signal(cx, false);
let nom_nouvelle_file = create_signal(cx, String::new());
let nouvelle_file = {
let queues = p.queues.clone();
move |_ev| {
let q = ÉtatQueue::new(cx, &nom_nouvelle_file.get());
queues.modify().push(q);
*nom_nouvelle_file.modify() = String::new();
}
};
let nouvelle_file_clav = move |ev: web_sys::Event| {
let event: web_sys::KeyboardEvent = ev.clone().unchecked_into();
if event.key_code() == 13 && !event.ctrl_key() {
nouvelle_file(ev)
}
};
view! {cx,
aside {
details(class="configuration") {
summary { "Configuration " }
Keyed {
iterable: p.queues,
key: |q| q.id,
view: |cx, q| view!{cx, ConfigQueue{ q }}
}
section {
h3 {"Nouvelle file" }
input(type="text", bind:value=nom_nouvelle_file, on:keypress=nouvelle_file_clav) {}
button(on:click=nouvelle_file) { "Créer" }
}
}
}
}
}
fn main() {
sycamore::render(|cx| {
let heure_courante = create_signal(cx, 0.0);
let _ = spawn_local_scoped(cx, async {
IntervalStream::new(1000)
.for_each(|_| {
heure_courante.set(js_sys::Date::now());
future::ready(())
})
.await
});
let date_prise_parole = create_signal(cx, 0.0);
let courantə = create_signal(cx, None);
// let courantə2 = create_signal(cx, None).clone();
let queues = create_signal(
cx,
vec![ÉtatQueue::new(cx, "Gauche"), ÉtatQueue::new(cx, "Droite")],
);
create_effect(cx, || {
for q in queues.get().iter() {
q.à_détruire.track()
}
queues.modify().retain(|q| !(*q.à_détruire.get()));
});
let total_priorités: &ReadSignal<usize> = create_memo(cx, {
let queues = queues.clone();
move || {
queues
.get()
.iter()
.map(|q| *q.priorité.get() as usize)
.sum()
}
});
let queue_courante: &ReadSignal<(usize, usize)> = create_memo(cx, {
move || {
let (idx, (id, _q)) = queues
.get()
.iter()
.map(|q| (q.id, *q.crédit_parole.get()))
.enumerate()
.max_by(|(_ia, (_id_a, a)), (_ib, (_id_b, b))| {
a.partial_cmp(b).expect("Tried to compare a NaN")
})
.expect("bla");
(idx, id)
}
});
provide_context_ref(cx, queue_courante);
let pause = move |_ev| {
courantə.set(None);
};
let next = move |_ev| {
//js_sys::alert("coucou");
let mut queue: ÉtatQueue = queues.get()[queue_courante.get().0].clone();
match queue.dequeue() {
Some(p) => {
courantə.set(Some(p));
date_prise_parole.set(js_sys::Date::now())
}
None => {
courantə.set(None);
}
}
for q in queues.get().iter() {
*q.crédit_parole.modify() +=
(*q.priorité.get() as f32) / (*total_priorités.get() as f32)
}
};
view! { cx,
div(on:keypress= {let next = next.clone();
move |ev: web_sys::Event| {
let event: web_sys::KeyboardEvent = ev.clone().unchecked_into();
if event.key_code() == 13 && event.ctrl_key() {
next(ev)
}
}
}) {
main {
h1 { "Kikikoz" }
p(class="orateurice") {
(
match &*courantə.get() {
None => "En attente".into(),
Some(p) => {
let temps_parole = std::time::Duration::from_millis((*heure_courante.get() - *date_prise_parole.get()) as u64).as_secs();
format!("On écoute {} depuis {:2}:{:02}", p, temps_parole / 60, temps_parole % 60)
}
}
)
br {}
button(on:click=next, id="bouton_next") { "Suivantə" }
" "
button(on:click=pause) { "Pause" }
}
section(id="les_queues") {
Keyed {
iterable: queues, //_pour_aff,
key: |q| q.id,
view: {
let queue_courante = queue_courante.clone();
move |cx, q|
view!{ cx,
div(class=(if queue_courante.get().1 == q.id {"active"} else {"attente"})) {
Queue {q}
}
}
}
}
}
br {}
Explications {}
br {}
footer {
a(href="https://git.tausendblum.site/florent.becker/kikikoz") { "Voir le code source."}
}
}
BarreCôté {
queues
}
}
}
});
}