Merge pull request #8 from terhechte/feature/wasm_demo

WASM Support
pull/12/head
Benedikt Terhechte 3 years ago committed by GitHub
commit 33acea62f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,2 @@
[build]
#target = "wasm32-unknown-unknown"

24
Cargo.lock generated

@ -202,10 +202,12 @@ version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"js-sys",
"libc",
"num-integer",
"num-traits",
"time 0.1.44",
"wasm-bindgen",
"winapi",
]
@ -256,6 +258,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if 1.0.0",
"wasm-bindgen",
]
[[package]]
name = "copypasta"
version = "0.7.1"
@ -1562,6 +1574,17 @@ dependencies = [
"ps-importer",
]
[[package]]
name = "postsack-web"
version = "0.2.0"
dependencies = [
"console_error_panic_hook",
"ps-core",
"ps-gui",
"serde",
"wasm-bindgen",
]
[[package]]
name = "ppv-lite86"
version = "0.2.15"
@ -1658,7 +1681,6 @@ dependencies = [
"objc",
"once_cell",
"ps-core",
"ps-database",
"ps-importer",
"rand",
"shellexpand",

@ -4,7 +4,8 @@ members = [
"ps-database",
"ps-importer",
"ps-gui",
"postsack",
"postsack-native",
"postsack-web",
]
[profile.dev]

@ -0,0 +1,29 @@
#!/bin/sh
set -e
rm -rf ../target/release/bundle/osx/Postsack.app
# Build for x86 and ARM
cargo build --release --target=aarch64-apple-darwin
cargo build --release --target=x86_64-apple-darwin
# Combine into a fat binary
lipo -create ../target/aarch64-apple-darwin/release/postsack ../target/x86_64-apple-darwin/release/postsack -output postsack
# Perform Cargo bundle to create a macOS Bundle
cargo bundle --release
# Override bundle binary with the fat one
# Also: We want to have `Postsack` capitalized on macOS, so we rename
rm ../target/release/bundle/osx/Postsack.app/Contents/MacOS/postsack
mv ./postsack ../target/release/bundle/osx/Postsack.app/Contents/MacOS/
# Tell the Info.plist or binary is capitalized
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable Postsack" "../target/release/bundle/osx/Postsack.app/Contents/Info.plist"

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 752 B

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 519 KiB

@ -0,0 +1,10 @@
use ps_database::Database;
use ps_gui::{eframe, PostsackApp};
fn main() {
#[cfg(debug_assertions)]
ps_core::setup_tracing();
let options = eframe::NativeOptions::default();
eframe::run_native(Box::new(PostsackApp::<Database>::new()), options);
}

@ -1,7 +1,7 @@
use ps_core::{
self,
model::{self, Engine, Rect},
Config, DatabaseLike, Field, Filter, FormatType, Importerlike, ValueField,
Config, DatabaseLike, DatabaseQuery, Field, Filter, FormatType, Importerlike, ValueField,
};
use ps_database::Database;
use ps_importer::mbox_importer;

@ -1,4 +1,4 @@
use ps_core::{self, DatabaseLike, FormatType, Importerlike};
use ps_core::{self, DatabaseLike, DatabaseQuery, FormatType, Importerlike};
use ps_database::Database;
use ps_importer;

@ -0,0 +1,3 @@
/target
target
.DS_Store

2633
postsack-web/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,17 @@
[package]
name = "postsack-web"
version = "0.2.0"
edition = "2021"
description = "Provides a high level visual overview of swaths of email"
[lib]
crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ps-gui = { path = "../ps-gui" }
ps-core = { path = "../ps-core" }
serde = { version = "1.0.131", features = ["derive"]}
wasm-bindgen = "*"
console_error_panic_hook = "0.1.7"

@ -0,0 +1,31 @@
# Postsack Web
This is the WASM / Web version of Postsack. It uses fake email data to provide a web demo
so that interested parties can try out Postsack native / the app without having to install
it on their device.
## Building Postsack Web
First, you need to make sure all dependencies are installed:
``` sh
cd postsack-web
./setup_web.sh
```
Once this is done, building can be performed with a single script:
``` sh
./build_web.sh
```
## Testing
In order to simplify testing, `build_web.sh` will launch a browser on `localhost:8080`.
By default, `setup_web.sh` will install the `basic-http-server` so that you can run it
in the `web_demo` folder prior to running `build-web.sh`:
``` sh
cd web_demo
basic-http-server -a 127.0.0.1:8080 .
```

@ -17,11 +17,15 @@ rm -f web_demo/${CRATE_NAME_SNAKE_CASE}_bg.wasm
echo "Building rust…"
BUILD=release
cargo build --release -p ${CRATE_NAME} --lib --target wasm32-unknown-unknown
if [[ "$BUILD" == "debug" ]]; then
cargo build -p ${CRATE_NAME} --lib --target wasm32-unknown-unknown
else
cargo build --${BUILD} -p ${CRATE_NAME} --lib --target wasm32-unknown-unknown
fi
echo "Generating JS bindings for wasm…"
TARGET_NAME="${CRATE_NAME_SNAKE_CASE}.wasm"
wasm-bindgen "target/wasm32-unknown-unknown/${BUILD}/${TARGET_NAME}" \
wasm-bindgen "../target/wasm32-unknown-unknown/${BUILD}/${TARGET_NAME}" \
--out-dir web_demo --no-modules --no-typescript
# to get wasm-opt: apt/brew/dnf install binaryen

@ -0,0 +1,13 @@
# Generate Data
This folder contains generated fake data from [https://generatedata.com/generator](https://generatedata.com/generator).
This data is used to generate `../src/generated.rs` so that the WASM build is compiled with data to be
used in the web demo.
`generated.rs` can be rebuild via the following command:
``` sh
cd fake_data # make sure you're in this folder
python ./generate.py
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,95 @@
# parse all the json files in this folder and generate a rust file
# containing generated data
import glob
import json
import random
import sys
entries = []
output_rust_file = "../src/generated.rs"
# Coalesce data
for json_file in glob.glob('*.json'):
parsed = json.load(open(json_file, "r"))
entries.extend(parsed)
# For each entry, generate a struct
to_address = "john@doe.com"
to_name = ""
struct_template = """Entry { %(fields)s }"""
output_template = """
use super::database::Entry;
pub const ENTRIES: [Entry; %(amount)s] = [
%(content)s
];
"""
# To generate some more data, we keep some email addresses to
# generate 10-12 additional emails with that address afterwards
additional_emails = []
generated_entries = []
def fields_from_entry(entry):
k = {}
k["sender_name"] = entry["name"]
email = entry["email"].split("@")
k["sender_domain"] = email[1]
k["sender_local_part"] = email[0]
date = entry["date"].split(",")
(k["year"], k["month"], k["day"]) = (int(date[0]), int(date[1]), int(date[2]))
k["timestamp"] = int(entry["time"])
k["is_reply"] = True if entry["reply"] == 1 else False
k["is_send"] = True if entry["send"] == 1 else False
k["subject"] = entry["subject"]
k["to_address"] = to_address
k["to_name"] = to_name
return k
def fields_to_string(k):
fields = []
for key in k:
value = k[key]
if type(value) == type(0):
fields.append("%s: %s" % (key, value))
elif type(value) == type(True):
fields.append("%s: %s" % (key, "true" if value == True else "false"))
elif type(value) == type(""):
fields.append("%s: \"%s\"" % (key, value))
elif type(value) == type(u""):
fields.append("%s: \"%s\"" % (key, value))
else:
print(value, type(value))
sys.exit(0)
return ", ".join(fields)
# first run over the emails
for entry in entries:
k = fields_from_entry(entry)
# Generate additional mails
if random.uniform(0.0, 1.0) > 0.7:
for _ in range(0, random.randint(5, 50)):
additional_emails.append((entry["email"], entry["name"]))
joined_fields = fields_to_string(k)
generated_entries.append(struct_template % { "fields": joined_fields })
# second run over the email to generate additional entries with the same
# email address so we have some clusters
for (entry, (email, name)) in zip(entries, additional_emails):
entry["email"] = email
entry["name"] = name
k = fields_from_entry(entry)
joined_fields = fields_to_string(k)
generated_entries.append(struct_template % { "fields": joined_fields })
writer = open(output_rust_file, "w")
entries_string = ",\n".join(generated_entries)
writer.write(output_template % { "content": entries_string, "amount": len(generated_entries) })
writer.close()

@ -0,0 +1,341 @@
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::thread::JoinHandle;
use std::{ops::Range, path::Path};
use ps_core::{
crossbeam_channel::Sender,
eyre::{bail, Result},
Config, DBMessage, DatabaseLike, DatabaseQuery, Field, Filter, Query, QueryResult, Value,
ValueField,
};
use ps_core::{OtherQuery, QueryRow};
#[derive(Default, Clone)]
pub struct Entry {
pub sender_domain: &'static str,
pub sender_local_part: &'static str,
pub sender_name: &'static str,
pub year: usize,
pub month: usize,
pub day: usize,
pub timestamp: usize,
pub subject: &'static str,
pub to_name: &'static str,
pub to_address: &'static str,
pub is_reply: bool,
pub is_send: bool,
}
impl Entry {
fn value(&self, field: &Field) -> Value {
match field {
Field::Path => Value::String("".to_string()),
Field::SenderDomain => Value::String(self.sender_domain.to_string()),
Field::SenderLocalPart => Value::String(self.sender_local_part.to_string()),
Field::SenderName => Value::String(self.sender_name.to_string()),
Field::Subject => Value::String(self.subject.to_string()),
Field::ToName => Value::String(self.to_name.to_string()),
Field::ToAddress => Value::String(self.to_address.to_string()),
Field::ToGroup => Value::String("".to_string()),
Field::Year => Value::Number(self.year.into()),
Field::Month => Value::Number(self.month.into()),
Field::Day => Value::Number(self.day.into()),
Field::Timestamp => Value::Number(self.timestamp.into()),
Field::IsReply => Value::Bool(self.is_reply),
Field::IsSend => Value::Bool(self.is_send),
Field::MetaIsSeen => Value::Bool(false),
Field::MetaTags => Value::Array(Vec::new()),
}
}
fn as_row(&self, fields: &[Field]) -> QueryRow {
let mut row = QueryRow::new();
for field in fields {
let value = self.value(&field);
let value_field = ValueField::new(&field, value);
row.insert(*field, value_field);
}
row
}
}
#[derive(Debug, PartialEq, Eq)]
struct HashedValue(Value);
impl Hash for HashedValue {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match &self.0 {
Value::String(s) => s.hash(state),
Value::Number(s) => s.hash(state),
Value::Array(s) => {
format!("{:?}", s).hash(state);
}
Value::Bool(s) => s.hash(state),
_ => {
format!("{:?}", &self.0).hash(state);
}
}
}
}
pub struct FakeDatabase;
impl FakeDatabase {
pub fn total_item_count() -> usize {
ENTRIES.len()
}
fn query_normal(
&self,
fields: &Vec<Field>,
filters: &Vec<Filter>,
range: &Range<usize>,
) -> Vec<QueryResult> {
let entries = self.filtered(filters);
let mut result = Vec::new();
for entry in entries.skip(range.start).take(range.end) {
result.push(QueryResult::Normal(entry.as_row(fields)));
}
result
}
fn query_grouped(&self, filters: &Vec<Filter>, group_by: &Field) -> Vec<QueryResult> {
let mut map = HashMap::<HashedValue, usize>::new();
for entry in self
.filtered(filters)
.map(|e| HashedValue(e.value(group_by)))
{
let entry = map.entry(entry).or_insert(0);
*entry += 1;
}
let mut result = Vec::new();
for (key, value) in map {
result.push(QueryResult::Grouped {
value: ValueField::new(group_by, key.0),
count: value,
})
}
result
}
fn query_other(&self, field: &Field) -> Vec<QueryResult> {
let mut set = HashSet::<HashedValue>::new();
for entry in &ENTRIES {
let hashed_entry = HashedValue(entry.value(field));
if !set.contains(&hashed_entry) {
set.insert(hashed_entry);
}
}
let mut result = Vec::new();
for value in set {
result.push(QueryResult::Other(ValueField::new(field, value.0)));
}
result
}
fn filtered<'a>(&'a self, filters: &'a Vec<Filter>) -> impl Iterator<Item = &'a Entry> {
ENTRIES.iter().filter(move |entry| {
for filter in filters {
// Go through all filters and escape early if they don't match
match filter {
Filter::Like(vf) => {
let other = entry.value(vf.field());
if vf.value() != &other {
return false;
}
}
Filter::NotLike(vf) => {
let other = entry.value(vf.field());
if vf.value() == &other {
return false;
}
}
Filter::Contains(vf) => {
let other = entry.value(vf.field());
match (&other, vf.value()) {
(Value::String(a), Value::String(b)) => {
if !a.contains(b) {
return false;
}
}
_ => {
let s1 = format!("{}", vf.value());
let s2 = format!("{}", &other);
if !s2.contains(&s1) {
return false;
}
}
}
}
Filter::Is(vf) => {
let other = entry.value(vf.field());
if vf.value() != &other {
return false;
}
}
}
}
true
})
}
}
impl Clone for FakeDatabase {
fn clone(&self) -> Self {
FakeDatabase
}
}
impl DatabaseQuery for FakeDatabase {
fn query(&self, query: &Query) -> Result<Vec<QueryResult>> {
match query {
Query::Normal {
fields,
filters,
range,
} => Ok(self.query_normal(fields, filters, range)),
Query::Grouped { filters, group_by } => Ok(self.query_grouped(filters, group_by)),
Query::Other {
query: OtherQuery::All(q),
} => Ok(self.query_other(q)),
}
}
}
impl DatabaseLike for FakeDatabase {
fn new(_path: impl AsRef<Path>) -> Result<Self>
where
Self: Sized,
{
Ok(FakeDatabase {})
}
fn config(_path: impl AsRef<Path>) -> Result<Config>
where
Self: Sized,
{
bail!("Na")
}
fn total_mails(&self) -> Result<usize> {
Ok(ENTRIES.len())
}
fn import(self) -> (Sender<DBMessage>, JoinHandle<Result<usize>>) {
panic!()
}
fn save_config(&self, _config: Config) -> Result<()> {
Ok(())
}
}
#[cfg(target_arch = "wasm32")]
use super::generated::ENTRIES;
#[cfg(not(target_arch = "wasm32"))]
const ENTRIES: [Entry; 7] = [
Entry {
sender_local_part: "tellus.non.magna",
is_send: true,
to_address: "john@doe.com",
sender_name: "Sybill Fleming",
timestamp: 1625731134,
month: 12,
to_name: "",
is_reply: false,
year: 2013,
sender_domain: "protonmail.edu",
day: 28,
subject: "libero et tristique pellentesque, tellus sem mollis dui,",
},
Entry {
sender_local_part: "mauris.sapien",
is_send: true,
to_address: "john@doe.com",
sender_name: "Ignatius Reed",
timestamp: 1645571678,
month: 10,
to_name: "",
is_reply: true,
year: 2020,
sender_domain: "icloud.com",
day: 26,
subject: "nisi magna sed dui. Fusce aliquam,",
},
Entry {
sender_local_part: "magna.nam",
is_send: false,
to_address: "john@doe.com",
sender_name: "Geraldine Gay",
timestamp: 1631684202,
month: 8,
to_name: "",
is_reply: true,
year: 2016,
sender_domain: "aol.org",
day: 18,
subject: "semper auctor. Mauris vel turpis. Aliquam adipiscing",
},
Entry {
sender_local_part: "tortor",
is_send: true,
to_address: "john@doe.com",
sender_name: "Colt Clark",
timestamp: 1640866204,
month: 4,
to_name: "",
is_reply: true,
year: 2012,
sender_domain: "aol.ca",
day: 2,
subject: "hendrerit id, ante. Nunc mauris sapien, cursus",
},
Entry {
sender_local_part: "urna.convallis.erat",
is_send: true,
to_address: "john@doe.com",
sender_name: "Joy Clark",
timestamp: 1646836804,
month: 2,
to_name: "",
is_reply: true,
year: 2020,
sender_domain: "protonmail.ca",
day: 10,
subject: "dui nec urna suscipit nonummy. Fusce fermentum fermentum arcu. Vestibulum",
},
Entry {
sender_local_part: "amet.luctus",
is_send: false,
to_address: "john@doe.com",
sender_name: "Ray Bowers",
timestamp: 1609958850,
month: 6,
to_name: "",
is_reply: false,
year: 2015,
sender_domain: "protonmail.org",
day: 30,
subject: "turpis egestas. Aliquam fringilla cursus",
},
Entry {
sender_local_part: "vehicula.et",
is_send: true,
to_address: "john@doe.com",
sender_name: "Maris Shaw",
timestamp: 1612463990,
month: 10,
to_name: "",
is_reply: false,
year: 2018,
sender_domain: "hotmail.ca",
day: 30,
subject: "molestie orci tincidunt",
},
];

File diff suppressed because it is too large Load Diff

@ -0,0 +1,31 @@
#[cfg(target_arch = "wasm32")]
use ps_core::{Config, FormatType};
#[cfg(target_arch = "wasm32")]
use ps_gui::{eframe, PostsackApp};
mod database;
#[cfg(target_arch = "wasm32")]
mod generated;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
use console_error_panic_hook;
/// Call this once from the HTML.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen]
pub fn start(canvas_id: &str) -> Result<(), eframe::wasm_bindgen::JsValue> {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
use database::FakeDatabase;
let format = FormatType::AppleMail;
let config = Config::new(None, "", vec!["test@gmail.com".to_owned()], format).unwrap();
let app = PostsackApp::<database::FakeDatabase>::new(config, FakeDatabase::total_item_count());
eframe::start_web(canvas_id, Box::new(app))
}

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- Disable zooming: -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<head>
<title>Postsack Web</title>
<style>
html {
/* Remove touch delay: */
touch-action: manipulation;
}
body {
/* Light mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #909090;
}
@media (prefers-color-scheme: dark) {
body {
/* Dark mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #404040;
}
}
/* Allow canvas to fill entire web page: */
html,
body {
overflow: hidden;
margin: 0 !important;
padding: 0 !important;
}
/* Position canvas in center-top: */
canvas {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 0%;
left: 50%;
transform: translate(-50%, 0%);
}
</style>
</head>
<body>
<!-- The WASM code will resize this dynamically -->
<canvas id="the_canvas_id"></canvas>
<script>
// The `--no-modules`-generated JS from `wasm-bindgen` attempts to use
// `WebAssembly.instantiateStreaming` to instantiate the wasm module,
// but this doesn't work with `file://` urls. This example is frequently
// viewed by simply opening `index.html` in a browser (with a `file://`
// url), so it would fail if we were to call this function!
//
// Work around this for now by deleting the function to ensure that the
// `no_modules.js` script doesn't have access to it. You won't need this
// hack when deploying over HTTP.
delete WebAssembly.instantiateStreaming;
</script>
<!-- this is the JS generated by the `wasm-bindgen` CLI tool -->
<script src="postsack_web.js"></script>
<script>
// We'll defer our execution until the wasm is ready to go.
// Here we tell bindgen the path to the wasm file so it can start
// initialization and return to us a promise when it's done.
wasm_bindgen("./postsack_web_bg.wasm")
.then(on_wasm_loaded)
.catch(console.error);
function on_wasm_loaded() {
// This call installs a bunch of callbacks and then returns.
console.log("loaded wasm, starting egui app");
wasm_bindgen.start("the_canvas_id");
}
</script>
</body>
</html>
<!-- Powered by egui: https://github.com/emilk/egui/ -->

@ -1,6 +0,0 @@
fn main() {
#[cfg(debug_assertions)]
ps_core::setup_tracing();
ps_gui::run_ui();
}

@ -12,7 +12,7 @@ tracing-subscriber = "0.3.0"
regex = "1.5.3"
flate2 = "1.0.22"
once_cell = "1.8.0"
chrono = "0.4.19"
chrono = {version = "0.4.19", features = ["wasmbind"]}
serde_json = "1.0.70"
serde = { version = "1.0.131", features = ["derive"]}
crossbeam-channel = "0.5.1"
@ -20,14 +20,12 @@ rsql_builder = "0.1.2"
treemap = "0.3.2"
strum = "0.23.0"
strum_macros = "0.23.0"
lru = { version = "0.7.0", optional = true }
shellexpand = "2.1.0"
rand = "0.8.4"
lru = { version = "0.7.0"}
[target."cfg(target_arch = \"wasm32\")".dependencies]
# https://docs.rs/getrandom/latest/getrandom/#webassembly-support
getrandom = { version = "0.2", features = ["js"] }
[features]
default = ["lru"]
[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
shellexpand = "2.1.0"

@ -8,12 +8,18 @@ use crate::Config;
use super::{db_message::DBMessage, query::Query, query_result::QueryResult};
pub trait DatabaseLike: Clone + Send {
pub trait DatabaseQuery: Send + 'static {
fn query(&self, query: &Query) -> Result<Vec<QueryResult>>;
}
pub trait DatabaseLike: DatabaseQuery + Clone {
fn new(path: impl AsRef<Path>) -> Result<Self>
where
Self: Sized;
fn config(path: impl AsRef<Path>) -> Result<Config>
where
Self: Sized;
fn total_mails(&self) -> Result<usize>;
fn query(&self, query: &Query) -> Result<Vec<QueryResult>>;
fn import(self) -> (Sender<DBMessage>, JoinHandle<Result<usize>>);
fn save_config(&self, config: Config) -> Result<()>;
}

@ -95,6 +95,13 @@ pub struct ValueField {
}
impl ValueField {
pub fn new(field: &Field, value: Value) -> ValueField {
ValueField {
field: *field,
value,
}
}
pub fn string<S: AsRef<str>>(field: &Field, value: S) -> ValueField {
ValueField {
field: *field,
@ -209,7 +216,11 @@ impl Query {
Query::Other {
query: OtherQuery::All(field),
} => (
format!("SELECT {} FROM emails", field.as_str()),
format!(
"SELECT {} FROM emails GROUP BY {}",
field.as_str(),
field.as_str()
),
format!(""),
),
};

@ -4,15 +4,18 @@ pub mod message_adapter;
pub mod model;
mod types;
pub use database::database_like::DatabaseLike;
pub use database::database_like::{DatabaseLike, DatabaseQuery};
pub use database::db_message::DBMessage;
pub use database::query::{Field, Filter, OtherQuery, Query, ValueField, AMOUNT_FIELD_NAME};
pub use database::query_result::{QueryResult, QueryRow};
pub use types::{Config, EmailEntry, EmailMeta, FormatType};
pub use crossbeam_channel;
pub use eyre;
pub use importer::{Importerlike, Message, MessageReceiver, MessageSender};
pub use serde_json::Value;
// Tracing
use tracing_subscriber::fmt;

@ -5,6 +5,7 @@
//! - [`segmentations::`]
//! - [`items::`]
use eyre::{bail, Result};
use lru::LruCache;
use crate::database::query::{Field, Filter, OtherQuery, Query, ValueField};
@ -49,7 +50,12 @@ pub struct Engine {
impl Engine {
pub fn new<Database: DatabaseLike + 'static>(config: &Config) -> Result<Self> {
#[cfg(not(target_arch = "wasm32"))]
let link = super::link::run::<_, Database>(config)?;
#[cfg(target_arch = "wasm32")]
let link = super::link::run::<_, Database>(config, Database::new(&config.database_path)?)?;
let engine = Engine {
link,
search_stack: Vec::new(),

@ -11,12 +11,13 @@ use std::sync::{
};
use std::{collections::HashSet, convert::TryInto};
#[allow(unused)]
use crossbeam_channel::{unbounded, Receiver, Sender};
use eyre::Result;
use serde_json::Value;
use crate::database::{
database_like::DatabaseLike,
database_like::{DatabaseLike, DatabaseQuery},
query::Query,
query_result::{QueryResult, QueryRow},
};
@ -35,8 +36,15 @@ pub enum Response<Context: Send + 'static> {
pub(super) type InputSender<Context> = Sender<(Query, Context)>;
pub(super) type OutputReciever<Context> = Receiver<Result<Response<Context>>>;
// FIXME: Instead of this wasm mess, two different link types?
pub(super) struct Link<Context: Send + 'static> {
#[cfg(target_arch = "wasm32")]
database: Box<dyn DatabaseQuery>,
#[cfg(not(target_arch = "wasm32"))]
pub input_sender: InputSender<Context>,
#[cfg(not(target_arch = "wasm32"))]
pub output_receiver: OutputReciever<Context>,
// We need to account for the brief moment where the processing channel is empty
// but we're applying the results. If there is a UI update in this window,
@ -46,8 +54,47 @@ pub(super) struct Link<Context: Send + 'static> {
// put into the output channel. In order to account for all of this, we employ a
// request counter to know how many requests are currently in the pipeline
request_counter: Arc<AtomicUsize>,
#[cfg(target_arch = "wasm32")]
response: Vec<Response<Context>>,
}
#[cfg(target_arch = "wasm32")]
impl<Context: Send + Sync + 'static> Link<Context> {
pub fn request(&mut self, query: &Query, context: Context) -> Result<()> {
let result = self.database.query(&query)?;
if let Some(response) = process_query(query.clone(), result, context).ok() {
self.response.insert(0, response);
}
Ok(())
}
pub fn receive(&mut self) -> Result<Option<Response<Context>>> {
Ok(self.response.pop())
}
pub fn is_processing(&self) -> bool {
self.request_counter.load(Ordering::Relaxed) > 0
}
pub fn request_counter(&self) -> Arc<AtomicUsize> {
self.request_counter.clone()
}
}
#[cfg(target_arch = "wasm32")]
pub(super) fn run<Context: Send + Sync + 'static, Database: DatabaseQuery>(
config: &Config,
database: Database,
) -> Result<Link<Context>> {
Ok(Link {
database: Box::new(database),
request_counter: Arc::new(AtomicUsize::new(0)),
response: Vec::new(),
})
}
#[cfg(not(target_arch = "wasm32"))]
impl<Context: Send + Sync + 'static> Link<Context> {
pub fn request(&mut self, query: &Query, context: Context) -> Result<()> {
self.request_counter.fetch_add(1, Ordering::Relaxed);
@ -81,7 +128,8 @@ impl<Context: Send + Sync + 'static> Link<Context> {
}
}
pub(super) fn run<Context: Send + Sync + 'static, Database: DatabaseLike + 'static>(
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn run<Context: Send + Sync + 'static, Database: DatabaseLike + DatabaseQuery>(
config: &Config,
) -> Result<Link<Context>> {
// Create a new database connection, just for reading
@ -96,7 +144,8 @@ pub(super) fn run<Context: Send + Sync + 'static, Database: DatabaseLike + 'stat
})
}
fn inner_loop<Context: Send + Sync + 'static, Database: DatabaseLike>(
#[cfg(not(target_arch = "wasm32"))]
fn inner_loop<Context: Send + Sync + 'static, Database: DatabaseQuery>(
database: Database,
input_receiver: Receiver<(Query, Context)>,
output_sender: Sender<Result<Response<Context>>>,
@ -104,39 +153,54 @@ fn inner_loop<Context: Send + Sync + 'static, Database: DatabaseLike>(
loop {
let (query, context) = input_receiver.recv()?;
let result = database.query(&query)?;
let response = match query {
Query::Grouped { .. } => {
let segmentations = calculate_segmentations(&result)?;
Response::Grouped(query, context, segmentations)
}
Query::Normal { .. } => {
let converted = calculate_rows(&result)?;
Response::Normal(query, context, converted)
}
Query::Other { .. } => {
let mut results = HashSet::new();
for entry in result {
match entry {
QueryResult::Other(field) => match field.value() {
Value::Array(s) => {
for n in s {
if let Value::String(s) = n {
if !results.contains(s) {
results.insert(s.to_owned());
}
let response = process_query(query, result, context);
output_sender.send(response)?;
}
}
fn process_query<Context: Send + Sync + 'static>(
query: Query,
result: Vec<QueryResult>,
context: Context,
) -> Result<Response<Context>> {
let response = match query {
Query::Grouped { .. } => {
let segmentations = calculate_segmentations(&result)?;
Response::Grouped(query, context, segmentations)
}
Query::Normal { .. } => {
let converted = calculate_rows(&result)?;
Response::Normal(query, context, converted)
}
Query::Other { .. } => {
let mut results = HashSet::new();
for entry in result {
match entry {
QueryResult::Other(field) => match field.value() {
Value::Array(s) => {
for n in s {
if let Value::String(s) = n {
if !results.contains(s) {
results.insert(s.to_owned());
}
}
}
_ => panic!("Should not end up here"),
},
_ => panic!("Should not end up here"),
}
_ => {
#[cfg(debug_assertions)]
panic!("Should not end up here")
}
},
_ => {
#[cfg(debug_assertions)]
panic!("Should not end up here")
}
}
Response::Other(query, context, results.into_iter().collect())
}
};
output_sender.send(Ok(response))?;
}
Response::Other(query, context, results.into_iter().collect())
}
};
Ok(response)
}
fn calculate_segmentations(result: &[QueryResult]) -> Result<Segmentation> {

@ -141,6 +141,11 @@ impl Config {
) -> eyre::Result<Self> {
// If we don't have a database path, we use a temporary folder.
let persistent = db.is_some();
#[cfg(target_arch = "wasm32")]
let database_path = PathBuf::new();
#[cfg(not(target_arch = "wasm32"))]
let database_path = match db {
Some(n) => n.as_ref().to_path_buf(),
None => {
@ -194,7 +199,6 @@ impl Config {
fn random_filename() -> String {
use rand::Rng;
let number: u32 = rand::thread_rng().gen();
let folder = "postsack";
let filename = format!("{}.sqlite", number);
return filename;
}

@ -9,7 +9,7 @@ thiserror = "1.0.29"
tracing = "0.1.29"
tracing-subscriber = "0.3.0"
rusqlite = {version = "0.26.1", features = ["chrono", "trace", "serde_json", "bundled"]}
chrono = "0.4.19"
chrono = {version = "0.4.19", features = ["wasmbind"]}
serde_json = "1.0.70"
serde = { version = "1.0.130", features = ["derive"]}
rsql_builder = "0.1.2"

@ -9,7 +9,7 @@ use super::sql::*;
use super::{value_from_field, RowConversion};
use ps_core::{
crossbeam_channel::{unbounded, Sender},
Config, DBMessage, DatabaseLike, EmailEntry, OtherQuery, Query, QueryResult,
Config, DBMessage, DatabaseLike, DatabaseQuery, EmailEntry, OtherQuery, Query, QueryResult,
};
#[derive(Debug)]
@ -25,6 +25,43 @@ impl Clone for Database {
}
}
impl DatabaseQuery for Database {
fn query(&self, query: &Query) -> Result<Vec<QueryResult>> {
use rusqlite::params_from_iter;
let c = match &self.connection {
Some(n) => n,
None => bail!("No connection to database available in query"),
};
let (sql, values) = query.to_sql();
let mut stmt = c.prepare(&sql)?;
let mut query_results = Vec::new();
let mut converted = Vec::new();
for value in values {
converted.push(super::conversion::json_to_value(&value)?);
}
let p = params_from_iter(converted.iter());
let mut rows = stmt.query(p)?;
while let Some(row) = rows.next()? {
match query {
Query::Grouped { group_by, .. } => {
let result = QueryResult::grouped_from_row(group_by, row)?;
query_results.push(result);
}
Query::Normal { fields, .. } => {
let result = QueryResult::from_row(fields, row)?;
query_results.push(result);
}
Query::Other {
query: OtherQuery::All(field),
} => query_results.push(QueryResult::Other(value_from_field(field, row)?)),
}
}
Ok(query_results)
}
}
impl DatabaseLike for Database {
/// Open database at path `Path`.
fn new(path: impl AsRef<Path>) -> Result<Self> {
@ -48,6 +85,13 @@ impl DatabaseLike for Database {
})
}
/// Open a database and try to retrieve a config from the information stored in there
fn config(path: impl AsRef<Path>) -> Result<Config> {
let database = Self::new(path.as_ref())?;
let fields = database.select_config_fields()?;
Config::from_fields(path.as_ref(), fields)
}
fn total_mails(&self) -> Result<usize> {
let connection = match &self.connection {
Some(n) => n,
@ -65,41 +109,6 @@ impl DatabaseLike for Database {
self.insert_config_fields(fields)
}
fn query(&self, query: &Query) -> Result<Vec<QueryResult>> {
use rusqlite::params_from_iter;
let c = match &self.connection {
Some(n) => n,
None => bail!("No connection to database available in query"),
};
let (sql, values) = query.to_sql();
let mut stmt = c.prepare(&sql)?;
let mut query_results = Vec::new();
let mut converted = Vec::new();
for value in values {
converted.push(super::conversion::json_to_value(&value)?);
}
let p = params_from_iter(converted.iter());
let mut rows = stmt.query(p)?;
while let Some(row) = rows.next()? {
match query {
Query::Grouped { group_by, .. } => {
let result = QueryResult::grouped_from_row(group_by, row)?;
query_results.push(result);
}
Query::Normal { fields, .. } => {
let result = QueryResult::from_row(fields, row)?;
query_results.push(result);
}
Query::Other {
query: OtherQuery::All(field),
} => query_results.push(QueryResult::Other(value_from_field(field, row)?)),
}
}
Ok(query_results)
}
/// Begin the data import.
/// This will consume the `Database`. A new one has to be opened
/// afterwards in order to support multi-threading.
@ -171,13 +180,6 @@ impl DatabaseLike for Database {
}
impl Database {
/// Open a database and try to retrieve a config from the information stored in there
pub fn config<P: AsRef<Path>>(path: P) -> Result<Config> {
let database = Self::new(path.as_ref())?;
let fields = database.select_config_fields()?;
Config::from_fields(path.as_ref(), fields)
}
fn create_tables(connection: &Connection) -> Result<()> {
connection.execute(TBL_EMAILS, params![])?;
connection.execute(TBL_ERRORS, params![])?;

@ -15,8 +15,7 @@ crossbeam-channel = "0.5.1"
eframe = "0.15.0"
num-format = "0.4.0"
rand = "0.8.4"
image = { version = "0.23", default-features = false, features = ["png"] }
chrono = "0.4.19"
chrono = {version = "0.4.19", features = ["wasmbind"]}
ps-core = { path = "../ps-core" }
[target."cfg(target_os = \"macos\")".dependencies.cocoa]
@ -26,9 +25,9 @@ version = "0.2.7"
[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
ps-importer = { path = "../ps-importer" }
ps-database = { path = "../ps-database" }
shellexpand = "2.1.0"
tinyfiledialogs = "3.0"
image = { version = "0.23", default-features = false, features = ["png"] }
[target."cfg(target_arch = \"wasm32\")".dependencies]
# https://docs.rs/getrandom/latest/getrandom/#webassembly-support

@ -1,29 +0,0 @@
#!/bin/sh
set -e
rm -rf target/release/bundle/osx/Postsack.app
# Build for x86 and ARM
cargo build --release --target=aarch64-apple-darwin
cargo build --release --target=x86_64-apple-darwin
# Combine into a fat binary
lipo -create target/aarch64-apple-darwin/release/postsack target/x86_64-apple-darwin/release/postsack -output postsack
# Perform Cargo bundle to create a macOS Bundle
cargo bundle --release
# Override bundle binary with the fat one
# Also: We want to have `Postsack` capitalized on macOS, so we rename
rm target/release/bundle/osx/Postsack.app/Contents/MacOS/postsack
mv ./postsack target/release/bundle/osx/Postsack.app/Contents/MacOS/
# Tell the Info.plist or binary is capitalized
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable Postsack" "target/release/bundle/osx/Postsack.app/Contents/Info.plist"

@ -1,3 +1,5 @@
use std::marker::PhantomData;
use eframe::{
egui::{self},
epi::{self, App, Frame, Storage},
@ -7,25 +9,40 @@ use super::app_state::StateUI;
use super::platform::Theme;
use super::textures::Textures;
pub struct PostsackApp {
use ps_core::DatabaseLike;
pub struct PostsackApp<Database: DatabaseLike> {
state: StateUI,
platform_custom_setup: bool,
textures: Option<Textures>,
_database: PhantomData<Database>,
}
impl PostsackApp {
impl<Database: DatabaseLike> PostsackApp<Database> {
#[cfg(not(target_arch = "wasm32"))]
pub fn new() -> Self {
let state = StateUI::new();
PostsackApp {
state,
platform_custom_setup: false,
textures: None,
_database: PhantomData,
}
}
#[cfg(target_arch = "wasm32")]
pub fn new(config: ps_core::Config, total: usize) -> Self {
let state = StateUI::new::<Database>(config, total);
PostsackApp {
state,
platform_custom_setup: false,
textures: None,
_database: PhantomData,
}
}
}
impl App for PostsackApp {
impl<Database: DatabaseLike> App for PostsackApp<Database> {
fn name(&self) -> &str {
"Postsack"
}
@ -51,7 +68,7 @@ impl App for PostsackApp {
}
}
self.state.update(ctx, &self.textures);
self.state.update::<Database>(ctx, &self.textures);
// Resize the native window to be just the size we need it to be:
frame.set_window_size(ctx.used_size());

@ -76,6 +76,10 @@ impl ImporterUI {
// Could not figure out how to build this properly
// with dynamic dispatch. (to abstract away the match)
// Will try again when I'm online.
// On Wasm, we just do nothing. Wasm is just a demo and
// the importer will never be run.
#[cfg(not(target_arch = "wasm32"))]
let handle = match config.format {
FormatType::AppleMail => {
let importer = ps_importer::applemail_importer(config);
@ -91,6 +95,9 @@ impl ImporterUI {
}
};
#[cfg(target_arch = "wasm32")]
let handle = std::thread::spawn(|| Ok(()));
Ok(Self {
config: cloned_config,
adapter,

@ -4,8 +4,7 @@ use eyre::{Report, Result};
use super::super::widgets::{FilterState, Spinner};
use super::Textures;
use super::{StateUIAction, StateUIVariant};
use ps_core::{model::Engine, Config};
use ps_database::Database;
use ps_core::{model::Engine, Config, DatabaseLike};
#[derive(Default)]
pub struct UIState {
@ -25,7 +24,7 @@ pub struct MainUI {
}
impl MainUI {
pub fn new(config: Config, total: usize) -> Result<Self> {
pub fn new<Database: DatabaseLike>(config: Config, total: usize) -> Result<Self> {
let mut engine = Engine::new::<Database>(&config)?;
engine.start()?;
Ok(Self {

@ -14,8 +14,6 @@ pub use main::{MainUI, UIState};
pub use startup::StartupUI;
use ps_core::{Config, DatabaseLike, FormatType};
// FIXME: Abstract away with a trait?
use ps_database::Database;
pub enum StateUIAction {
CreateDatabase {
@ -60,7 +58,11 @@ impl StateUI {
/// This proxies the `update` call to the individual calls in
/// the `app_state` types
pub fn update(&mut self, ctx: &egui::CtxRef, textures: &Option<Textures>) {
pub fn update<Database: DatabaseLike>(
&mut self,
ctx: &egui::CtxRef,
textures: &Option<Textures>,
) {
let response = match self {
StateUI::Startup(panel) => panel.update_panel(ctx, textures),
StateUI::Import(panel) => panel.update_panel(ctx, textures),
@ -74,14 +76,18 @@ impl StateUI {
sender_emails,
format,
} => {
*self =
self.create_database(database_path, emails_folder_path, sender_emails, format)
*self = self.create_database::<Database>(
database_path,
emails_folder_path,
sender_emails,
format,
)
}
StateUIAction::OpenDatabase { database_path } => {
*self = self.open_database(database_path)
*self = self.open_database::<Database>(database_path)
}
StateUIAction::ImportDone { config, total } => {
*self = match main::MainUI::new(config.clone(), total) {
*self = match main::MainUI::new::<Database>(config.clone(), total) {
Ok(n) => StateUI::Main(n),
Err(e) => StateUI::Error(ErrorUI::new(e, Some(config))),
};
@ -98,11 +104,17 @@ impl StateUI {
}
impl StateUI {
#[cfg(not(target_arch = "wasm32"))]
pub fn new() -> StateUI {
StateUI::Startup(startup::StartupUI::default())
}
pub fn create_database(
#[cfg(target_arch = "wasm32")]
pub fn new<Database: DatabaseLike>(config: Config, total: usize) -> StateUI {
StateUI::Main(main::MainUI::new::<Database>(config, total).unwrap())
}
pub fn create_database<Database: DatabaseLike>(
&self,
database_path: Option<PathBuf>,
emails_folder_path: PathBuf,
@ -124,7 +136,7 @@ impl StateUI {
self.importer_with_config(config, database)
}
pub fn open_database(&mut self, database_path: PathBuf) -> StateUI {
pub fn open_database<Database: DatabaseLike>(&mut self, database_path: PathBuf) -> StateUI {
let config = match Database::config(&database_path) {
Ok(config) => config,
Err(report) => return StateUI::Error(error::ErrorUI::new(report, None)),
@ -135,13 +147,17 @@ impl StateUI {
Err(report) => return StateUI::Error(error::ErrorUI::new(report, None)),
};
match main::MainUI::new(config.clone(), total) {
match main::MainUI::new::<Database>(config.clone(), total) {
Ok(n) => StateUI::Main(n),
Err(e) => StateUI::Error(ErrorUI::new(e, Some(config))),
}
}
fn importer_with_config(&self, config: Config, database: Database) -> StateUI {
fn importer_with_config<Database: DatabaseLike>(
&self,
config: Config,
database: Database,
) -> StateUI {
let importer = match import::ImporterUI::new(config.clone(), database) {
Ok(n) => n,
Err(e) => {

@ -155,7 +155,8 @@ impl StartupUI {
self.open_email_folder_dialog()
}
if self.format == FormatType::AppleMail && ui.button("or Mail.app default folder").clicked(){
self.email_folder = ps_importer::default_path(&self.format);
self.set_default_path();
}
});
ui.end_row();
@ -243,6 +244,14 @@ impl StartupUI {
response.response
}
#[cfg(not(target_arch = "wasm32"))]
fn set_default_path(&mut self) {
self.email_folder = ps_importer::default_path(&self.format);
}
#[cfg(target_arch = "wasm32")]
fn set_default_path(&mut self) {}
}
impl StartupUI {
@ -300,6 +309,10 @@ impl StartupUI {
self.format = selected;
}
#[cfg(target_arch = "wasm32")]
fn open_email_folder_dialog(&mut self) {}
#[cfg(not(target_arch = "wasm32"))]
fn open_email_folder_dialog(&mut self) {
let fallback = shellexpand::tilde("~/");
let default_path = ps_importer::default_path(&self.format)
@ -320,6 +333,10 @@ impl StartupUI {
self.email_folder = Some(path);
}
#[cfg(target_arch = "wasm32")]
fn save_database_dialog(&mut self) {}
#[cfg(not(target_arch = "wasm32"))]
fn save_database_dialog(&mut self) {
let default_path = "~/Desktop/";
@ -342,6 +359,12 @@ impl StartupUI {
self.database_path = Some(path)
}
#[cfg(target_arch = "wasm32")]
fn open_database_dialog(&mut self) -> Option<PathBuf> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn open_database_dialog(&mut self) -> Option<PathBuf> {
let default_path = "~/Desktop/";

@ -7,9 +7,5 @@ mod segmentation_bar;
mod textures;
pub(crate) mod widgets;
pub fn run_ui() {
let options = eframe::NativeOptions::default();
eframe::run_native(Box::new(app::PostsackApp::new()), options);
}
pub use app::PostsackApp;
pub use eframe;

@ -48,9 +48,12 @@ impl<'a> Widget for NavigationBar<'a> {
ui.add_space(15.0);
let close_text = "Close";
if ui.add(navigation_button(close_text)).clicked() {
self.state.action_close = true;
#[cfg(not(target_arch = "wasm32"))]
{
let close_text = "Close";
if ui.add(navigation_button(close_text)).clicked() {
self.state.action_close = true;
}
}
let filter_text = "\u{1f50D} Filters";

@ -15,6 +15,12 @@ mod linux;
#[cfg(target_os = "linux")]
pub use linux::{initial_update, navigation_button};
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
pub use web::{initial_update, navigation_button};
#[cfg(target_os = "macos")]
mod macos;
@ -63,6 +69,9 @@ pub fn setup(ctx: &egui::CtxRef, theme: Theme) {
#[cfg(target_os = "macos")]
use macos as module;
#[cfg(target_arch = "wasm32")]
use web as module;
INSTANCE
.set(module::platform_colors(theme))
.expect("Could not setup colors");

@ -0,0 +1,48 @@
#![cfg(target_arch = "wasm32")]
use eframe::egui::{self, Color32};
use eyre::Result;
use super::{PlatformColors, Theme};
pub fn platform_colors(theme: Theme) -> PlatformColors {
// From Google images, Windows 11
match theme {
Theme::Light => PlatformColors {
is_light: true,
animation_background: Color32::from_rgb(248, 246, 249),
window_background: Color32::from_rgb(241, 243, 246),
content_background: Color32::from_rgb(251, 251, 253),
text_primary: Color32::from_gray(0),
text_secondary: Color32::from_gray(30),
line1: Color32::from_gray(0),
line2: Color32::from_gray(30),
line3: Color32::from_gray(60),
line4: Color32::from_gray(90),
},
Theme::Dark => PlatformColors {
is_light: false,
animation_background: Color32::from_gray(60),
window_background: Color32::from_rgb(32, 32, 32),
content_background: Color32::from_rgb(34, 32, 40),
text_primary: Color32::from_gray(255),
text_secondary: Color32::from_gray(200),
line1: Color32::from_gray(255),
line2: Color32::from_gray(210),
line3: Color32::from_gray(190),
line4: Color32::from_gray(120),
},
}
}
/// This is called from `App::setup`
pub fn setup(ctx: &egui::CtxRef) {}
/// This is called once from `App::update` on the first run.
pub fn initial_update(ctx: &egui::CtxRef) -> Result<()> {
Ok(())
}
pub fn navigation_button(title: &str) -> egui::Button {
egui::Button::new(title)
}

@ -7,6 +7,8 @@
//! necessary on macOS and I'd rather not load it on Windows or Linux systems.
use eframe::{self, egui, epi};
#[cfg(target_os = "macos")]
use image;
/// Pre-loaded textures
@ -37,6 +39,7 @@ impl Textures {
/// Load the permission image
// via: https://github.com/emilk/egui/blob/master/eframe/examples/image.rs
#[allow(unused)]
#[cfg(target_os = "macos")]
fn install_missing_permission_image(
image_data: &[u8],
frame: &mut epi::Frame<'_>,

Loading…
Cancel
Save