This commit is contained in:
creating2morrow 2023-04-30 11:55:41 -04:00
commit 916a8b9fed
263 changed files with 87198 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*/target
/target
/core
/test-lmdb
/wallet
genkey-batch
monero-wallet-rpc.log
notes.txt
.vscode/settings.json

4115
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "nevmes"
version = "0.1.0-alpha"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
env_logger = "0.10.0"
nevmes_auth = { path = "./nevmes-auth" }
nevmes_contact = { path = "./nevmes-contact" }
nevmes_core = { path = "./nevmes-core" }
nevmes_gui = { path = "./nevmes-gui" }
nevmes_message = { path = "./nevmes-message" }
rocket = { version = "0.5.0-rc.2", features = ["json"] }

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# NEVMES
NEVidebla-MESago (invisible message)
### gpg and i2p made simple for end-to-end encrypted, secure comms
## About
* send messages over the invisible internet
* vanity base32 addresses (advanced)
* automated mandatory gpg key encryption
* xmr payment integration
## Dev
* stack - rust (egui, rocket), lmdb, i2p-zero, monero(rpc, daemon), gpg
* install dependencies
* ubuntu example: `sudo apt update -y && sudo apt upgrade -y`
* `sudo apt install -y libssl-dev build-essential libgpgme-dev`
* download and run i2prouter start (optional: setup to run on boot similar tor daemon)
* `git clone https://github/com/creating2morrow/nevmes`
* `cd nevmes && ./scripts/build_all_and_run.sh "-- -h"`
* gui built with rust [egui](https://docs.rs/egui/latest/egui/)
## API
* remote/programmatic access
* secured by wallet signing
* jwt and jwp
* see [curl.md](./docs/curl.md)
## Binaries
* nevmes-auth - `internal` auth server
* nevmes-contact - `internal` add contacts server
* nevmes-core - application core logic
* nevmes-gui - primary user interface
* nevmes-message - `internal` message tx/read etc. server
* nevmes - `external` primary server for contact share, payment, message rx etc.
* [monerod](https://www.getmonero.org/downloads/#cli) - (not included) monero-wallet-rpc needs this
* can be overriden with remote node
* use the `--remote-node` flag
* [monero-wallet-rpc](https://www.getmonero.org/downloads/#cli) - (not included) interface for xmr wallet ops
* [i2p-zero](https://github.com/i2p-zero/i2p-zero/releases/tag/v1.20) - (not included) tunnel creation
* [i2p](https://geti2p.net/en/download) - http proxy (not included, *i2p-zero http proxy not working)
## Manual
[the manual](./docs/man.md)
## Known issues
* gui password and screen lock needs fixing up
* timeout out JWP payment approval screen with infinite loading
* message retry login (untested)
* test framework (in progress)
* docs on all `fn` and `structs`
* i2pd installer on home screen?
* and more daemon info and wallet functionality (multisig)

67
docs/curl.md Normal file
View file

@ -0,0 +1,67 @@
# Remote access
NOTE: JWT for micro servers disabled in dev
## Login API
* send with dummy string on first request
* sign data in the response
* send second request with signature to get AUTHID and UID
```bash
curl -iv -x localhost:9043/alice.b32.i2p/login/<SIGNATURE>/<AUTHID>/<UID>
```
## generate invoice
```bash
curl -iv http://localhost:9000/invoice
```
## get contact info
```bash
curl -iv http://localhost:9000/share
```
## generate jwp
```bash
curl -iv -X POST http://localhost:9000/prove -d '{"address": "", "confirmations":0,"hash":"", "message":"", "signature": ""}' -H 'Content-Type: application/json'
```
## health check
```bash
curl -iv http://localhost:9000/xmr/version -H 'proof: eyJhbGciOiJIUzUxMiJ9...'
```
## add contact
```bash
curl -iv -X POST http://localhost:9044/contact -d '{"cid": "KEEP EMPTY", "gpg_key": [1,2,3...], "i2p_address": "", "xmr_address": ""}' -H 'Content-Type: application/json'
```
## view contacts
```bash
curl -iv http://localhost:9044/contacts
```
## send message
```bash
curl -ivk localhost:9045/tx -d '{"uid":"123", "mid": "", "body": [1,2,3 <PLAINTEXT_BYTES>], "from": "alice.b32.i2p", "created": 0, "to": "bob.b32.i2p"}' -H 'Content-Type: application/json'
```
## receive message
```bash
curl -ivk localhost:9000/message/rx -d '{"uid":"", "mid": "", "body": [1,2,3 <ENCRYPTED_BYTES>], "from": "alice.b32.i2p", "created": 0, "to": "bob.b32.i2p"}' -H 'Content-Type: application/json' -H 'proof: eyJhbGciOiJIUzUxMiJ9...'
```
## view messages
```bash
curl -iv http://localhost:9044/messages
```

54
docs/man.md Normal file
View file

@ -0,0 +1,54 @@
# The Manual
## Architecture
* gui
* three internal mircoservers (auth, contact and message)
* core code module and lmdb
* one external i2p hidden service
* jwt for internal auth, jwp for external
### JWP (JSON Web Proof)
* utilizes some external blockchain (nevmes uses monero) for authorization of auth tokens
* 32 byte random signing keys generated on app start-up
* `Hmac<Sha384>` internal, `Hmac<Sha512>` external (jwp)
* see [proof.rs](./nevmes-core/src/proof.rs)
`eyJhbGciOiJIUzUxMiJ9.eyJhZGRyZXNzIjoiNThvaUJMQUtBQ3JaeTRqVnRYdUFXMzlCOW1zR3dlbVVkSm9HVlozcGdSY1RoWHZqWjZ0RERqRGpuOE1mTUZ5cEtZMlU1U1B6SkE3NnFHeHhDdjJzd1Y0NjhFYkI2dEsiLCJoYXNoIjoiNzRhOTM5NTU1Y2EyMWJmY2MxYzlhMjhlYjFkN2M5MWZiMjRhYzRiOTY4MDk2Yzg4ODU1ODA3ODcwMDA1NmQ2NiIsIm1lc3NhZ2UiOiIiLCJzaWduYXR1cmUiOiJPdXRQcm9vZlYyWHdYTEJYV0VtbXlWd3YyOHFQRWQ0Mk14bm1FNTU3aUFEVHFGNjZDWG9LQ1ZFeFBqTVU4NFNIeWprZmdLd01WZEI4OUZkTkJ5QUxyeU1ZamVxQlY1U0VtU0V4MUJWWE1ITVJNWHVuMzh5aWVtcWhCcmVSWUdpRGdMN1lmRmVmemJSTnhlIn0.gH4RlLrxu3xqxNvsHv7lX1yYomg07yTlv6VEKpDfXwbDV4O267CXzm30G4YBQOfuDf3xpegUmeVXOScPvIZVRw`
## Getting started
* getting started the app will automatically generate an account and associated monero PRIMARY address. Only use it here to maintain privacy
* so first off, you need to someone to love, dont you want somebody to love...
* get your contact's i2p .b32.i2p address (top of gui screen)
* deposit some stagenet monero in your xmr account (address at top of gui screen)
* once unlocked nevmes xmr balance will display
* when authorizing to send to contact an invoice will be generated
* authorize payment and tx proof generation in the prompt
* this tx proof will be used to create an encrypted json web proof of payment with each contact
* think of it as a reusable, unforgeable coupon or ticket
* the invoice shows payment per blocks (time)
* default is 1 piconero per day
* the jwp is cached by the client until block time expiration at which time you will be required to authorize another payment
## Adding a contact
* now that the bills are paid insert the .b32.i2p address on the contact line
* click add
* if all goes well you will have imported their public nevmes gpg app key
* dont reuse the app gpg keys anywhere else!
* don't forget to trust the contact with `sign key` in the `check status` window
## Sending a message
* the `check status` button will show current jwp for each contact
* `clear stale jwp` will purge data in case of timeout issues
* don't keep large amounts in nevmes just enoug for fees and jwps
* once a valid jwp is created (takes a few minutes) the `compose` button will be visible
* you need to click `check status` on contacts before sending to refresh jwp expiration check
* draft a plain text message, dont be shy
* enter recipient (.b32.i2p address) and press `send`
* plain text messages never leave your machine
* you can click `Refresh` button in the Mailbox to check for new messages
* messages must be decrypted by clicking `decrypt`
## More to come...

6
nevmes-auth/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
/core
/test-lmdb
/wallet
monero-wallet-rpc.log
notes.txt

2600
nevmes-auth/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
nevmes-auth/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "nevmes_auth"
version = "0.1.0-alpha"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
env_logger = "0.10.0"
nevmes_core = { path = "../nevmes-core" }
log = "0.4"
rocket = { version = "0.5.0-rc.2", features = ["json"] }

View file

@ -0,0 +1,17 @@
use rocket::http::Status;
use rocket::response::status::Custom;
use rocket::serde::json::Json;
use rocket::get;
use nevmes_core::{auth, models::*};
/// Login with wallet signature
///
/// Creates user on initial login
///
#[get("/login/<signature>/<aid>/<uid>")]
pub async fn login
(aid: String, uid: String,signature: String) -> Custom<Json<Authorization>> {
let m_auth: Authorization = auth::verify_login(aid, uid, signature).await;
Custom(Status::Created, Json(m_auth))
}

2
nevmes-auth/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
// Keep complex logic in the core module
pub mod controller;

19
nevmes-auth/src/main.rs Normal file
View file

@ -0,0 +1,19 @@
#[macro_use]
extern crate rocket;
use nevmes_auth::*;
use nevmes_core::*;
// The only changes in here should be mounting new controller methods
#[launch]
async fn rocket() -> _ {
let config = rocket::Config {
port: utils::get_app_auth_port(),
..rocket::Config::debug_default()
};
env_logger::init();
log::info!("nevmes-auth is online");
rocket::custom(&config)
.mount("/", routes![controller::login])
}

6
nevmes-contact/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
/core
/test-lmdb
/wallet
monero-wallet-rpc.log
notes.txt

2600
nevmes-contact/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
nevmes-contact/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "nevmes_contact"
version = "0.1.0-alpha"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
env_logger = "0.10.0"
nevmes_core = { path = "../nevmes-core" }
log = "0.4"
rocket = { version = "0.5.0-rc.2", features = ["json"] }

View file

@ -0,0 +1,33 @@
use rocket::http::Status;
use rocket::response::status::Custom;
use rocket::serde::json::Json;
use rocket::{get, post};
use nevmes_core::{auth, contact, models::*, utils};
/// Add contact
#[post("/", data="<req_contact>")]
pub async fn add_contact
(req_contact: Json<Contact>,_token: auth::BearerToken) -> Custom<Json<Contact>> {
let res_contact = contact::create(&req_contact).await;
if res_contact.cid == utils::empty_string() {
return Custom(Status::BadRequest, Json(Default::default()))
}
Custom(Status::Ok, Json(res_contact))
}
/// Return all contacts
#[get("/")]
pub async fn get_contacts
(_token: auth::BearerToken) -> Custom<Json<Vec<Contact>>> {
let contacts = contact::find_all();
Custom(Status::Ok, Json(contacts))
}
/// trust contact
#[post("/<key>")]
pub async fn trust_contact
(key: String, _token: auth::BearerToken) -> Status {
contact::trust_gpg(key);
Status::Ok
}

View file

@ -0,0 +1,2 @@
// Keep complex logic in the core module
pub mod controller;

View file

@ -0,0 +1,21 @@
#[macro_use]
extern crate rocket;
use nevmes_contact::*;
use nevmes_core::*;
// The only changes in here should be mounting new controller methods
#[launch]
async fn rocket() -> _ {
let config = rocket::Config {
port: utils::get_app_contact_port(),
..rocket::Config::debug_default()
};
env_logger::init();
log::info!("nevmes-contact is online");
rocket::custom(&config)
.mount("/trust", routes![controller::trust_contact])
.mount("/contact", routes![controller::add_contact])
.mount("/contacts", routes![controller::get_contacts])
}

2590
nevmes-core/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

30
nevmes-core/Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "nevmes_core"
version = "0.1.0-alpha"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = "0.4.23"
clap = { version = "4.1.4", features = ["derive"] }
diqwest = "1.1.1"
env_logger = "0.10.0"
gpgme = "0.11.0"
hex = "0.4.3"
hmac = "0.12.1"
jwt = "0.16.0"
lmdb-rs = "0.7.6"
log = "0.4"
rand = "0.8.5"
rand_core = "0.6.4"
reqwest = { version = "0.11.12", features = ["json"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
schedule_recv = "0.1.0"
sha2 = "0.10.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.94"
tokio = "1.25.0"
[profile.release]
prod = true

127
nevmes-core/src/args.rs Normal file
View file

@ -0,0 +1,127 @@
use clap::Parser;
/// cmd line args
#[derive(Parser, Default, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
/// set release environment
#[arg(
short,
long,
help = "Set release environment (dev, prod)",
default_value = "dev"
)]
pub release_env: String,
/// Monero location
#[arg(
long,
help = "Monero download absolute path.",
default_value = "monero-x86_64-linux-gnu-v0.18.2.0"
)]
pub monero_location: String,
/// Monero RPC host
#[arg(
long,
help = "Monero RPC host.",
default_value = "http://localhost:38083"
)]
pub monero_rpc_host: String,
/// Monero blockchain location
#[arg(
long,
help = "Monero blockchain location",
default_value = "/home/user/.bitmonero"
)]
pub monero_blockchain_dir: String,
/// Absolute path to i2p zero
#[arg(
long,
help = "Absolute path to i2p-zero directroy",
default_value = "/home/user/i2p-zero-linux.v1.20"
)]
pub i2p_zero_dir: String,
/// Monero RPC daemon host
#[arg(
long,
help = "Monero RPC daemon.",
default_value = "http://localhost:38081"
)]
pub monero_rpc_daemon: String,
/// Monero RPC Username
#[arg(long, help = "Monero RPC username.", default_value = "user")]
pub monero_rpc_username: String,
/// Monero RPC credential
#[arg(long, help = "Monero RPC credential.", default_value = "pass")]
pub monero_rpc_cred: String,
/// Token expiration in minutes
#[arg(
short,
long,
help = "Set the token expiration limit in minutes.",
default_value = "60"
)]
pub token_timeout: i64,
/// Payment Threshold
#[arg(
short,
long,
help = "Set a payment threshold in piconeros",
default_value = "1"
)]
pub payment_threshold: u128,
/// Confirmation Threshold
#[arg(
short,
long,
help = "Set a confirmation expiration for payments",
default_value = "720"
)]
pub confirmation_threshold: u64,
/// Application port
#[arg(long, help = "Set app port", default_value = "9000")]
pub port: u16,
/// Auth port
#[arg(long, help = "Set app auth port", default_value = "9043")]
pub auth_port: u16,
/// Contact saving port
#[arg(long, help = "Set app contact saving port", default_value = "9044")]
pub contact_port: u16,
/// Messaging sending port
#[arg(long, help = "Set app message sending port", default_value = "9045")]
pub message_port: u16,
/// Auto trust contact gpg keys (DISABLED)
#[arg(
long,
help = "FUTURE FEATURE. Auto trust contacts. DISABLED",
default_value = "false"
)]
pub auto_trust: bool,
/// Start with gui
#[arg(
long,
help = "Start the graphical user interface",
default_value = "false"
)]
pub gui: bool,
/// i2p http proxy host
#[arg(
long,
help = "i2p http proxy host",
default_value = "http://localhost:4444"
)]
pub i2p_proxy_host: String,
/// Connect wallet rpc for a remote-node, WARNING: may harm privacy
#[arg(
long,
help = "connect to remote node, don't use locally running monerod",
default_value = "false"
)]
pub remote_node: bool,
/// Connect to micro servers
#[arg(
long,
help = "allow remote access to mirco server functionality",
default_value = "false"
)]
pub remote_access: bool,
}

221
nevmes-core/src/auth.rs Normal file
View file

@ -0,0 +1,221 @@
use crate::{args, models::*, db, monero, reqres, user, utils};
use clap::Parser;
use log::{debug, error, info};
use rocket::http::Status;
use rocket::outcome::Outcome;
use rocket::request::FromRequest;
use rocket::{request, Request};
use hmac::{Hmac, Mac};
use jwt::*;
use sha2::Sha384;
use std::collections::BTreeMap;
/// Create authorization data to sign and expiration
pub fn create(address: &String) -> Authorization {
info!("creating auth");
let aid: String = format!("auth{}", utils::generate_rnd());
let rnd: String = utils::generate_rnd();
let created: i64 = chrono::offset::Utc::now().timestamp();
let token: String = create_token(String::from(address), created);
let new_auth = Authorization {
aid,
created,
uid: utils::empty_string(),
rnd,
token,
xmr_address: String::from(address),
};
let s = db::Interface::open();
debug!("insert auth: {:?}", &new_auth);
let k = &new_auth.aid;
db::Interface::write(&s.env, &s.handle, k, &Authorization::to_db(&new_auth));
new_auth
}
/// Authorization lookup for recurring requests
pub fn find(aid: &String) -> Authorization {
info!("searching for auth: {}", aid);
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, &String::from(aid));
debug!("auth read: {}", r);
if r == utils::empty_string() {
return Default::default();
}
Authorization::from_db(String::from(aid), r)
}
/// Update new authorization creation time
fn update_expiration(f_auth: Authorization, address: &String) -> Authorization {
info!("modify auth expiration");
let data = utils::generate_rnd();
let time: i64 = chrono::offset::Utc::now().timestamp();
// update time, token and data to sign
let u_auth = Authorization::update_expiration(
f_auth,
time,
data,
create_token(String::from(address), time),
);
let s = db::Interface::open();
db::Interface::delete(&s.env, &s.handle, &u_auth.aid);
db::Interface::write(
&s.env,
&s.handle,
&u_auth.aid,
&Authorization::to_db(&u_auth),
);
return u_auth;
}
/// Performs the signature verfication against stored auth
pub async fn verify_login
(aid: String, uid: String, signature: String) -> Authorization {
let m_address: reqres::XmrRpcAddressResponse = monero::get_address().await;
let address = m_address.result.address;
let f_auth: Authorization = find(&aid);
if f_auth.xmr_address == utils::empty_string() {
error!("auth not found");
return create(&address);
}
let data: String = String::from(&f_auth.rnd);
let sig_address: String =
monero::verify_signature(String::from(&address), data, String::from(&signature)).await;
if sig_address == utils::ApplicationErrors::LoginError.value() {
error!("signature validation failed");
return f_auth;
}
let f_user: User = user::find(&uid);
if f_user.xmr_address == utils::empty_string() {
info!("creating new user");
let u: User = user::create(&address);
// update auth with uid
let u_auth = Authorization::update_uid(f_auth, String::from(&u.uid));
let s = db::Interface::open();
db::Interface::delete(&s.env, &s.handle, &u_auth.aid);
db::Interface::write(&s.env, &s.handle, &u_auth.aid, &Authorization::to_db(&u_auth));
return u_auth
} else if f_user.xmr_address != utils::empty_string() {
info!("returning user");
let m_access = verify_access(&address, &signature).await;
if !m_access { return Default::default() }
return f_auth;
} else {
error!("error creating user");
return Default::default()
}
}
/// Called during auth flow to update data to sign and expiration
pub async fn verify_access(address: &String, signature: &String) -> bool {
// look up auth for address
let f_auth: Authorization = find(address);
if f_auth.xmr_address != utils::empty_string() {
// check expiration, generate new data to sign if necessary
let now: i64 = chrono::offset::Utc::now().timestamp();
let expiration = get_auth_expiration();
if now > f_auth.created + expiration {
update_expiration(f_auth, address);
return false;
}
}
// verify signature on the data if not expired
let data = f_auth.rnd;
let sig_address: String =
monero::verify_signature(String::from(address), data, String::from(signature)).await;
if sig_address == utils::ApplicationErrors::LoginError.value() {
debug!("signing failed");
return false;
}
info!("auth verified");
return true;
}
/// get the auth expiration command line configuration
fn get_auth_expiration() -> i64 {
let args = args::Args::parse();
args.token_timeout * 60
}
fn create_token(address: String, created: i64) -> String {
let jwt_secret_key = utils::get_jwt_secret_key();
let key: Hmac<Sha384> = Hmac::new_from_slice(&jwt_secret_key.as_bytes()).expect("hash");
let header = Header {
algorithm: AlgorithmType::Hs384,
..Default::default()
};
let mut claims = BTreeMap::new();
let expiration = get_auth_expiration() * created;
claims.insert("address", address);
claims.insert("expiration", expiration.to_string());
let token = Token::new(header, claims).sign_with_key(&key);
String::from(token.expect("expected token").as_str())
}
/// This token is used for internal micro server authentication
#[derive(Debug)]
pub struct BearerToken(String);
#[derive(Debug)]
pub enum BearerTokenError {
Expired,
Missing,
Invalid,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for BearerToken {
type Error = BearerTokenError;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let env = utils::get_release_env();
let dev = utils::ReleaseEnvironment::Development;
if env == dev {
return Outcome::Success(BearerToken(utils::empty_string()))
}
let token = request.headers().get_one("token");
let m_address: reqres::XmrRpcAddressResponse = monero::get_address().await;
let address = m_address.result.address;
debug!("{}", address);
match token {
Some(token) => {
// check validity
let jwt_secret_key = utils::get_jwt_secret_key();
let key: Hmac<Sha384> = Hmac::new_from_slice(&jwt_secret_key.as_bytes()).expect("");
let jwt: Result<
Token<jwt::Header, BTreeMap<std::string::String, std::string::String>, _>,
jwt::Error,
> = token.verify_with_key(&key);
return match jwt {
Ok(j) => {
let claims = j.claims();
debug!("claim address: {}", claims["address"]);
// verify address
if claims["address"] != address {
return Outcome::Failure((
Status::Unauthorized,
BearerTokenError::Invalid,
));
}
// verify expiration
let now: i64 = chrono::offset::Utc::now().timestamp();
let expire = match claims["expiration"].parse::<i64>() {
Ok(n) => n,
Err(_) => 0,
};
if now > expire {
return Outcome::Failure((
Status::Unauthorized,
BearerTokenError::Expired,
));
}
Outcome::Success(BearerToken(String::from(token)))
}
Err(_) => Outcome::Failure((Status::Unauthorized, BearerTokenError::Invalid)),
};
}
None => Outcome::Failure((Status::Unauthorized, BearerTokenError::Missing)),
}
}
}

154
nevmes-core/src/contact.rs Normal file
View file

@ -0,0 +1,154 @@
// Contact repo/service layer
use crate::{db, gpg, models::*, utils, reqres, monero, i2p};
use rocket::serde::json::Json;
use log::{debug, error, info};
use std::error::Error;
/// Create a new contact
pub async fn create(c: &Json<Contact>) -> Contact {
let f_cid: String = format!("c{}", utils::generate_rnd());
info!("creating contact: {}", f_cid);
let new_contact = Contact {
cid: String::from(&f_cid),
gpg_key: c.gpg_key.iter().cloned().collect(),
i2p_address: String::from(&c.i2p_address),
xmr_address: String::from(&c.xmr_address),
};
let is_valid = validate_contact(c).await;
if !is_valid {
return Default::default();
}
let import = c.gpg_key.iter().cloned().collect();
gpg::import_key(String::from(&f_cid), import).unwrap();
debug!("insert contact: {:?}", &new_contact);
let s = db::Interface::open();
let k = &new_contact.cid;
db::Interface::write(&s.env, &s.handle, k, &Contact::to_db(&new_contact));
// in order to retrieve all contact, write keys to with cl
let list_key = format!("cl");
let r = db::Interface::read(&s.env, &s.handle, &String::from(&list_key));
if r == utils::empty_string() {
debug!("creating contact index");
}
let msg_list = [r, String::from(&f_cid)].join(",");
debug!("writing contact index {} for key {}", msg_list, list_key);
db::Interface::write(&s.env, &s.handle, &String::from(list_key), &msg_list);
new_contact
}
/// Contact lookup
pub fn find(cid: &String) -> Contact {
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, &String::from(cid));
if r == utils::empty_string() {
error!("contact not found");
return Default::default()
}
Contact::from_db(String::from(cid), r)
}
/// All contact lookup
pub fn find_all() -> Vec<Contact> {
let s = db::Interface::open();
let list_key = format!("cl");
let r = db::Interface::read(&s.env, &s.handle, &String::from(list_key));
if r == utils::empty_string() {
error!("contact index not found");
return Default::default()
}
let v_cid = r.split(",");
let v: Vec<String> = v_cid.map(|s| String::from(s)).collect();
let mut contacts: Vec<Contact> = Vec::new();
for id in v {
if id != utils::empty_string() {
let contact: Contact = find(&id);
contacts.push(contact);
}
}
contacts
}
async fn validate_contact(j: &Json<Contact>) -> bool {
info!("validating contact: {}", &j.cid);
let validate_address = monero::validate_address(&j.xmr_address).await;
j.cid.len() < utils::string_limit()
&& j.i2p_address.len() < utils::string_limit()
&& j.i2p_address.contains(".b32.i2p")
&& j.gpg_key.len() < utils::gpg_key_limit()
&& validate_address.result.valid
}
/// Send our information
pub async fn share() -> Contact {
let m_address: reqres::XmrRpcAddressResponse = monero::get_address().await;
let gpg_key = gpg::export_key().unwrap_or(Vec::new());
let i2p_address = i2p::get_destination();
let xmr_address = m_address.result.address;
Contact { cid: utils::empty_string(),gpg_key,i2p_address,xmr_address }
}
pub fn exists(from: &String) -> bool {
let all = find_all();
let mut addresses: Vec<String> = Vec::new();
for c in all { addresses.push(c.i2p_address); }
return addresses.contains(from);
}
/// Sign for trusted nevmes contacts
///
/// UI/UX should have some prompt about the implication of trusting keys
///
/// however that is beyond the scope of this app. nevmes assumes contacts
///
/// using the app already have some level of knowledge about each other.
///
/// Without signing the key message encryption and sending is not possible.
pub fn trust_gpg(key: String) { gpg::sign_key(&key).unwrap(); }
/// Get invoice for jwp creation
pub async fn request_invoice(contact: String) -> Result<reqres::Invoice, Box<dyn Error>> {
// TODO(c2m): Error handling for http 402 status
let host = utils::get_i2p_http_proxy();
let proxy = reqwest::Proxy::http(&host)?;
let client = reqwest::Client::builder().proxy(proxy).build();
match client?.get(format!("http://{}/invoice", contact)).send().await {
Ok(response) => {
let res = response.json::<reqres::Invoice>().await;
debug!("invoice request response: {:?}", res);
match res {
Ok(r) => {
Ok(r)
},
_ => Ok(Default::default()),
}
}
Err(e) => {
error!("failed to generate invoice due to: {:?}", e);
Ok(Default::default())
}
}
}
/// Send the request to contact to add them
pub async fn add_contact_request(contact: String) -> Result<Contact, Box<dyn Error>> {
// TODO(c2m): Error handling for http 402 status
let host = utils::get_i2p_http_proxy();
let proxy = reqwest::Proxy::http(&host)?;
let client = reqwest::Client::builder().proxy(proxy).build();
match client?.get(format!("http://{}/share", contact)).send().await {
Ok(response) => {
let res = response.json::<Contact>().await;
debug!("share response: {:?}", res);
match res {
Ok(r) => {
Ok(r)
},
_ => Ok(Default::default()),
}
}
Err(e) => {
error!("failed to fetch contact info due to: {:?}", e);
Ok(Default::default())
}
}
}

61
nevmes-core/src/db.rs Normal file
View file

@ -0,0 +1,61 @@
// db created and exported from here
extern crate lmdb_rs as lmdb;
use log::{debug, error};
use lmdb::{EnvBuilder, DbFlags, Environment, DbHandle};
use crate::utils;
pub struct Interface {
pub env: Environment,
pub handle: DbHandle,
}
impl Interface {
pub fn open() -> Self {
let release_env = utils::get_release_env();
let file_path = format!("/home/{}/.nevmes/", std::env::var("USER").unwrap_or(String::from("user")));
let mut env_str: &str = "test-lmdb";
if release_env != utils::ReleaseEnvironment::Development { env_str = "lmdb"; };
let env = EnvBuilder::new().open(format!("{}/{}", file_path, env_str), 0o777).unwrap();
let handle = env.get_default_db(DbFlags::empty()).unwrap();
Interface { env, handle }
}
pub fn write(e: &Environment, h: &DbHandle, k: &str, v: &str) {
let txn = e.new_transaction().unwrap();
{
// get a database bound to this transaction
let db = txn.bind(&h);
let pair = vec![(k,v)];
for &(key, value) in pair.iter() { db.set(&key, &value).unwrap(); }
}
match txn.commit() {
Err(_) => error!("failed to commit!"),
Ok(_) => ()
}
}
pub fn read(e: &Environment, h: &DbHandle, k: &str) -> String {
let reader = e.get_reader().unwrap();
let db = reader.bind(&h);
let value = db.get::<&str>(&k).unwrap_or_else(|_| "");
let r = String::from(value);
{
if r == utils::empty_string() {
debug!("Failed to read from db.")
}
}
r
}
pub fn delete(e: &Environment, h: &DbHandle, k: &str) {
let txn = e.new_transaction().unwrap();
{
// get a database bound to this transaction
let db = txn.bind(&h);
db.del::<>(&k).unwrap_or_else(|_| error!("failed to delete"));
}
match txn.commit() {
Err(_) => error!("failed to commit!"),
Ok(_) => ()
}
}
}

169
nevmes-core/src/gpg.rs Normal file
View file

@ -0,0 +1,169 @@
use log::{debug, error, info};
use std::process::Command;
use gpgme::*;
use std::{error::Error, fs::File, io::Write};
use crate::{utils, i2p};
/// Searches for key, returns empty string if none exists
///
/// TODO(c2m): add more cli options
pub fn find_key() -> Result<String, Box<dyn Error>> {
info!("searching for application gpg key");
let proto = Protocol::OpenPgp;
let mode = KeyListMode::LOCAL;
let mut ctx = Context::from_protocol(proto)?;
ctx.set_key_list_mode(mode)?;
let name = i2p::get_destination();
let mut keys = ctx.find_keys([&name])?;
let mut k: String = utils::empty_string();
for key in keys.by_ref().filter_map(|x| x.ok()) {
let r_key: &str = key.id().unwrap_or("");
if String::from(r_key) != utils::empty_string() {
k = String::from(r_key);
break;
} else {
error!("error finding gpg key");
}
}
if keys.finish()?.is_truncated() {
error!("key listing unexpectedly truncated");
}
Ok(k)
}
pub fn gen_key() {
info!("creating gpg key");
let output = Command::new("gpg")
.args(["--batch", "--gen-key", "genkey-batch"])
.spawn()
.expect("gpg key generation failed");
debug!("{:?}", output.stdout);
}
/// Export ascii armor app public gpg key
pub fn export_key() -> Result<Vec<u8>, Box<dyn Error>> {
info!("exporting public key");
let mut ctx = Context::from_protocol(Protocol::OpenPgp)?;
ctx.set_armor(true);
let name = i2p::get_destination();
let keys = {
let mut key_iter = ctx.find_keys([&name])?;
let keys: Vec<_> = key_iter.by_ref().collect::<Result<_, _>>()?;
if key_iter.finish()?.is_truncated() {
Err("key listing unexpectedly truncated")?;
}
keys
};
let mode = gpgme::ExportMode::empty();
let mut output = Vec::new();
ctx.export_keys(&keys, mode, &mut output)
.map_err(|e| format!("export failed: {:?}", e))?;
Ok(output)
}
/// Import gpg keys from contacts
pub fn import_key(cid: String, key: Vec<u8>) -> Result<(), Box<dyn Error>> {
info!("importing key: {}", hex::encode(&key));
let filename = format!("{}.nevmes", &cid);
let mut f = File::create(&filename)?;
f.write_all(&key)?;
let mut ctx = Context::from_protocol(gpgme::Protocol::OpenPgp)?;
println!("reading file `{}'", &filename);
let input = File::open(&filename)?;
let mut data = Data::from_seekable_stream(input)?;
let mode = None;
mode.map(|m| data.set_encoding(m));
ctx.import(&mut data).map_err(|e| format!("import failed {:?}", e))?;
utils::stage_cleanup(filename);
Ok(())
}
pub fn encrypt(name: String, body: &Vec<u8>) -> Result<Vec<u8>, Box<dyn Error>> {
let proto = Protocol::OpenPgp;
let mut ctx = Context::from_protocol(proto)?;
ctx.set_armor(true);
let keys: Vec<Key> = ctx.find_keys([&name])?
.filter_map(|x| x.ok())
.filter(|k| k.can_encrypt())
.collect();
let filename = format!("{}.nevmes", name);
let mut f = File::create(&filename)?;
f.write_all(body)?;
let mut input = File::open(&filename)
.map_err(|e| format!("can't open file `{}': {}", filename, e))?;
let mut output = Vec::new();
ctx.encrypt(&keys, &mut input, &mut output)
.map_err(|e| format!("encrypting failed: {:?}", e))?;
debug!("encrypted message body: {}", String::from_utf8(output.iter().cloned().collect()).unwrap_or(utils::empty_string()));
utils::stage_cleanup(filename);
Ok(output)
}
pub fn decrypt(mid: &String, body: &Vec<u8>) -> Result<Vec<u8>, Box<dyn Error>> {
let proto = Protocol::OpenPgp;
let mut ctx = Context::from_protocol(proto)?;
ctx.set_armor(true);
let filename = format!("{}.nevmes", mid);
let mut f = File::create(&filename)?;
f.write_all(&body)?;
let mut input = File::open(&filename)
.map_err(|e| format!("can't open file `{}': {}", filename, e))?;
let mut output = Vec::new();
ctx.decrypt(&mut input, &mut output)
.map_err(|e| format!("decrypting failed: {:?}", e))?;
utils::stage_cleanup(filename);
Ok(output)
}
pub fn write_gen_batch() -> Result<(), Box<dyn Error>> {
let name = i2p::get_destination();
let data = format!(
"%no-protection
Key-Type: RSA
Key-Length: 4096
Subkey-Type: ECC
Subkey-Curve: Curve25519
Name-Real: {}
Name-Email: {}
Expire-Date: 0", name, name
);
let filename = format!("genkey-batch");
let mut f = File::create(&filename)?;
f.write_all(&data.into_bytes())?;
Ok(())
}
pub fn sign_key(key: &str) -> Result<(), Box<dyn Error>> {
let mut ctx = Context::from_protocol(gpgme::Protocol::OpenPgp)?;
let mut keys = ctx.find_keys([key])?;
let mut k: String = utils::empty_string();
for ak in keys.by_ref().filter_map(|x| x.ok()) {
let r_key: &str = ak.id().unwrap_or("");
if String::from(r_key) != utils::empty_string() {
k = String::from(r_key);
break;
} else {
error!("error finding gpg key");
}
}
debug!("key-id match: {}", k);
let mut k2s_ctx = Context::from_protocol(gpgme::Protocol::OpenPgp)?;
let key_to_sign = k2s_ctx
.get_key(k)
.map_err(|e| format!("no key matched given key-id: {:?}", e))?;
let name = Some(i2p::get_destination());
if let Some(app_key) = name {
let key = k2s_ctx
.get_secret_key(app_key)
.map_err(|e| format!("unable to find signing key: {:?}", e))?;
debug!("app key: {:?}", key.id());
k2s_ctx.add_signer(&key)
.map_err(|e| format!("add_signer() failed: {:?}", e))?;
}
k2s_ctx.sign_key(&key_to_sign, None::<String>, Default::default())
.map_err(|e| format!("signing failed: {:?}", e))?;
println!("Signed key for {}", key);
Ok(())
}

184
nevmes-core/src/i2p.rs Normal file
View file

@ -0,0 +1,184 @@
use std::{fs, env, process::Command};
use log::{debug, error, info, warn};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{args, utils};
use clap::Parser;
use std::time::Duration;
// TODO(c2m): debug i2p-zero http proxy
#[derive(Debug)]
pub enum I2pStatus {
Accept,
Reject,
}
impl I2pStatus {
pub fn value(&self) -> String {
match *self {
I2pStatus::Accept => String::from("Accepting tunnels"),
I2pStatus::Reject => String::from("Rejecting tunnels: Starting up"),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct HttpProxyStatus {
pub open: bool
}
#[derive(Debug)]
pub enum ProxyStatus {
Opening,
Open
}
impl ProxyStatus {
pub fn value(&self) -> String {
match *self {
ProxyStatus::Opening => String::from("opening\n"),
ProxyStatus::Open => String::from("open\n"),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
struct Tunnel {
// http proxy tunnel wont have this field
dest: Option<String>,
port: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Tunnels {
tunnels: Vec<Tunnel>,
}
impl Default for Tunnels {
fn default() -> Self {
Tunnels { tunnels: Vec::new() }
}
}
async fn find_tunnels() {
let app_port = utils::get_app_port();
let file_path = format!(
"/home/{}/.i2p-zero/config/tunnels.json",
env::var("USER").unwrap_or(String::from("user"))
);
let contents = fs::read_to_string(file_path).unwrap_or(utils::empty_string());
debug!("i2p tunnels: {}", contents);
let has_app_tunnel = contents.contains(&format!("{}", app_port));
// let has_http_tunnel = contents.contains("4444");
if !has_app_tunnel {
tokio::time::sleep(Duration::new(120, 0)).await;
create_tunnel();
}
// TODO(c2m): why is i2p-zero http proxy always giving "destination not found" error?
// if !has_http_tunnel { create_http_proxy(); }
}
pub async fn start() {
info!("starting i2p-zero");
let args = args::Args::parse();
let path = args.i2p_zero_dir;
let output = Command::new(format!("{}/router/bin/i2p-zero", path))
.spawn()
.expect("i2p-zero failed to start");
debug!("{:?}", output.stdout);
find_tunnels().await;
{ tokio::spawn(async { check_connection(true).await }); }
}
fn create_tunnel() {
info!("creating tunnel");
let output = Command::new("./i2p-zero-linux.v1.20/router/bin/tunnel-control.sh")
.args(["server.create", "127.0.0.1", &format!("{}",utils::get_app_port())])
.spawn()
.expect("i2p-zero failed to create a tunnel");
debug!("{:?}", output.stdout);
}
// TODO(c2m): use i2p-zero http proxy
// fn create_http_proxy() {
// info!("creating http proxy");
// let output = Command::new("./i2p-zero-linux.v1.20/router/bin/tunnel-control.sh")
// .args(["http.create", "4444"])
// .spawn()
// .expect("i2p-zero failed to create a http proxy");
// debug!("{:?}", output.stdout);
// }
pub fn get_destination() -> String {
let file_path = format!(
"/home/{}/.i2p-zero/config/tunnels.json",
env::var("USER").unwrap_or(String::from("user"))
);
let contents = fs::read_to_string(file_path).expect("read tunnels.json");
let input = format!(r#"{contents}"#);
let mut j: Tunnels = serde_json::from_str(&input).unwrap_or(Default::default());
let destination: String = j.tunnels.remove(0).dest.ok_or(utils::empty_string())
.unwrap_or(utils::empty_string());
destination
}
pub async fn get_proxy_status() -> HttpProxyStatus {
check_connection(false).await
}
/// Ping i2pd tunnels local server for status
async fn check_connection(bg: bool) -> HttpProxyStatus {
let client: reqwest::Client = reqwest::Client::new();
let host: &str = "http://localhost:7657/tunnels";
if bg {
let tick: std::sync::mpsc::Receiver<()> = schedule_recv::periodic_ms(60000);
loop {
tick.recv().unwrap();
return process_connection_info(client, host).await;
}
} else {
return process_connection_info(client, host).await;
}
}
async fn process_connection_info(client: Client, host: &str) -> HttpProxyStatus {
match client.get(host).send().await {
Ok(response) => {
// do some parsing here to check the status
let res = response.text().await;
match res {
Ok(res) => {
// split the html from the local i2p tunnels page
let split1 = res.split("<h4><span class=\"tunnelBuildStatus\">");
let mut v1: Vec<String> = split1.map(|s| String::from(s)).collect();
let s1 = v1.remove(1);
let v2 = s1.split("</span></h4>");
let mut split2: Vec<String> = v2.map(|s| String::from(s)).collect();
let status: String = split2.remove(0);
if status == I2pStatus::Accept.value() {
info!("i2p is currently accepting tunnels");
return HttpProxyStatus { open: true };
} else if status == I2pStatus::Reject.value() {
info!("i2p is currently rejecting tunnels");
return HttpProxyStatus { open: false };
} else {
info!("i2p is unstable");
return HttpProxyStatus { open: true };
}
}
_ => {
error!("i2p status check failure");
return HttpProxyStatus { open: false };
}
}
}
Err(e) => {
warn!("i2p-zero http proxy is degraded");
warn!("please install i2pd from geti2p.org");
warn!("start i2p with /path-to-i2p/i2prouter start");
error!("{}", e.to_string());
return HttpProxyStatus { open: false };
}
}
}

18
nevmes-core/src/lib.rs Normal file
View file

@ -0,0 +1,18 @@
pub mod args; // command line arguments
pub mod auth; // internal auth repo/service layer
pub mod contact; // contact repo/service layer
pub mod db; // lmdb interface
pub mod gpg; // gpgme interface
pub mod i2p; // i2p repo/service layer
pub mod message; // message repo/service layer
pub mod models; // db structs
pub mod monero; // monero-wallet-rpc interface
pub mod proof; // external auth/payment proof module
pub mod reqres; // http request/responses
pub mod utils; // misc.
pub mod user; // user rep/service layer
pub const NEVMES_JWP_SECRET_KEY: &str = "NEVMES_JWP_SECRET_KEY";
pub const NEVMES_JWT_SECRET_KEY: &str = "NEVMES_JWT_SECRET_KEY";
// DO NOT EDIT BELOW THIS LINE

248
nevmes-core/src/message.rs Normal file
View file

@ -0,0 +1,248 @@
// Message repo/service layer
use crate::{contact, db, models::*, utils, reqres, i2p, gpg};
use std::error::Error;
use log::{debug, error, info};
use rocket::serde::json::Json;
/// Create a new message
pub async fn create(m: Json<Message>, jwp: String) -> Message {
let f_mid: String = format!("m{}", utils::generate_rnd());
info!("creating message: {}", &f_mid);
let created = chrono::offset::Utc::now().timestamp();
// get contact public gpg key and encrypt the message
debug!("sending message: {:?}", &m);
let e_body = gpg::encrypt(
String::from(&m.to), &m.body).unwrap_or(Vec::new());
let new_message = Message {
mid: String::from(&f_mid),
uid: String::from(&m.uid),
from: i2p::get_destination(),
body: e_body,
created,
to: String::from(&m.to),
};
debug!("insert message: {:?}", &new_message);
let s = db::Interface::open();
let k = &new_message.mid;
db::Interface::write(&s.env, &s.handle, k, &Message::to_db(&new_message));
// in order to retrieve all message, write keys to with ml
let list_key = format!("ml");
let r = db::Interface::read(&s.env, &s.handle, &String::from(&list_key));
if r == utils::empty_string() {
debug!("creating message index");
}
let msg_list = [r, String::from(&f_mid)].join(",");
debug!("writing message index {} for id: {}", msg_list, list_key);
db::Interface::write(&s.env, &s.handle, &String::from(list_key), &msg_list);
info!("attempting to send message");
let send = send_message(&new_message, &jwp).await;
send.unwrap();
new_message
}
/// Rx message
pub async fn rx(m: Json<Message>) {
// don't allow messages from outside the contact list
let is_in_contact_list = contact::exists(&m.from);
if !is_in_contact_list {
return;
}
let f_mid: String = format!("m{}", utils::generate_rnd());
let new_message = Message {
mid: String::from(&f_mid),
uid: String::from("rx"),
from: String::from(&m.from),
body: m.body.iter().cloned().collect(),
created: chrono::offset::Utc::now().timestamp(),
to: String::from(&m.to),
};
debug!("insert message: {:?}", &new_message);
let s = db::Interface::open();
let k = &new_message.mid;
db::Interface::write(&s.env, &s.handle, k, &Message::to_db(&new_message));
// in order to retrieve all message, write keys to with rx
let list_key = format!("rx");
let r = db::Interface::read(&s.env, &s.handle, &String::from(&list_key));
if r == utils::empty_string() {
debug!("creating message index");
}
let msg_list = [r, String::from(&f_mid)].join(",");
debug!("writing message index {} for {}", msg_list, list_key);
db::Interface::write(&s.env, &s.handle, &String::from(list_key), &msg_list);
}
/// Message lookup
pub fn find(mid: &String) -> Message {
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, &String::from(mid));
if r == utils::empty_string() {
error!("message not found");
return Default::default()
}
Message::from_db(String::from(mid), r)
}
/// Message lookup
pub fn find_all() -> Vec<Message> {
let i_s = db::Interface::open();
let i_list_key = format!("ml");
let i_r = db::Interface::read(&i_s.env, &i_s.handle, &String::from(i_list_key));
if i_r == utils::empty_string() {
error!("message index not found");
}
let i_v_mid = i_r.split(",");
let i_v: Vec<String> = i_v_mid.map(|s| String::from(s)).collect();
let mut messages: Vec<Message> = Vec::new();
for m in i_v {
let message: Message = find(&m);
if message.mid != utils::empty_string() {
messages.push(message);
}
}
let o_list_key = format!("rx");
let o_s = db::Interface::open();
let o_r = db::Interface::read(&o_s.env, &o_s.handle, &String::from(o_list_key));
if o_r == utils::empty_string() {
error!("message index not found");
}
let o_v_mid = o_r.split(",");
let o_v: Vec<String> = o_v_mid.map(|s| String::from(s)).collect();
for m in o_v {
let message: Message = find(&m);
if message.mid != utils::empty_string() {
messages.push(message);
}
}
messages
}
/// Tx message
async fn send_message(out: &Message, jwp: &str) -> Result<(), Box<dyn Error>> {
// TODO(c2m): Error handling for http 402 status
let host = utils::get_i2p_http_proxy();
let proxy = reqwest::Proxy::http(&host)?;
let client = reqwest::Client::builder().proxy(proxy).build();
// check if the contact is online
let is_online: bool = is_contact_online(String::from(jwp)).await.unwrap_or(false);
if is_online {
return match client?.post(format!("http://{}/message/rx", out.to))
.header("proof", jwp).json(&out).send().await {
Ok(response) => {
let res = response.text().await;
debug!("send response: {:?}", res);
match res {
Ok(r) => {
if r.contains("402") { error!("Payment required"); }
// remove the mid from fts if necessary
remove_from_retry(String::from(&out.mid));
Ok(())
},
_ => Ok(()),
}
}
Err(e) => {
error!("failed to send message due to: {:?}", e);
Ok(())
}
}
} else {
send_to_retry(String::from(&out.mid)).await;
Ok(())
}
}
/// Returns decrypted hex string of the encrypted message
pub fn decrypt_body(mid: String) -> reqres::DecryptedMessageBody {
let m = find(&mid);
let d = gpg::decrypt(&mid, &m.body).unwrap();
let body = hex::encode(d);
reqres::DecryptedMessageBody { mid, body }
}
/// Message deletion
pub fn delete(mid: &String) {
let s = db::Interface::open();
db::Interface::delete(&s.env, &s.handle, &String::from(mid));
}
/// ping the contact health check over i2p
async fn is_contact_online(jwp: String) -> Result<bool, Box<dyn Error>> {
let host = utils::get_i2p_http_proxy();
let proxy = reqwest::Proxy::http(&host)?;
let client = reqwest::Client::builder().proxy(proxy).build();
match client?.get(format!("http://{}/xmr/rpc/version", host))
.header("proof", jwp).send().await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcVersionResponse>().await;
debug!("check is contact online by version response: {:?}", res);
match res {
Ok(r) => {
if r.result.version == 0 { error!("Payment required"); }
if r.result.version != 0 { Ok(true) } else { Ok(false) }
},
_ => Ok(false),
}
}
Err(e) => {
error!("failed to send message due to: {:?}", e);
Ok(false)
}
}
}
/// stage message for async retry
async fn send_to_retry(mid: String) {
let s = db::Interface::open();
// in order to retrieve FTS (failed-to-send), write keys to with fts
let list_key = format!("fts");
let r = db::Interface::read(&s.env, &s.handle, &String::from(&list_key));
if r == utils::empty_string() {
debug!("creating fts message index");
}
let msg_list = [r, String::from(&mid)].join(",");
debug!("writing fts message index {} for id: {}", msg_list, list_key);
db::Interface::write(&s.env, &s.handle, &String::from(list_key), &msg_list);
}
/// clear fts message from index
fn remove_from_retry(mid: String) {
let s = db::Interface::open();
// in order to retrieve FTS (failed-to-send), write keys to with fts
let list_key = format!("fts");
let r = db::Interface::read(&s.env, &s.handle, &String::from(&list_key));
if r == utils::empty_string() {
debug!("fts is empty");
}
let pre_v_fts = r.split(",");
let v: Vec<String> = pre_v_fts.map(|s| if s != &mid { String::from(s)} else { utils::empty_string()} ).collect();
let msg_list = v.join(",");
debug!("writing fts message index {} for id: {}", msg_list, list_key);
db::Interface::write(&s.env, &s.handle, &String::from(list_key), &msg_list);
}
/// triggered on app startup, retries to send fts every minute
pub async fn retry_fts() {
let tick: std::sync::mpsc::Receiver<()> = schedule_recv::periodic_ms(60000);
loop {
tick.recv().unwrap();
let s = db::Interface::open();
let list_key = format!("fts");
let r = db::Interface::read(&s.env, &s.handle, &String::from(list_key));
if r == utils::empty_string() {
error!("fts message index not found");
}
let v_mid = r.split(",");
let v: Vec<String> = v_mid.map(|s| String::from(s)).collect();
for m in v {
let message: Message = find(&m);
if message.mid != utils::empty_string() {
// fetch the jwp which just so happens to be cached by the client
let s = db::Interface::open();
let k = format!("{}-{}", "gui-jwp", message.to);
let jwp = db::Interface::read(&s.env, &s.handle, &k);
send_message(&message, &jwp).await.unwrap();
}
}
}
}

208
nevmes-core/src/models.rs Normal file
View file

@ -0,0 +1,208 @@
use crate::utils;
use rocket::serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Authorization {
pub aid: String,
pub created: i64,
pub rnd: String,
pub token: String,
pub uid: String,
pub xmr_address: String,
}
impl Default for Authorization {
fn default() -> Self {
Authorization {
aid: utils::empty_string(),
created: 0,
uid: utils::empty_string(),
rnd: utils::empty_string(),
token: utils::empty_string(),
xmr_address: utils::empty_string(),
}
}
}
impl Authorization {
pub fn to_db(a: &Authorization) -> String {
format!(
"{}:{}:{}:{}:{}",
a.created, a.uid, a.rnd, a.token, a.xmr_address
)
}
pub fn from_db(k: String, v: String) -> Authorization {
let values = v.split(":");
let mut v: Vec<String> = values.map(|s| String::from(s)).collect();
let created_str = v.remove(0);
let created = match created_str.parse::<i64>() {
Ok(n) => n,
Err(_e) => 0,
};
let uid = v.remove(0);
let rnd = v.remove(0);
let token = v.remove(0);
let xmr_address = v.remove(0);
Authorization {
aid: k,
created,
uid,
rnd,
token,
xmr_address,
}
}
pub fn update_uid(a: Authorization, uid: String) -> Authorization {
Authorization {
aid: a.aid,
created: a.created,
uid,
rnd: a.rnd,
token: a.token,
xmr_address: a.xmr_address,
}
}
pub fn update_expiration(
a: Authorization,
created: i64,
rnd: String,
token: String,
) -> Authorization {
Authorization {
aid: a.aid,
created,
uid: a.uid,
rnd,
token,
xmr_address: a.xmr_address,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Contact {
pub cid: String,
pub i2p_address: String,
pub xmr_address: String,
pub gpg_key: Vec<u8>,
}
impl Default for Contact {
fn default() -> Self {
Contact {
cid: utils::empty_string(),
gpg_key: Vec::new(),
i2p_address: utils::empty_string(),
xmr_address: utils::empty_string(),
}
}
}
impl Contact {
pub fn to_db(c: &Contact) -> String {
let gpg = hex::encode(&c.gpg_key);
format!("{}!{}!{}", gpg, c.i2p_address, c.xmr_address)
}
pub fn from_db(k: String, v: String) -> Contact {
let values = v.split("!");
let mut v: Vec<String> = values.map(|s| String::from(s)).collect();
let gpg_key = hex::decode(v.remove(0)).unwrap_or(Vec::new());
let i2p_address = v.remove(0);
let xmr_address = v.remove(0);
Contact {
cid: k,
gpg_key,
i2p_address,
xmr_address,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Message {
pub mid: String,
pub uid: String,
pub body: Vec<u8>,
pub created: i64,
pub from: String,
pub to: String,
}
impl Default for Message {
fn default() -> Self {
Message {
mid: utils::empty_string(),
uid: utils::empty_string(),
body: Vec::new(),
created: 0,
from: utils::empty_string(),
to: utils::empty_string(),
}
}
}
impl Message {
pub fn to_db(m: &Message) -> String {
let body = hex::encode(&m.body);
format!("{}:{}:{}:{}:{}", m.uid, body, m.created, m.from, m.to)
}
pub fn from_db(k: String, v: String) -> Message {
let values = v.split(":");
let mut v: Vec<String> = values.map(|s| String::from(s)).collect();
let uid = v.remove(0);
let body = hex::decode(v.remove(0)).unwrap_or(Vec::new());
let created_str = v.remove(0);
let created = match created_str.parse::<i64>() {
Ok(n) => n,
Err(_e) => 0,
};
let from = v.remove(0);
let to = v.remove(0);
Message { mid: k, uid, body, created, from, to, }
}
}
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct User {
pub uid: String,
pub xmr_address: String,
pub name: String,
}
impl Default for User {
fn default() -> Self {
User {
uid: utils::empty_string(),
xmr_address: utils::empty_string(),
name: utils::empty_string(),
}
}
}
impl User {
pub fn to_db(u: &User) -> String {
format!("{}:{}", u.name, u.xmr_address)
}
pub fn from_db(k: String, v: String) -> User {
let values = v.split(":");
let mut v: Vec<String> = values.map(|s| String::from(s)).collect();
let name = v.remove(0);
let xmr_address = v.remove(0);
User {
uid: k,
name,
xmr_address,
}
}
pub fn update(u: User, name: String) -> User {
User {
uid: u.uid,
name,
xmr_address: u.xmr_address,
}
}
}

805
nevmes-core/src/monero.rs Normal file
View file

@ -0,0 +1,805 @@
use crate::{args, reqres, utils::{self, get_release_env, ReleaseEnvironment}, proof};
use clap::Parser;
use diqwest::WithDigestAuth;
use log::{debug, error, info};
use std::process::Command;
/// Current xmr ring size updated here.
const RING_SIZE: u32 = 0x10;
struct RpcLogin {
username: String,
credential: String,
}
enum RpcFields {
Address,
Balance,
CheckTxProof,
Close,
Create,
Export,
Finalize,
GetTxProof,
GetTxById,
GetVersion,
Id,
Import,
JsonRpcVersion,
Make,
Open,
Prepare,
SignMultisig,
SweepAll,
Transfer,
ValidateAddress,
Verify,
}
impl RpcFields {
pub fn value(&self) -> String {
match *self {
RpcFields::Address => String::from("get_address"),
RpcFields::Balance => String::from("get_balance"),
RpcFields::CheckTxProof => String::from("check_tx_proof"),
RpcFields::Close => String::from("close_wallet"),
RpcFields::Create => String::from("create_wallet"),
RpcFields::Export => String::from("export_multisig_info"),
RpcFields::Finalize => String::from("finalize_multisig"),
RpcFields::GetTxProof => String::from("get_tx_proof"),
RpcFields::GetTxById => String::from("get_transfer_by_txid"),
RpcFields::GetVersion => String::from("get_version"),
RpcFields::Id => String::from("0"),
RpcFields::Import => String::from("import_multisig_info"),
RpcFields::JsonRpcVersion => String::from("2.0"),
RpcFields::Make => String::from("make_multisig"),
RpcFields::Open => String::from("open_wallet"),
RpcFields::Prepare => String::from("prepare_multisig"),
RpcFields::SignMultisig => String::from("sign_multisig"),
RpcFields::SweepAll => String::from("sweep_all"),
RpcFields::Transfer => String::from("transfer"),
RpcFields::ValidateAddress => String::from("validate_address"),
RpcFields::Verify => String::from("verify"),
}
}
}
enum DaemonFields {
GetInfo,
Id,
Version,
}
impl DaemonFields {
pub fn value(&self) -> String {
match *self {
DaemonFields::GetInfo => String::from("get_info"),
DaemonFields::Id => String::from("0"),
DaemonFields::Version => String::from("2.0"),
}
}
}
pub enum LockTimeLimit {
Blocks,
}
impl LockTimeLimit {
pub fn value(&self) -> u64 {
match *self { LockTimeLimit::Blocks => 20, }
}
}
/// Start monerod from the -`-monero-location` flag
///
/// default: /home/$USER/monero-xxx-xxx
pub fn start_daemon() {
info!("starting monerod");
let blockchain_dir = get_blockchain_dir();
let bin_dir = get_monero_location();
let release_env = get_release_env();
if release_env == ReleaseEnvironment::Development {
let args = ["--data-dir", &blockchain_dir, "--stagenet", "--detach"];
let output = Command::new(format!("{}/monerod", bin_dir))
.args(args)
.spawn()
.expect("monerod failed to start");
debug!("{:?}", output.stdout);
} else {
let args = ["--data-dir", &blockchain_dir, "--detach"];
let output = Command::new(format!("{}/monerod", bin_dir))
.args(args)
.spawn()
.expect("monerod failed to start");
debug!("{:?}", output.stdout);
}
}
/// Start monero-wallet-rpc
pub fn start_rpc() {
info!("starting monero-wallet-rpc");
let bin_dir = get_monero_location();
let port = get_rpc_port();
let login = get_rpc_creds();
let daemon_address = get_rpc_daemon();
let rpc_login = format!("{}:{}", &login.username, &login.credential);
let mut wallet_dir = format!("/home/{}/.nevmes/stagenet/wallet/",
std::env::var("USER").unwrap_or(String::from("user")),
);
let release_env = get_release_env();
if release_env == ReleaseEnvironment::Development {
let args = [
"--rpc-bind-port", &port,
"--wallet-dir", &wallet_dir,
"--rpc-login", &rpc_login,
"--daemon-address", &daemon_address,
"--stagenet"
];
let output = Command::new(format!("{}/monero-wallet-rpc", bin_dir))
.args(args)
.spawn()
.expect("monero-wallet-rpc failed to start");
debug!("{:?}", output.stdout);
} else {
wallet_dir = format!("/home/{}/.nevmes/wallet/",
std::env::var("USER").unwrap_or(String::from("user")),
);
let args = ["--rpc-bind-port", &port, "--wallet-dir", &wallet_dir,
"--rpc-login", &rpc_login, "--daemon-address", &daemon_address];
let output = Command::new(format!("{}/monero-wallet-rpc", bin_dir))
.args(args)
.spawn()
.expect("monero-wallet-rpc failed to start");
debug!("{:?}", output.stdout);
}
}
fn get_rpc_port() -> String {
let args = args::Args::parse();
let rpc = String::from(args.monero_rpc_host);
let values = rpc.split(":");
let mut v: Vec<String> = values.map(|s| String::from(s)).collect();
let port = v.remove(2);
debug!("monero-wallet-rpc port: {}", port);
port
}
/// Get monero rpc host from command line argument
fn get_blockchain_dir() -> String {
let args = args::Args::parse();
String::from(args.monero_blockchain_dir)
}
/// Get monero download location
fn get_monero_location() -> String {
let args = args::Args::parse();
String::from(args.monero_location)
}
/// Get monero rpc host from the `--monero-rpc-host` cli arg
fn get_rpc_host() -> String {
let args = args::Args::parse();
let rpc = String::from(args.monero_rpc_host);
format!("{}/json_rpc", rpc)
}
/// Get creds from the `--monero-rpc-daemon` cli arg
fn get_rpc_creds() -> RpcLogin {
let args = args::Args::parse();
let username = String::from(args.monero_rpc_username);
let credential = String::from(args.monero_rpc_cred);
RpcLogin { username, credential }
}
fn get_rpc_daemon() -> String {
let args = args::Args::parse();
let daemon = String::from(args.monero_rpc_daemon);
format!("{}/json_rpc", daemon)
}
/// Performs rpc 'get_version' method
pub async fn get_version() -> reqres::XmrRpcVersionResponse {
let client = reqwest::Client::new();
let host = get_rpc_host();
let req = reqres::XmrRpcRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::GetVersion.value(),
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcVersionResponse>().await;
debug!("get version response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_e) => Default::default(),
}
}
/// Helper function for checking xmr rpc online during app startup
pub async fn check_rpc_connection() -> () {
let res: reqres::XmrRpcVersionResponse = get_version().await;
if res.result.version == 0 {
error!("failed to connect to monero-wallet-rpc");
}
}
/// Performs the xmr rpc 'verify' method
pub async fn verify_signature(address: String, data: String, signature: String) -> String {
info!("signature verification in progress");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcVerifyParams {
address,
data,
signature,
};
let req = reqres::XmrRpcVerifyRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Verify.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcVerifyResponse>().await;
debug!("verify response: {:?}", res);
match res {
Ok(res) => {
if res.result.good {
req.params.address
} else {
utils::ApplicationErrors::LoginError.value()
}
}
_ => utils::ApplicationErrors::LoginError.value(),
}
}
Err(_e) => utils::ApplicationErrors::LoginError.value(),
}
}
/// Performs the xmr rpc 'create_wallet' method
pub async fn create_wallet(filename: String) -> bool {
info!("creating wallet: {}", &filename);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcCreateWalletParams {
filename,
language: String::from("English"),
};
let req = reqres::XmrRpcCreateRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Create.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
// The result from wallet creation is empty
let res = response.text().await;
debug!("create response: {:?}", res);
match res {
Ok(r) => {
if r.contains("-1") {
return false;
}
true
},
_ => false,
}
}
Err(_) => false
}
}
/// Performs the xmr rpc 'open_wallet' method
pub async fn open_wallet(filename: String) -> bool {
info!("opening wallet for {}", &filename);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcOpenWalletParams {
filename,
};
let req = reqres::XmrRpcOpenRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Open.value(),
params,
};
debug!("open request: {:?}", req);
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
// The result from wallet operation is empty
let res = response.text().await;
debug!("open response: {:?}", res);
match res {
Ok(r) => {
if r.contains("-1") {
return false;
}
return true;
},
_ => false,
}
}
Err(_) => false
}
}
/// Performs the xmr rpc 'close_wallet' method
pub async fn close_wallet(filename: String) -> bool {
info!("closing wallet for {}", &filename);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcOpenWalletParams {
filename,
};
let req = reqres::XmrRpcOpenRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Close.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
// The result from wallet operation is empty
let res = response.text().await;
debug!("close response: {:?}", res);
match res {
Ok(_) => true,
_ => false,
}
}
Err(_) => false
}
}
/// Performs the xmr rpc 'get_balance' method
pub async fn get_balance() -> reqres::XmrRpcBalanceResponse {
info!("fetching wallet balance");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcBalanceParams = reqres::XmrRpcBalanceParams {
account_index: 0, address_indices: vec![0], all_accounts: false, strict: false,
};
let req = reqres::XmrRpcBalanceRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Balance.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcBalanceResponse>().await;
debug!("balance response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'get_address' method
pub async fn get_address() -> reqres::XmrRpcAddressResponse {
info!("fetching wallet address");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcAddressParams = reqres::XmrRpcAddressParams {
account_index: 0, address_index: vec![0],
};
let req = reqres::XmrRpcAddressRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Address.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcAddressResponse>().await;
debug!("address response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'get_address' method
pub async fn validate_address(address: &String) -> reqres::XmrRpcValidateAddressResponse {
info!("validating wallet address");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcValidateAddressParams = reqres::XmrRpcValidateAddressParams {
address: String::from(address), any_net_type: false, allow_openalias: true,
};
let req = reqres::XmrRpcValidateAddressRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::ValidateAddress.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcValidateAddressResponse>().await;
debug!("validate_address response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
// START Multisig
/// Performs the xmr rpc 'prepare_multisig' method
pub async fn prepare_wallet() -> reqres::XmrRpcPrepareResponse {
info!("prepare msig wallet");
let client = reqwest::Client::new();
let host = get_rpc_host();
let req = reqres::XmrRpcRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Prepare.value(),
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcPrepareResponse>().await;
debug!("prepare response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'make_multisig' method
pub async fn make_wallet(info: Vec<String>) -> reqres::XmrRpcMakeResponse {
info!("make msig wallet");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcMakeParams {
multisig_info: info,
threshold: 2,
};
let req = reqres::XmrRpcMakeRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Make.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcMakeResponse>().await;
debug!("make response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'finalize_multisig' method
pub async fn finalize_wallet(info: Vec<String>) -> reqres::XmrRpcFinalizeResponse {
info!("finalize msig wallet");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcFinalizeParams {
multisig_info: info,
};
let req = reqres::XmrRpcFinalizeRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Finalize.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcFinalizeResponse>().await;
debug!("finalize response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'export_multisig_info' method
pub async fn export_multisig_info() -> reqres::XmrRpcExportResponse {
info!("export msig info");
let client = reqwest::Client::new();
let host = get_rpc_host();
let req = reqres::XmrRpcRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Export.value(),
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcExportResponse>().await;
debug!("export msig response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'import_multisig_info' method
pub async fn import_multisig_info(info: Vec<String>) -> reqres::XmrRpcImportResponse {
info!("import msig wallet");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcImportParams {
info,
};
let req = reqres::XmrRpcImportRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Import.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcImportResponse>().await;
debug!("import msig info response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'sign_multisig' method
pub async fn sign_multisig(tx_data_hex: String) -> reqres::XmrRpcSignMultisigResponse {
info!("sign msig txset");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params = reqres::XmrRpcSignMultisigParams {
tx_data_hex,
};
let req = reqres::XmrRpcSignMultisigRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::SignMultisig.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcSignMultisigResponse>().await;
debug!("sign msig txset response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
// END Multisig
/// Performs the xmr rpc 'check_tx_proof' method
pub async fn check_tx_proof(txp: &proof::TxProof) -> reqres::XmrRpcCheckTxProofResponse {
info!("check_tx_proof proof: {:?}", txp);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcCheckTxProofParams = reqres::XmrRpcCheckTxProofParams {
address: String::from(&txp.address),
message: String::from(&txp.message),
signature: String::from(&txp.signature),
txid: String::from(&txp.hash),
};
let req = reqres::XmrRpcCheckTxProofRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::CheckTxProof.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcCheckTxProofResponse>().await;
debug!("check_tx_proof response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'get_tx_proof' method
pub async fn get_tx_proof(ptxp: proof::TxProof) -> reqres::XmrRpcGetTxProofResponse {
info!("fetching proof: {:?}", &ptxp.hash);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcGetTxProofParams = reqres::XmrRpcGetTxProofParams {
address: String::from(&ptxp.address),
message: String::from(&ptxp.message),
txid: String::from(&ptxp.hash),
};
let req = reqres::XmrRpcGetTxProofRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::GetTxProof.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcGetTxProofResponse>().await;
debug!("get_tx_proof response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'get_transfer_by_txid' method
pub async fn get_transfer_by_txid(txid: &str) -> reqres::XmrRpcGetTxByIdResponse {
info!("fetching tx: {:?}", txid);
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcGetTxByIdParams = reqres::XmrRpcGetTxByIdParams {
txid: String::from(txid)
};
let req = reqres::XmrRpcGetTxByIdRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::GetTxById.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcGetTxByIdResponse>().await;
debug!("get_transfer_by_txid response: {:?}", res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'transfer' method
pub async fn transfer(d: reqres::Destination) -> reqres::XmrRpcTransferResponse {
info!("transfer");
let client = reqwest::Client::new();
let host = get_rpc_host();
let mut destinations: Vec<reqres::Destination> = Vec::new();
destinations.push(d);
let params: reqres::XmrRpcTransferParams = reqres::XmrRpcTransferParams {
account_index: 0,
destinations,
get_tx_key: false,
priority: 0,
ring_size: RING_SIZE,
subaddr_indices: vec![0],
};
let req = reqres::XmrRpcTransfrerRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::Transfer.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcTransferResponse>().await;
debug!("{} response: {:?}", RpcFields::Transfer.value(), res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
/// Performs the xmr rpc 'sweep_all' method
pub async fn sweep_all(address: String) -> reqres::XmrRpcSweepAllResponse {
info!("sweep_all");
let client = reqwest::Client::new();
let host = get_rpc_host();
let params: reqres::XmrRpcSweepAllParams = reqres::XmrRpcSweepAllParams { address };
let req = reqres::XmrRpcSweepAllRequest {
jsonrpc: RpcFields::JsonRpcVersion.value(),
id: RpcFields::Id.value(),
method: RpcFields::SweepAll.value(),
params,
};
let login: RpcLogin = get_rpc_creds();
match client.post(host).json(&req)
.send_with_digest_auth(&login.username, &login.credential).await {
Ok(response) => {
let res = response.json::<reqres::XmrRpcSweepAllResponse>().await;
debug!("{} response: {:?}", RpcFields::SweepAll.value(), res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}
// Daemon requests
//-------------------------------------------------------------------
/// Performs the xmr daemon 'get_info' method
pub async fn get_info() -> reqres::XmrDaemonGetInfoResponse {
info!("fetching daemon info");
let client = reqwest::Client::new();
let host = get_rpc_daemon();
let req = reqres::XmrRpcRequest {
jsonrpc: DaemonFields::Version.value(),
id: DaemonFields::Id.value(),
method: DaemonFields::GetInfo.value(),
};
match client.post(host).json(&req).send().await {
Ok(response) => {
let res = response.json::<reqres::XmrDaemonGetInfoResponse>().await;
debug!("{} response: {:?}", DaemonFields::GetInfo.value(), res);
match res {
Ok(res) => res,
_ => Default::default(),
}
}
Err(_) => Default::default()
}
}

206
nevmes-core/src/proof.rs Normal file
View file

@ -0,0 +1,206 @@
use crate::{monero, reqres, utils};
use log::{error, info};
use std::error::Error;
use rocket::http::Status;
use rocket::outcome::Outcome;
use rocket::request::FromRequest;
use rocket::{request, Request};
use hmac::{Hmac, Mac};
use jwt::*;
use serde::{Deserialize, Serialize};
use sha2::Sha512;
use std::collections::BTreeMap;
#[derive(Debug, Deserialize, Serialize)]
pub struct TxProof {
pub address: String,
pub confirmations: u64,
pub hash: String,
pub message: String,
pub signature: String,
}
impl Default for TxProof {
fn default() -> Self {
TxProof {
address: utils::empty_string(),
confirmations: 0,
hash: utils::empty_string(),
message: utils::empty_string(),
signature: utils::empty_string(),
}
}
}
pub async fn create_invoice() -> reqres::Invoice {
info!("creating invoice");
let m_address = monero::get_address().await;
let address = m_address.result.address;
let pay_threshold = utils::get_payment_threshold();
let conf_threshold = utils::get_conf_threshold();
reqres::Invoice { address, conf_threshold, pay_threshold }
}
pub async fn create_jwp(proof: &TxProof) -> String {
info!("creating jwp");
// validate the proof
let c_txp: TxProof = validate_proof(proof).await;
if c_txp.confirmations == 0 {
return utils::empty_string();
}
let jwp_secret_key = utils::get_jwp_secret_key();
let key: Hmac<Sha512> = Hmac::new_from_slice(&jwp_secret_key.as_bytes()).expect("hash");
let header = Header {
algorithm: AlgorithmType::Hs512,
..Default::default()
};
let mut claims = BTreeMap::new();
let address = &proof.address;
let hash = &proof.hash;
let expire = &format!("{}", utils::get_payment_threshold());
let message = &proof.message;
let signature = &proof.signature;
claims.insert("address", address);
claims.insert("hash", hash);
claims.insert("expire", expire);
claims.insert("message", message);
claims.insert("signature", signature);
let token = Token::new(header, claims).sign_with_key(&key);
String::from(token.expect("expected token").as_str())
}
/// Send transaction proof to contact for JWP generation
pub async fn prove_payment(contact: String, txp: &TxProof) -> Result<reqres::Jwp, Box<dyn Error>> {
// TODO(c2m): Error handling for http 402 status
let host = utils::get_i2p_http_proxy();
let proxy = reqwest::Proxy::http(&host)?;
let client = reqwest::Client::builder().proxy(proxy).build();
match client?.post(format!("http://{}/prove", contact)).json(txp).send().await {
Ok(response) => {
let res = response.json::<reqres::Jwp>().await;
log::debug!("prove payment response: {:?}", res);
match res {
Ok(r) => {
Ok(r)
},
_ => Ok(Default::default()),
}
}
Err(e) => {
error!("failed to prove payment: {:?}", e);
Ok(Default::default())
}
}
}
/// # PaymentProof
///
/// is a JWP (JSON Web Proof) with the contents:
///
/// address: this server's xmr address
///
/// hash: hash of the payment
///
/// message: (optional) default: empty string
///
/// signature: validates proof of payment
#[derive(Debug)]
pub struct PaymentProof(String);
impl PaymentProof { pub fn get_jwp(self) -> String { self.0 } }
#[derive(Debug)]
pub enum PaymentProofError {
Expired,
Missing,
Invalid,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for PaymentProof {
type Error = PaymentProofError;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let proof = request.headers().get_one("proof");
let m_address: reqres::XmrRpcAddressResponse = monero::get_address().await;
let nevmes_address = m_address.result.address;
match proof {
Some(proof) => {
// check validity of address, payment amount and tx confirmations
let jwp_secret_key = utils::get_jwp_secret_key();
let key: Hmac<Sha512> = Hmac::new_from_slice(&jwp_secret_key.as_bytes()).expect("");
let jwp: Result<
Token<jwt::Header, BTreeMap<std::string::String, std::string::String>, _>,
jwt::Error,
> = proof.verify_with_key(&key);
return match jwp {
Ok(j) => {
let claims = j.claims();
let address = &claims["address"];
if address != &nevmes_address {
return Outcome::Failure((
Status::PaymentRequired,
PaymentProofError::Invalid,
));
}
let hash = &claims["hash"];
let message = &claims["message"];
let signature = &claims["signature"];
// verify proof
let txp: TxProof = TxProof {
address: String::from(address),
hash: String::from(hash),
confirmations: 0,
message: String::from(message),
signature: String::from(signature),
};
let c_txp = validate_proof(&txp).await;
if c_txp.confirmations == 0 {
return Outcome::Failure((
Status::PaymentRequired,
PaymentProofError::Invalid,
));
}
// verify expiration
let expire = utils::get_conf_threshold();
if c_txp.confirmations > expire {
return Outcome::Failure((
Status::Unauthorized,
PaymentProofError::Expired,
));
}
Outcome::Success(PaymentProof(String::from(proof)))
}
Err(e) => {
error!("jwp error: {:?}", e);
return Outcome::Failure((Status::PaymentRequired, PaymentProofError::Invalid));
}
};
}
None => Outcome::Failure((Status::PaymentRequired, PaymentProofError::Missing)),
}
}
}
async fn validate_proof(txp: &TxProof) -> TxProof {
// verify unlock time isn't something funky (e.g. > 20)
let tx: reqres::XmrRpcGetTxByIdResponse = monero::get_transfer_by_txid(&txp.hash).await;
let unlock_time = tx.result.transfer.unlock_time;
let p = monero::check_tx_proof(txp).await;
let cth = utils::get_conf_threshold();
let pth = utils::get_payment_threshold();
let lgtm = p.result.good && !p.result.in_pool
&& unlock_time < monero::LockTimeLimit::Blocks.value()
&& p.result.confirmations < cth && p.result.received >= pth;
if lgtm {
return TxProof {
address: String::from(&txp.address),
hash: String::from(&txp.hash),
confirmations: p.result.confirmations,
message: String::from(&txp.message),
signature: String::from(&txp.signature)
}
}
Default::default()
}

843
nevmes-core/src/reqres.rs Normal file
View file

@ -0,0 +1,843 @@
use crate::utils;
use serde::{Deserialize, Serialize};
// All http requests and responses are here
// START XMR Structs
// params
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcValidateAddressParams {
pub address: String,
pub any_net_type: bool,
pub allow_openalias: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcVerifyParams {
pub address: String,
pub data: String,
pub signature: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcCreateWalletParams {
pub filename: String,
pub language: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcOpenWalletParams {
pub filename: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcMakeParams {
pub multisig_info: Vec<String>,
pub threshold: u8,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcFinalizeParams {
pub multisig_info: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcImportParams {
pub info: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcSignMultisigParams {
pub tx_data_hex: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcBalanceParams {
pub account_index: u8,
pub address_indices: Vec<u8>,
pub all_accounts: bool,
pub strict: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcAddressParams {
pub account_index: u8,
pub address_index: Vec<u8>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcCheckTxProofParams {
pub address: String,
pub message: String,
pub signature: String,
pub txid: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcTxProofParams {
pub address: String,
pub message: String,
pub txid: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxProofParams {
pub address: String,
pub message: String,
pub txid: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxByIdParams {
pub txid: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Destination {
pub address: String,
pub amount: u128,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcTransferParams {
pub destinations: Vec<Destination>,
pub account_index: u32,
pub subaddr_indices: Vec<u32>,
pub priority: u8,
pub ring_size: u32,
pub get_tx_key: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcSweepAllParams {
pub address: String,
}
// requests
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcValidateAddressRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcValidateAddressParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcCreateRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcCreateWalletParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcOpenRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcOpenWalletParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcAddressRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcAddressParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcBalanceRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcBalanceParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcMakeRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcMakeParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcFinalizeRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcFinalizeParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcImportRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcImportParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcSignMultisigRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcSignMultisigParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcVerifyRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcVerifyParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcCheckTxProofRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcCheckTxProofParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxProofRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcGetTxProofParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxByIdRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcGetTxByIdParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcTransfrerRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcTransferParams,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcSweepAllRequest {
pub jsonrpc: String,
pub id: String,
pub method: String,
pub params: XmrRpcSweepAllParams,
}
// results
#[derive(Deserialize, Debug)]
pub struct XmrRpcValidateAddressResult {
pub integrated: bool,
pub nettype: String,
pub openalias_address: String,
pub subaddress: bool,
pub valid: bool,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcVerifyResult {
pub good: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct XmrRpcVersionResult {
pub version: u32,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcFinalizeResult {
pub address: String,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcPrepareResult {
pub multisig_info: String,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcMakeResult {
pub address: String,
pub multisig_info: String,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcExportResult {
pub info: String,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcImportResult {
pub n_outputs: u8,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcSignMultisigResult {
pub tx_hash_list: Vec<String>,
}
#[derive(Deserialize, Debug)]
pub struct SubAddressInfo {
pub account_index: u8,
pub address_index: u8,
pub address: String,
pub balance: u128,
pub unlocked_balance: u128,
pub label: String,
pub num_unspent_outputs: u8,
pub time_to_unlock: u128,
pub blocks_to_unlock: u128,
}
#[derive(Deserialize, Debug)]
pub struct Address {
pub address: String,
pub address_index: u8,
pub label: String,
pub used: bool,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcAddressResult {
pub address: String,
pub addresses: Vec<Address>,
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcBalanceResult {
pub balance: u128,
pub unlocked_balance: u128,
pub multisig_import_needed: bool,
pub time_to_unlock: u128,
pub blocks_to_unlock: u128,
pub per_subaddress: Vec<SubAddressInfo>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcCheckTxProofResult {
pub confirmations: u64,
pub good: bool,
pub in_pool: bool,
pub received: u128,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxProofResult {
pub signature: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct SubAddressIndex {
pub major: u64,
pub minor: u64,
}
impl Default for SubAddressIndex {
fn default() -> Self {
SubAddressIndex {
major: 0,
minor: 0,
}
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Transfer {
pub address: String,
pub amount: u128,
pub amounts: Vec<u128>,
pub confirmations: u64,
pub double_spend_seen: bool,
pub fee: u128,
pub height: u64,
pub locked: bool,
pub note: String,
pub payment_id: String,
pub subaddr_index: SubAddressIndex,
pub subaddr_indices: Vec<SubAddressIndex>,
pub suggested_confirmations_threshold: u64,
pub timestamp: u64,
pub txid: String,
pub r#type: String,
pub unlock_time: u64,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcGetTxByIdResult {
pub transfer: Transfer,
pub transfers: Vec<Transfer>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcTranferResult {
pub amount: u128,
pub fee: u128,
pub multisig_txset: String,
pub tx_blob: String,
pub tx_hash: String,
pub tx_key: String,
pub tx_metadata: String,
pub unsigned_txset: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct KeyImageList {
key_images: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct XmrRpcSweepAllResult {
pub amount_list: Vec<u128>,
pub fee_list: Vec<u128>,
pub multisig_txset: String,
pub spent_key_images_list: Vec<KeyImageList>,
pub tx_hash_list: Vec<String>,
pub unsigned_txset: String,
pub weight_list: Vec<u128>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct XmrDaemonGetInfoResult {
pub adjusted_time: u64,
pub alt_blocks_count: u64,
pub block_size_limit: u64,
pub block_size_median: u64,
pub block_weight_median: u64,
pub bootstrap_daemon_address: String,
pub busy_syncing: bool,
pub credits: u64,
pub cumulative_difficulty: u64,
pub cumulative_difficulty_top64: u64,
pub database_size: u64,
pub difficulty: u64,
pub difficulty_top64: u64,
pub free_space: u64,
pub grey_peerlist_size: u64,
pub height: u64,
pub height_without_bootstrap: u64,
pub incoming_connections_count: u32,
pub mainnet: bool,
pub nettype: String,
pub offline: bool,
pub outgoing_connections_count: u32,
pub restricted: bool,
pub rpc_connections_count: u32,
pub stagenet: bool,
pub start_time: u64,
pub status: String,
pub synchronized: bool,
pub target: u32,
pub target_height: u32,
pub testnet: bool,
pub top_block_hash: String,
pub top_hash: String,
pub tx_count: u64,
pub tx_pool_size: u32,
pub untrusted: bool,
pub update_available: bool,
pub version: String,
pub was_bootstrap_ever_used: bool,
pub white_peerlist_size: u32,
pub wide_cumulative_difficulty: String,
pub wide_difficulty: String,
}
// responses
#[derive(Serialize, Deserialize, Debug)]
pub struct XmrDaemonGetInfoResponse {
pub result: XmrDaemonGetInfoResult,
}
impl Default for XmrDaemonGetInfoResponse {
fn default() -> Self {
XmrDaemonGetInfoResponse {
result: XmrDaemonGetInfoResult {
adjusted_time: 0,
alt_blocks_count: 0,
block_size_limit: 0,
block_size_median: 0,
block_weight_median: 0,
bootstrap_daemon_address: utils::empty_string(),
busy_syncing: false,
credits: 0,
cumulative_difficulty: 0,
cumulative_difficulty_top64: 0,
database_size: 0,
difficulty: 0,
difficulty_top64: 0,
free_space: 0,
grey_peerlist_size: 0,
height: 0,
height_without_bootstrap: 0,
incoming_connections_count: 0,
mainnet: false,
nettype: utils::empty_string(),
offline: false,
outgoing_connections_count: 0,
restricted: false,
rpc_connections_count: 0,
stagenet: false,
start_time: 0,
status: utils::empty_string(),
synchronized: false,
target: 0,
target_height: 0,
testnet: false,
top_block_hash: utils::empty_string(),
top_hash: utils::empty_string(),
tx_count: 0,
tx_pool_size: 0,
untrusted: false,
update_available: false,
version: utils::empty_string(),
was_bootstrap_ever_used: false,
white_peerlist_size: 0,
wide_cumulative_difficulty: utils::empty_string(),
wide_difficulty: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcVerifyResponse {
pub result: XmrRpcVerifyResult,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct XmrRpcVersionResponse {
pub result: XmrRpcVersionResult,
}
impl Default for XmrRpcVersionResponse {
fn default() -> Self {
XmrRpcVersionResponse {
result: XmrRpcVersionResult { version: 0 },
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcPrepareResponse {
pub result: XmrRpcPrepareResult,
}
impl Default for XmrRpcPrepareResponse {
fn default() -> Self {
XmrRpcPrepareResponse {
result: XmrRpcPrepareResult {
multisig_info: String::from(""),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcBalanceResponse {
pub result: XmrRpcBalanceResult,
}
impl Default for XmrRpcBalanceResponse {
fn default() -> Self {
XmrRpcBalanceResponse {
result: XmrRpcBalanceResult {
balance: 0,
unlocked_balance: 0,
multisig_import_needed: false,
time_to_unlock: 0,
blocks_to_unlock: 0,
per_subaddress: Vec::new(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcValidateAddressResponse {
pub result: XmrRpcValidateAddressResult,
}
impl Default for XmrRpcValidateAddressResponse {
fn default() -> Self {
XmrRpcValidateAddressResponse {
result: XmrRpcValidateAddressResult {
integrated: false,
nettype: utils::empty_string(),
openalias_address: utils::empty_string(),
subaddress: false,
valid: false,
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcAddressResponse {
pub result: XmrRpcAddressResult,
}
impl Default for XmrRpcAddressResponse {
fn default() -> Self {
XmrRpcAddressResponse {
result: XmrRpcAddressResult {
address: utils::empty_string(),
addresses: Vec::new(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcMakeResponse {
pub result: XmrRpcMakeResult,
}
impl Default for XmrRpcMakeResponse {
fn default() -> Self {
XmrRpcMakeResponse {
result: XmrRpcMakeResult {
address: utils::empty_string(),
multisig_info: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcFinalizeResponse {
pub result: XmrRpcFinalizeResult,
}
impl Default for XmrRpcFinalizeResponse {
fn default() -> Self {
XmrRpcFinalizeResponse {
result: XmrRpcFinalizeResult {
address: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcExportResponse {
pub result: XmrRpcExportResult,
}
impl Default for XmrRpcExportResponse {
fn default() -> Self {
XmrRpcExportResponse {
result: XmrRpcExportResult {
info: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcImportResponse {
pub result: XmrRpcImportResult,
}
impl Default for XmrRpcImportResponse {
fn default() -> Self {
XmrRpcImportResponse {
result: XmrRpcImportResult { n_outputs: 0 },
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcSignMultisigResponse {
pub result: XmrRpcSignMultisigResult,
}
impl Default for XmrRpcSignMultisigResponse {
fn default() -> Self {
XmrRpcSignMultisigResponse {
result: XmrRpcSignMultisigResult {
tx_hash_list: Vec::new(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcCheckTxProofResponse {
pub result: XmrRpcCheckTxProofResult,
}
impl Default for XmrRpcCheckTxProofResponse {
fn default() -> Self {
XmrRpcCheckTxProofResponse {
result: XmrRpcCheckTxProofResult {
confirmations: 0,
good: false,
in_pool: false,
received: 0,
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcGetTxProofResponse {
pub result: XmrRpcGetTxProofResult,
}
impl Default for XmrRpcGetTxProofResponse {
fn default() -> Self {
XmrRpcGetTxProofResponse {
result: XmrRpcGetTxProofResult {
signature: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcGetTxByIdResponse {
pub result: XmrRpcGetTxByIdResult,
}
impl Default for XmrRpcGetTxByIdResponse {
fn default() -> Self {
XmrRpcGetTxByIdResponse {
result: XmrRpcGetTxByIdResult {
transfer: Transfer {
address: utils::empty_string(),
amount: 0,
amounts: Vec::new(),
confirmations: 0,
double_spend_seen: false,
fee: 0,
height: 0,
locked: false,
note: utils::empty_string(),
payment_id: utils::empty_string(),
subaddr_index: Default::default(),
subaddr_indices: Vec::new(),
suggested_confirmations_threshold: 0,
timestamp: 0,
txid: utils::empty_string(),
r#type: utils::empty_string(),
unlock_time: 0,
},
transfers: Vec::new(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcTransferResponse {
pub result: XmrRpcTranferResult,
}
impl Default for XmrRpcTransferResponse {
fn default() -> Self {
XmrRpcTransferResponse {
result: XmrRpcTranferResult {
amount: 0,
fee: 0,
multisig_txset: utils::empty_string(),
tx_blob: utils::empty_string(),
tx_hash: utils::empty_string(),
tx_key: utils::empty_string(),
tx_metadata: utils::empty_string(),
unsigned_txset: utils::empty_string(),
},
}
}
}
#[derive(Deserialize, Debug)]
pub struct XmrRpcSweepAllResponse {
pub result: XmrRpcSweepAllResult,
}
impl Default for XmrRpcSweepAllResponse {
fn default() -> Self {
XmrRpcSweepAllResponse {
result: XmrRpcSweepAllResult {
amount_list: Vec::new(),
fee_list: Vec::new(),
multisig_txset: utils::empty_string(),
spent_key_images_list: Vec::new(),
tx_hash_list: Vec::new(),
unsigned_txset: utils::empty_string(),
weight_list: Vec::new(),
},
}
}
}
// END XMR Structs
/// Container for the message decryption
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct DecryptedMessageBody {
pub mid: String,
pub body: String,
}
impl Default for DecryptedMessageBody {
fn default() -> Self {
DecryptedMessageBody {
mid: utils::empty_string(),
body: utils::empty_string(),
}
}
}
/// Invoice response for host.b32.i2p/invoice
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct Invoice {
pub address: String,
pub pay_threshold: u128,
pub conf_threshold: u64,
}
impl Default for Invoice {
fn default() -> Self {
Invoice {
address: utils::empty_string(),
pay_threshold: 0,
conf_threshold: 0,
}
}
}
/// Not to be confused with the PaymentProof guard.
///
/// This is the response when proving payment
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct Jwp {
pub jwp: String,
}
impl Default for Jwp {
fn default() -> Self {
Jwp {
jwp: utils::empty_string(),
}
}
}

47
nevmes-core/src/user.rs Normal file
View file

@ -0,0 +1,47 @@
// User repo/service layer
use crate::{db, models::*, utils};
use rocket::serde::json::Json;
use log::{debug, error, info};
// This module is only used for remote access
/// Create a new user
pub fn create(address: &String) -> User {
let f_uid: String = format!("u{}", utils::generate_rnd());
let new_user = User {
uid: String::from(&f_uid),
xmr_address: String::from(address),
name: utils::empty_string(),
};
debug!("insert user: {:?}", &new_user);
let s = db::Interface::open();
let k = &new_user.uid;
db::Interface::write(&s.env, &s.handle, k, &User::to_db(&new_user));
new_user
}
/// User lookup
pub fn find(uid: &String) -> User {
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, &String::from(uid));
if r == utils::empty_string() {
error!("user not found");
return Default::default()
}
User::from_db(String::from(uid), r)
}
/// Modify user
pub fn modify(u: Json<User>) -> User {
info!("modify user: {}", u.uid);
let f_cust: User = find(&u.uid);
if f_cust.uid == utils::empty_string() {
error!("user not found");
return Default::default();
}
let u_user = User::update(f_cust, String::from(&u.name));
let s = db::Interface::open();
db::Interface::delete(&s.env, &s.handle, &u_user.uid);
db::Interface::write(&s.env, &s.handle, &u_user.uid, &User::to_db(&u_user));
return u_user;
}

366
nevmes-core/src/utils.rs Normal file
View file

@ -0,0 +1,366 @@
use rand_core::RngCore;
use clap::Parser;
use rocket::serde::json::Json;
use crate::{args, db, i2p, message, models, monero, gpg, utils, reqres};
use log::{info, debug, error, warn};
use std::time::Duration;
/// Handles the state for the connection manager popup
pub struct Connections {
pub blockchain_dir: String,
pub daemon_host: String,
pub i2p_zero_dir: String,
pub mainnet: bool,
pub monero_location: String,
pub rpc_credential: String,
pub rpc_username: String,
pub rpc_host: String,
}
impl Default for Connections {
fn default() -> Self {
Connections {
blockchain_dir: String::from("/home/user/.bitmonero"),
daemon_host: String::from("http://localhost:38081"),
i2p_zero_dir: String::from("/home/user/i2p-zero-linux.v1.20"),
mainnet: false,
monero_location: String::from("/home/user/monero-x86_64-linux-gnu-v0.18.2.2"),
rpc_credential: String::from("pass"),
rpc_username: String::from("user"),
rpc_host: String::from("http://localhost:38083"),
}
}
}
#[derive(Debug)]
pub enum ApplicationErrors {
LoginError,
UnknownError,
}
impl ApplicationErrors {
pub fn value(&self) -> String {
match *self {
ApplicationErrors::LoginError => String::from("LoginError"),
ApplicationErrors::UnknownError => String::from("UnknownError"),
}
}
}
#[derive(Debug, PartialEq)]
pub enum ReleaseEnvironment {
Development,
Production,
}
impl ReleaseEnvironment {
pub fn value(&self) -> String {
match *self {
ReleaseEnvironment::Development => String::from("development"),
ReleaseEnvironment::Production => String::from("production"),
}
}
}
/// start core module from gui
pub fn start_core(conn: &Connections) {
let env = if !conn.mainnet { "dev" } else { "prod" };
let args = [
"--monero-location", &conn.monero_location,
"--monero-blockchain-dir", &conn.blockchain_dir,
"--monero-rpc-host", &conn.rpc_host,
"--monero-rpc-daemon", &conn.daemon_host,
"--monero-rpc-username", &conn.rpc_username,
"--monero-rpc-cred", &conn.rpc_credential,
"--i2p-zero-dir", &conn.i2p_zero_dir,
"-r", env,
];
let path = if conn.mainnet { "nevmes" } else { "../target/debug/nevmes" };
let output = std::process::Command::new(path)
.args(args)
.spawn()
.expect("core module failed to start");
debug!("{:?}", output.stdout);
}
/// Using remote node?
pub fn is_using_remote_node() -> bool {
let args = args::Args::parse();
let r = args.remote_node;
if r { warn!("using a remote node may harm privacy"); }
r
}
/// Random data generation for auth / primary keys
pub fn generate_rnd() -> String {
let mut data = [0u8; 32];
rand::thread_rng().fill_bytes(&mut data);
hex::encode(data)
}
/// Helper for separation of dev and prod concerns
pub fn get_release_env() -> ReleaseEnvironment {
let args = args::Args::parse();
let env = String::from(args.release_env);
if env == "prod" {
return ReleaseEnvironment::Production;
} else {
return ReleaseEnvironment::Development;
}
}
/// app port
pub fn get_app_port() -> u16 {
let args = args::Args::parse();
args.port
}
/// i2p http proxy
pub fn get_i2p_http_proxy() -> String {
let args = args::Args::parse();
args.i2p_proxy_host
}
/// app auth port
pub fn get_app_auth_port() -> u16 {
let args = args::Args::parse();
args.auth_port
}
/// app contact port
pub fn get_app_contact_port() -> u16 {
let args = args::Args::parse();
args.contact_port
}
/// app message port
pub fn get_app_message_port() -> u16 {
let args = args::Args::parse();
args.message_port
}
/// jwp confirmation limit
pub fn get_conf_threshold() -> u64 {
let args = args::Args::parse();
args.confirmation_threshold
}
/// jwp confirmation limit
pub fn get_payment_threshold() -> u128 {
let args = args::Args::parse();
args.payment_threshold
}
/// convert contact to json so only core module does the work
pub fn contact_to_json(c: &models::Contact) -> Json<models::Contact> {
let r_contact: models::Contact = models::Contact {
cid: String::from(&c.cid),
i2p_address: String::from(&c.i2p_address),
xmr_address: String::from(&c.xmr_address),
gpg_key: c.gpg_key.iter().cloned().collect(),
};
Json(r_contact)
}
/// convert message to json so only core module does the work
pub fn message_to_json(m: &models::Message) -> Json<models::Message> {
let r_message: models::Message = models::Message {
body: m.body.iter().cloned().collect(),
mid: String::from(&m.mid),
uid: utils::empty_string(),
created: m.created,
from: String::from(&m.from),
to: String::from(&m.to),
};
Json(r_message)
}
/// Instead of putting `String::from("")`
pub fn empty_string() -> String { String::from("") }
// DoS prevention
pub const fn string_limit() -> usize { 512 }
pub const fn gpg_key_limit() -> usize { 4096 }
pub const fn message_limit() -> usize { 9999 }
/// Generate application gpg keys at startup if none exist
async fn gen_app_gpg() {
let mut gpg_key = gpg::find_key().unwrap_or(utils::empty_string());
if gpg_key == utils::empty_string() {
info!("no gpg key found for nevmes, creating it...");
// wait for key gen
gpg::write_gen_batch().unwrap();
gpg::gen_key();
tokio::time::sleep(Duration::new(9, 0)).await;
gpg_key = gpg::find_key().unwrap_or(utils::empty_string());
}
debug!("gpg key: {}", gpg_key);
}
/// Generate application wallet at startup if none exist
async fn gen_app_wallet() {
info!("fetching application wallet");
let filename = "nevmes";
let mut m_wallet = monero::open_wallet(String::from(filename)).await;
if !m_wallet {
m_wallet = monero::create_wallet(String::from(filename)).await;
if !m_wallet {
error!("failed to create wallet")
} else {
m_wallet = monero::open_wallet(String::from(filename)).await;
if m_wallet {
let m_address: reqres::XmrRpcAddressResponse =
monero::get_address().await;
info!("app wallet address: {}", m_address.result.address)
}
}
}
}
/// Secret keys for signing internal/external auth tokens
fn gen_signing_keys() {
info!("generating signing keys");
let jwp = get_jwp_secret_key();
let jwt = get_jwt_secret_key();
// send to db
let s = db::Interface::open();
if jwp == utils::empty_string() {
let rnd_jwp = generate_rnd();
db::Interface::write(&s.env, &s.handle, crate::NEVMES_JWP_SECRET_KEY, &rnd_jwp);
}
if jwt == utils::empty_string() {
let rnd_jwt = generate_rnd();
db::Interface::write(&s.env, &s.handle, crate::NEVMES_JWT_SECRET_KEY, &rnd_jwt);
}
}
/// TODO(c2m): add a button to gui to call this
///
/// dont' forget to generate new keys as well
pub fn revoke_signing_keys() {
let s = db::Interface::open();
db::Interface::delete(&s.env, &s.handle, crate::NEVMES_JWT_SECRET_KEY);
db::Interface::delete(&s.env, &s.handle, crate::NEVMES_JWP_SECRET_KEY);
}
pub fn get_jwt_secret_key() -> String {
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, crate::NEVMES_JWT_SECRET_KEY);
if r == utils::empty_string() {
error!("JWT key not found");
return Default::default()
}
r
}
pub fn get_jwp_secret_key() -> String {
let s = db::Interface::open();
let r = db::Interface::read(&s.env, &s.handle, crate::NEVMES_JWP_SECRET_KEY);
if r == utils::empty_string() {
error!("JWP key not found");
return Default::default()
}
r
}
/// Start the remote access microservers `--remote-access` flag
fn start_micro_servers() {
info!("starting auth server");
let mut auth_path = "nevmes-auth/target/debug/nevmes_auth";
let env = get_release_env();
if env == ReleaseEnvironment::Production { auth_path = "nevmes_auth"; }
let a_output = std::process::Command::new(auth_path)
.spawn().expect("failed to start auth server");
debug!("{:?}", a_output.stdout);
info!("starting contact server");
let mut contact_path = "nevmes-contact/target/debug/nevmes_contact";
if env == ReleaseEnvironment::Production { contact_path = "nevmes_contact"; }
let c_output = std::process::Command::new(contact_path)
.spawn().expect("failed to start contact server");
debug!("{:?}", c_output.stdout);
info!("starting message server");
let mut message_path = "nevmes-message/target/debug/nevmes_message";
if env == ReleaseEnvironment::Production { message_path = "nevmes_message"; }
let m_output = std::process::Command::new(message_path)
.spawn().expect("failed to start message server");
debug!("{:?}", m_output.stdout);
}
/// open gui from i2m core launch
fn start_gui() {
let args = args::Args::parse();
if args.gui {
info!("starting gui");
let mut gui_path = "nevmes-gui/target/debug/nevmes_gui";
let env = get_release_env();
if env == ReleaseEnvironment::Production { gui_path = "nevmes-gui"; }
let g_output = std::process::Command::new(gui_path)
.spawn().expect("failed to start gui");
debug!("{:?}", g_output.stdout);
}
}
/// Put all app pre-checks here
pub async fn start_up() {
info!("nevmes is starting up");
let args = args::Args::parse();
if args.remote_access { start_micro_servers(); }
gen_signing_keys();
if !is_using_remote_node() { monero::start_daemon(); }
// wait for daemon for a bit
tokio::time::sleep(std::time::Duration::new(5, 0)).await;
monero::start_rpc();
// wait for rpc server for a bit
tokio::time::sleep(std::time::Duration::new(5, 0)).await;
monero::check_rpc_connection().await;
gen_app_wallet().await;
i2p::start().await;
gen_app_gpg().await;
let env: String = get_release_env().value();
start_gui();
{ tokio::spawn(async { message::retry_fts().await; }); }
info!("{} - nevmes is online", env);
}
/// Called by gui for cleaning up monerod, rpc, etc.
///
/// pass true from gui connection manager so not to kill nevmes
pub fn kill_child_processes(cm: bool) {
info!("stopping child processes");
// TODO(c2m): prompt on gui letting user determine what background
// services to keep running
if cm {
let xmrd_output = std::process::Command::new("pkill")
.arg("monerod")
.spawn()
.expect("monerod failed to stop");
debug!("{:?}", xmrd_output.stdout);
let rpc_output = std::process::Command::new("killall")
.arg("monero-wallet-rpc")
.spawn()
.expect("monero-wallet-rpc failed to stop");
debug!("{:?}", rpc_output.stdout);
}
if !cm {
let nevmes_output = std::process::Command::new("pkill")
.arg("nevmes")
.spawn()
.expect("nevmes failed to stop");
debug!("{:?}", nevmes_output.stdout);
}
let i2pz_output = std::process::Command::new("pkill")
.arg("i2p-zero")
.spawn()
.expect("i2p-zero failed to stop");
debug!("{:?}", i2pz_output.stdout);
}
/// Move temp files to /tmp
pub fn stage_cleanup(f: String) {
info!("staging {} for cleanup", &f);
let output = std::process::Command::new("mv")
.args([&f, "/tmp"])
.spawn()
.expect("cleanup staging failed");
debug!("{:?}", output.stdout);
}

4
nevmes-gui/.gitignore vendored Executable file
View file

@ -0,0 +1,4 @@
/target
/core
notes.txt
.env

4752
nevmes-gui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

60
nevmes-gui/Cargo.toml Normal file
View file

@ -0,0 +1,60 @@
[package]
name = "nevmes_gui"
version = "0.1.0-alpha"
authors = ["emilk", "creating2morrow <creating2morrow@proton.me>"]
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.65"
publish = false
default-run = "nevmes_gui"
[package.metadata.docs.rs]
all-features = true
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["glow", "persistence"]
persistence = ["eframe/persistence", "egui/persistence", "serde"]
web_screen_reader = ["eframe/web_screen_reader"] # experimental
serde = ["dep:serde", "egui/serde"]
glow = ["eframe/glow"]
wgpu = ["eframe/wgpu", "bytemuck"]
[dependencies]
chrono = { version = "0.4", features = ["js-sys", "wasmbind"] }
eframe = { version = "0.21.0", path = "./crates/eframe", default-features = false }
egui = { version = "0.21.0", path = "./crates/egui", features = [
"extra_debug_asserts",
] }
hex = "0.4.3"
nevmes_core = { path = "../nevmes-core" }
log = "0.4"
qrcode = "0.12"
image = "0.23.14"
reqwest = { version = "0.11", features = ["json"] }
sha2 = "0.10.6"
tokio = { version = "1", features = ["net", "rt-multi-thread"] }
tracing = "0.1"
tracing-subscriber = "0.3"
# Optional dependencies:
bytemuck = { version = "1.7.1", optional = true }
egui_extras = { version = "0.21.0", path = "./crates/egui_extras" }
poll-promise = { version = "0.2", optional = true, default-features = false }
serde = { version = "1", optional = true, features = ["derive"] }
# native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tracing-subscriber = "0.3"
# web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.6"
tracing-wasm = "0.2"
wasm-bindgen-futures = "0.4"

201
nevmes-gui/LICENSE-APACHE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

25
nevmes-gui/LICENSE-MIT Normal file
View file

@ -0,0 +1,25 @@
Copyright (c) 2018-2021 creating2morrow, Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

4
nevmes-gui/README.md Normal file
View file

@ -0,0 +1,4 @@
## Dev
`cd ../ && cargo build && cd nevmes-gui && RUST_LOG=debug cargo run`

BIN
nevmes-gui/assets/i2p.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
nevmes-gui/assets/qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
nevmes-gui/assets/xmr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,13 @@
# Changelog for ecolor
All notable changes to the `ecolor` crate will be noted in this file.
## Unreleased
## 0.21.0 - 2023-02-08
* Add `Color32::gamma_multiply` ([#2437](https://github.com/emilk/egui/pull/2437)).
## 0.20.0 - 2022-12-08
* Split out `ecolor` crate from `epaint`

View file

@ -0,0 +1,50 @@
[package]
name = "ecolor"
version = "0.21.0"
authors = [
"Emil Ernerfeldt <emil.ernerfeldt@gmail.com>",
"Andreas Reich <reichandreas@gmx.de>",
]
description = "Color structs and color conversion utilities"
edition = "2021"
rust-version = "1.65"
homepage = "https://github.com/emilk/egui"
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/emilk/egui"
categories = ["mathematics", "encoding"]
keywords = ["gui", "color", "conversion", "gamedev", "images"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[package.metadata.docs.rs]
all-features = true
[lib]
[features]
default = []
## Enable additional checks if debug assertions are enabled (debug builds).
extra_debug_asserts = []
## Always enable additional checks.
extra_asserts = []
[dependencies]
#! ### Optional dependencies
## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast `ecolor` types to `&[u8]`.
bytemuck = { version = "1.7.2", optional = true, features = ["derive"] }
## [`cint`](https://docs.rs/cint) enables interopability with other color libraries.
cint = { version = "0.3.1", optional = true }
## Enable the [`hex_color`] macro.
color-hex = { version = "0.2.0", optional = true }
## Enable this when generating docs.
document-features = { version = "0.2", optional = true }
## Allow serialization using [`serde`](https://docs.rs/serde).
serde = { version = "1", optional = true, features = ["derive"] }

View file

@ -0,0 +1,11 @@
# ecolor - egui color library
[![Latest version](https://img.shields.io/crates/v/ecolor.svg)](https://crates.io/crates/ecolor)
[![Documentation](https://docs.rs/ecolor/badge.svg)](https://docs.rs/ecolor)
[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
A simple color storage and conversion library.
Made for [`egui`](https://github.com/emilk/egui/).

View file

@ -0,0 +1,161 @@
use super::*;
use cint::{Alpha, ColorInterop, EncodedSrgb, Hsv, LinearSrgb, PremultipliedAlpha};
// ---- Color32 ----
impl From<Alpha<EncodedSrgb<u8>>> for Color32 {
fn from(srgba: Alpha<EncodedSrgb<u8>>) -> Self {
let Alpha {
color: EncodedSrgb { r, g, b },
alpha: a,
} = srgba;
Color32::from_rgba_unmultiplied(r, g, b, a)
}
}
// No From<Color32> for Alpha<_> because Color32 is premultiplied
impl From<PremultipliedAlpha<EncodedSrgb<u8>>> for Color32 {
fn from(srgba: PremultipliedAlpha<EncodedSrgb<u8>>) -> Self {
let PremultipliedAlpha {
color: EncodedSrgb { r, g, b },
alpha: a,
} = srgba;
Color32::from_rgba_premultiplied(r, g, b, a)
}
}
impl From<Color32> for PremultipliedAlpha<EncodedSrgb<u8>> {
fn from(col: Color32) -> Self {
let (r, g, b, a) = col.to_tuple();
PremultipliedAlpha {
color: EncodedSrgb { r, g, b },
alpha: a,
}
}
}
impl From<PremultipliedAlpha<EncodedSrgb<f32>>> for Color32 {
fn from(srgba: PremultipliedAlpha<EncodedSrgb<f32>>) -> Self {
let PremultipliedAlpha {
color: EncodedSrgb { r, g, b },
alpha: a,
} = srgba;
// This is a bit of an abuse of the function name but it does what we want.
let r = linear_u8_from_linear_f32(r);
let g = linear_u8_from_linear_f32(g);
let b = linear_u8_from_linear_f32(b);
let a = linear_u8_from_linear_f32(a);
Color32::from_rgba_premultiplied(r, g, b, a)
}
}
impl From<Color32> for PremultipliedAlpha<EncodedSrgb<f32>> {
fn from(col: Color32) -> Self {
let (r, g, b, a) = col.to_tuple();
// This is a bit of an abuse of the function name but it does what we want.
let r = linear_f32_from_linear_u8(r);
let g = linear_f32_from_linear_u8(g);
let b = linear_f32_from_linear_u8(b);
let a = linear_f32_from_linear_u8(a);
PremultipliedAlpha {
color: EncodedSrgb { r, g, b },
alpha: a,
}
}
}
impl ColorInterop for Color32 {
type CintTy = PremultipliedAlpha<EncodedSrgb<u8>>;
}
// ---- Rgba ----
impl From<PremultipliedAlpha<LinearSrgb<f32>>> for Rgba {
fn from(srgba: PremultipliedAlpha<LinearSrgb<f32>>) -> Self {
let PremultipliedAlpha {
color: LinearSrgb { r, g, b },
alpha: a,
} = srgba;
Rgba([r, g, b, a])
}
}
impl From<Rgba> for PremultipliedAlpha<LinearSrgb<f32>> {
fn from(col: Rgba) -> Self {
let (r, g, b, a) = col.to_tuple();
PremultipliedAlpha {
color: LinearSrgb { r, g, b },
alpha: a,
}
}
}
impl ColorInterop for Rgba {
type CintTy = PremultipliedAlpha<LinearSrgb<f32>>;
}
// ---- Hsva ----
impl From<Alpha<Hsv<f32>>> for Hsva {
fn from(srgba: Alpha<Hsv<f32>>) -> Self {
let Alpha {
color: Hsv { h, s, v },
alpha: a,
} = srgba;
Hsva::new(h, s, v, a)
}
}
impl From<Hsva> for Alpha<Hsv<f32>> {
fn from(col: Hsva) -> Self {
let Hsva { h, s, v, a } = col;
Alpha {
color: Hsv { h, s, v },
alpha: a,
}
}
}
impl ColorInterop for Hsva {
type CintTy = Alpha<Hsv<f32>>;
}
// ---- HsvaGamma ----
impl ColorInterop for HsvaGamma {
type CintTy = Alpha<Hsv<f32>>;
}
impl From<Alpha<Hsv<f32>>> for HsvaGamma {
fn from(srgba: Alpha<Hsv<f32>>) -> Self {
let Alpha {
color: Hsv { h, s, v },
alpha: a,
} = srgba;
Hsva::new(h, s, v, a).into()
}
}
impl From<HsvaGamma> for Alpha<Hsv<f32>> {
fn from(col: HsvaGamma) -> Self {
let Hsva { h, s, v, a } = col.into();
Alpha {
color: Hsv { h, s, v },
alpha: a,
}
}
}

View file

@ -0,0 +1,216 @@
use crate::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8, Rgba};
/// This format is used for space-efficient color representation (32 bits).
///
/// Instead of manipulating this directly it is often better
/// to first convert it to either [`Rgba`] or [`crate::Hsva`].
///
/// Internally this uses 0-255 gamma space `sRGBA` color with premultiplied alpha.
/// Alpha channel is in linear space.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))]
pub struct Color32(pub(crate) [u8; 4]);
impl std::ops::Index<usize> for Color32 {
type Output = u8;
#[inline(always)]
fn index(&self, index: usize) -> &u8 {
&self.0[index]
}
}
impl std::ops::IndexMut<usize> for Color32 {
#[inline(always)]
fn index_mut(&mut self, index: usize) -> &mut u8 {
&mut self.0[index]
}
}
impl Color32 {
// Mostly follows CSS names:
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const BLACK: Color32 = Color32::from_rgb(0, 0, 0);
pub const DARK_GRAY: Color32 = Color32::from_rgb(96, 96, 96);
pub const GRAY: Color32 = Color32::from_rgb(160, 160, 160);
pub const LIGHT_GRAY: Color32 = Color32::from_rgb(220, 220, 220);
pub const WHITE: Color32 = Color32::from_rgb(255, 255, 255);
pub const BROWN: Color32 = Color32::from_rgb(165, 42, 42);
pub const DARK_RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
pub const RED: Color32 = Color32::from_rgb(255, 0, 0);
pub const LIGHT_RED: Color32 = Color32::from_rgb(255, 128, 128);
pub const YELLOW: Color32 = Color32::from_rgb(255, 255, 0);
pub const LIGHT_YELLOW: Color32 = Color32::from_rgb(255, 255, 0xE0);
pub const KHAKI: Color32 = Color32::from_rgb(240, 230, 140);
pub const DARK_GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
pub const GREEN: Color32 = Color32::from_rgb(0, 255, 0);
pub const LIGHT_GREEN: Color32 = Color32::from_rgb(0x90, 0xEE, 0x90);
pub const DARK_BLUE: Color32 = Color32::from_rgb(0, 0, 0x8B);
pub const BLUE: Color32 = Color32::from_rgb(0, 0, 255);
pub const LIGHT_BLUE: Color32 = Color32::from_rgb(0xAD, 0xD8, 0xE6);
pub const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
pub const DEBUG_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 200, 0, 128);
/// An ugly color that is planned to be replaced before making it to the screen.
pub const TEMPORARY_COLOR: Color32 = Color32::from_rgb(64, 254, 0);
#[inline(always)]
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 255])
}
#[inline(always)]
pub const fn from_rgb_additive(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 0])
}
/// From `sRGBA` with premultiplied alpha.
#[inline(always)]
pub const fn from_rgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
Self([r, g, b, a])
}
/// From `sRGBA` WITHOUT premultiplied alpha.
pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
if a == 255 {
Self::from_rgb(r, g, b) // common-case optimization
} else if a == 0 {
Self::TRANSPARENT // common-case optimization
} else {
let r_lin = linear_f32_from_gamma_u8(r);
let g_lin = linear_f32_from_gamma_u8(g);
let b_lin = linear_f32_from_gamma_u8(b);
let a_lin = linear_f32_from_linear_u8(a);
let r = gamma_u8_from_linear_f32(r_lin * a_lin);
let g = gamma_u8_from_linear_f32(g_lin * a_lin);
let b = gamma_u8_from_linear_f32(b_lin * a_lin);
Self::from_rgba_premultiplied(r, g, b, a)
}
}
#[inline(always)]
pub const fn from_gray(l: u8) -> Self {
Self([l, l, l, 255])
}
#[inline(always)]
pub const fn from_black_alpha(a: u8) -> Self {
Self([0, 0, 0, a])
}
pub fn from_white_alpha(a: u8) -> Self {
Rgba::from_white_alpha(linear_f32_from_linear_u8(a)).into()
}
#[inline(always)]
pub const fn from_additive_luminance(l: u8) -> Self {
Self([l, l, l, 0])
}
#[inline(always)]
pub const fn is_opaque(&self) -> bool {
self.a() == 255
}
#[inline(always)]
pub const fn r(&self) -> u8 {
self.0[0]
}
#[inline(always)]
pub const fn g(&self) -> u8 {
self.0[1]
}
#[inline(always)]
pub const fn b(&self) -> u8 {
self.0[2]
}
#[inline(always)]
pub const fn a(&self) -> u8 {
self.0[3]
}
/// Returns an opaque version of self
pub fn to_opaque(self) -> Self {
Rgba::from(self).to_opaque().into()
}
/// Returns an additive version of self
#[inline(always)]
pub const fn additive(self) -> Self {
let [r, g, b, _] = self.to_array();
Self([r, g, b, 0])
}
/// Premultiplied RGBA
#[inline(always)]
pub const fn to_array(&self) -> [u8; 4] {
[self.r(), self.g(), self.b(), self.a()]
}
/// Premultiplied RGBA
#[inline(always)]
pub const fn to_tuple(&self) -> (u8, u8, u8, u8) {
(self.r(), self.g(), self.b(), self.a())
}
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
Rgba::from(*self).to_srgba_unmultiplied()
}
/// Multiply with 0.5 to make color half as opaque, perceptually.
///
/// Fast multiplication in gamma-space.
///
/// This is perceptually even, and faster that [`Self::linear_multiply`].
#[inline]
pub fn gamma_multiply(self, factor: f32) -> Color32 {
crate::ecolor_assert!(0.0 <= factor && factor <= 1.0);
let Self([r, g, b, a]) = self;
Self([
(r as f32 * factor + 0.5) as u8,
(g as f32 * factor + 0.5) as u8,
(b as f32 * factor + 0.5) as u8,
(a as f32 * factor + 0.5) as u8,
])
}
/// Multiply with 0.5 to make color half as opaque in linear space.
///
/// This is using linear space, which is not perceptually even.
/// You may want to use [`Self::gamma_multiply`] instead.
pub fn linear_multiply(self, factor: f32) -> Color32 {
crate::ecolor_assert!(0.0 <= factor && factor <= 1.0);
// As an unfortunate side-effect of using premultiplied alpha
// we need a somewhat expensive conversion to linear space and back.
Rgba::from(self).multiply(factor).into()
}
/// Converts to floating point values in the range 0-1 without any gamma space conversion.
///
/// Use this with great care! In almost all cases, you want to convert to [`crate::Rgba`] instead
/// in order to obtain linear space color values.
#[inline]
pub fn to_normalized_gamma_f32(self) -> [f32; 4] {
let Self([r, g, b, a]) = self;
[
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
a as f32 / 255.0,
]
}
}

View file

@ -0,0 +1,39 @@
/// Construct a [`crate::Color32`] from a hex RGB or RGBA string.
///
/// ```
/// # use ecolor::{hex_color, Color32};
/// assert_eq!(hex_color!("#202122"), Color32::from_rgb(0x20, 0x21, 0x22));
/// assert_eq!(hex_color!("#abcdef12"), Color32::from_rgba_unmultiplied(0xab, 0xcd, 0xef, 0x12));
/// ```
#[macro_export]
macro_rules! hex_color {
($s:literal) => {{
let array = color_hex::color_from_hex!($s);
if array.len() == 3 {
$crate::Color32::from_rgb(array[0], array[1], array[2])
} else {
#[allow(unconditional_panic)]
$crate::Color32::from_rgba_unmultiplied(array[0], array[1], array[2], array[3])
}
}};
}
#[test]
fn test_from_rgb_hex() {
assert_eq!(
crate::Color32::from_rgb(0x20, 0x21, 0x22),
hex_color!("#202122")
);
assert_eq!(
crate::Color32::from_rgb_additive(0x20, 0x21, 0x22),
hex_color!("#202122").additive()
);
}
#[test]
fn test_from_rgba_hex() {
assert_eq!(
crate::Color32::from_rgba_unmultiplied(0x20, 0x21, 0x22, 0x50),
hex_color!("20212250")
);
}

View file

@ -0,0 +1,231 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32, Color32, Rgba,
};
/// Hue, saturation, value, alpha. All in the range [0, 1].
/// No premultiplied alpha.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Hsva {
/// hue 0-1
pub h: f32,
/// saturation 0-1
pub s: f32,
/// value 0-1
pub v: f32,
/// alpha 0-1. A negative value signifies an additive color (and alpha is ignored).
pub a: f32,
}
impl Hsva {
pub fn new(h: f32, s: f32, v: f32, a: f32) -> Self {
Self { h, s, v, a }
}
/// From `sRGBA` with premultiplied alpha
pub fn from_srgba_premultiplied(srgba: [u8; 4]) -> Self {
Self::from_rgba_premultiplied(
linear_f32_from_gamma_u8(srgba[0]),
linear_f32_from_gamma_u8(srgba[1]),
linear_f32_from_gamma_u8(srgba[2]),
linear_f32_from_linear_u8(srgba[3]),
)
}
/// From `sRGBA` without premultiplied alpha
pub fn from_srgba_unmultiplied(srgba: [u8; 4]) -> Self {
Self::from_rgba_unmultiplied(
linear_f32_from_gamma_u8(srgba[0]),
linear_f32_from_gamma_u8(srgba[1]),
linear_f32_from_gamma_u8(srgba[2]),
linear_f32_from_linear_u8(srgba[3]),
)
}
/// From linear RGBA with premultiplied alpha
pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
#![allow(clippy::many_single_char_names)]
if a == 0.0 {
if r == 0.0 && b == 0.0 && a == 0.0 {
Hsva::default()
} else {
Hsva::from_additive_rgb([r, g, b])
}
} else {
let (h, s, v) = hsv_from_rgb([r / a, g / a, b / a]);
Hsva { h, s, v, a }
}
}
/// From linear RGBA without premultiplied alpha
pub fn from_rgba_unmultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
#![allow(clippy::many_single_char_names)]
let (h, s, v) = hsv_from_rgb([r, g, b]);
Hsva { h, s, v, a }
}
pub fn from_additive_rgb(rgb: [f32; 3]) -> Self {
let (h, s, v) = hsv_from_rgb(rgb);
Hsva {
h,
s,
v,
a: -0.5, // anything negative is treated as additive
}
}
pub fn from_rgb(rgb: [f32; 3]) -> Self {
let (h, s, v) = hsv_from_rgb(rgb);
Hsva { h, s, v, a: 1.0 }
}
pub fn from_srgb([r, g, b]: [u8; 3]) -> Self {
Self::from_rgb([
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
])
}
// ------------------------------------------------------------------------
pub fn to_opaque(self) -> Self {
Self { a: 1.0, ..self }
}
pub fn to_rgb(&self) -> [f32; 3] {
rgb_from_hsv((self.h, self.s, self.v))
}
pub fn to_srgb(&self) -> [u8; 3] {
let [r, g, b] = self.to_rgb();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
]
}
pub fn to_rgba_premultiplied(&self) -> [f32; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied();
let additive = a < 0.0;
if additive {
[r, g, b, 0.0]
} else {
[a * r, a * g, a * b, a]
}
}
/// Represents additive colors using a negative alpha.
pub fn to_rgba_unmultiplied(&self) -> [f32; 4] {
let Hsva { h, s, v, a } = *self;
let [r, g, b] = rgb_from_hsv((h, s, v));
[r, g, b, a]
}
pub fn to_srgba_premultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_premultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a),
]
}
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a.abs()),
]
}
}
impl From<Hsva> for Rgba {
fn from(hsva: Hsva) -> Rgba {
Rgba(hsva.to_rgba_premultiplied())
}
}
impl From<Rgba> for Hsva {
fn from(rgba: Rgba) -> Hsva {
Self::from_rgba_premultiplied(rgba.0[0], rgba.0[1], rgba.0[2], rgba.0[3])
}
}
impl From<Hsva> for Color32 {
fn from(hsva: Hsva) -> Color32 {
Color32::from(Rgba::from(hsva))
}
}
impl From<Color32> for Hsva {
fn from(srgba: Color32) -> Hsva {
Hsva::from(Rgba::from(srgba))
}
}
/// All ranges in 0-1, rgb is linear.
pub fn hsv_from_rgb([r, g, b]: [f32; 3]) -> (f32, f32, f32) {
#![allow(clippy::many_single_char_names)]
let min = r.min(g.min(b));
let max = r.max(g.max(b)); // value
let range = max - min;
let h = if max == min {
0.0 // hue is undefined
} else if max == r {
(g - b) / (6.0 * range)
} else if max == g {
(b - r) / (6.0 * range) + 1.0 / 3.0
} else {
// max == b
(r - g) / (6.0 * range) + 2.0 / 3.0
};
let h = (h + 1.0).fract(); // wrap
let s = if max == 0.0 { 0.0 } else { 1.0 - min / max };
(h, s, max)
}
/// All ranges in 0-1, rgb is linear.
pub fn rgb_from_hsv((h, s, v): (f32, f32, f32)) -> [f32; 3] {
#![allow(clippy::many_single_char_names)]
let h = (h.fract() + 1.0).fract(); // wrap
let s = s.clamp(0.0, 1.0);
let f = h * 6.0 - (h * 6.0).floor();
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
match (h * 6.0).floor() as i32 % 6 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
5 => [v, p, q],
_ => unreachable!(),
}
}
#[test]
#[ignore] // a bit expensive
fn test_hsv_roundtrip() {
for r in 0..=255 {
for g in 0..=255 {
for b in 0..=255 {
let srgba = Color32::from_rgb(r, g, b);
let hsva = Hsva::from(srgba);
assert_eq!(srgba, Color32::from(hsva));
}
}
}
}

View file

@ -0,0 +1,66 @@
use crate::{gamma_from_linear, linear_from_gamma, Color32, Hsva, Rgba};
/// Like Hsva but with the `v` value (brightness) being gamma corrected
/// so that it is somewhat perceptually even.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HsvaGamma {
/// hue 0-1
pub h: f32,
/// saturation 0-1
pub s: f32,
/// value 0-1, in gamma-space (~perceptually even)
pub v: f32,
/// alpha 0-1. A negative value signifies an additive color (and alpha is ignored).
pub a: f32,
}
impl From<HsvaGamma> for Rgba {
fn from(hsvag: HsvaGamma) -> Rgba {
Hsva::from(hsvag).into()
}
}
impl From<HsvaGamma> for Color32 {
fn from(hsvag: HsvaGamma) -> Color32 {
Rgba::from(hsvag).into()
}
}
impl From<HsvaGamma> for Hsva {
fn from(hsvag: HsvaGamma) -> Hsva {
let HsvaGamma { h, s, v, a } = hsvag;
Hsva {
h,
s,
v: linear_from_gamma(v),
a,
}
}
}
impl From<Rgba> for HsvaGamma {
fn from(rgba: Rgba) -> HsvaGamma {
Hsva::from(rgba).into()
}
}
impl From<Color32> for HsvaGamma {
fn from(srgba: Color32) -> HsvaGamma {
Hsva::from(srgba).into()
}
}
impl From<Hsva> for HsvaGamma {
fn from(hsva: Hsva) -> HsvaGamma {
let Hsva { h, s, v, a } = hsva;
HsvaGamma {
h,
s,
v: gamma_from_linear(v),
a,
}
}
}

View file

@ -0,0 +1,173 @@
//! Color conversions and types.
//!
//! If you want a compact color representation, use [`Color32`].
//! If you want to manipulate RGBA colors use [`Rgba`].
//! If you want to manipulate colors in a way closer to how humans think about colors, use [`HsvaGamma`].
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
#![allow(clippy::wrong_self_convention)]
#[cfg(feature = "cint")]
mod cint_impl;
#[cfg(feature = "cint")]
pub use cint_impl::*;
mod color32;
pub use color32::*;
mod hsva_gamma;
pub use hsva_gamma::*;
mod hsva;
pub use hsva::*;
#[cfg(feature = "color-hex")]
mod hex_color_macro;
mod rgba;
pub use rgba::*;
// ----------------------------------------------------------------------------
// Color conversion:
impl From<Color32> for Rgba {
fn from(srgba: Color32) -> Rgba {
Rgba([
linear_f32_from_gamma_u8(srgba.0[0]),
linear_f32_from_gamma_u8(srgba.0[1]),
linear_f32_from_gamma_u8(srgba.0[2]),
linear_f32_from_linear_u8(srgba.0[3]),
])
}
}
impl From<Rgba> for Color32 {
fn from(rgba: Rgba) -> Color32 {
Color32([
gamma_u8_from_linear_f32(rgba.0[0]),
gamma_u8_from_linear_f32(rgba.0[1]),
gamma_u8_from_linear_f32(rgba.0[2]),
linear_u8_from_linear_f32(rgba.0[3]),
])
}
}
/// gamma [0, 255] -> linear [0, 1].
pub fn linear_f32_from_gamma_u8(s: u8) -> f32 {
if s <= 10 {
s as f32 / 3294.6
} else {
((s as f32 + 14.025) / 269.025).powf(2.4)
}
}
/// linear [0, 255] -> linear [0, 1].
/// Useful for alpha-channel.
#[inline(always)]
pub fn linear_f32_from_linear_u8(a: u8) -> f32 {
a as f32 / 255.0
}
/// linear [0, 1] -> gamma [0, 255] (clamped).
/// Values outside this range will be clamped to the range.
pub fn gamma_u8_from_linear_f32(l: f32) -> u8 {
if l <= 0.0 {
0
} else if l <= 0.0031308 {
fast_round(3294.6 * l)
} else if l <= 1.0 {
fast_round(269.025 * l.powf(1.0 / 2.4) - 14.025)
} else {
255
}
}
/// linear [0, 1] -> linear [0, 255] (clamped).
/// Useful for alpha-channel.
#[inline(always)]
pub fn linear_u8_from_linear_f32(a: f32) -> u8 {
fast_round(a * 255.0)
}
fn fast_round(r: f32) -> u8 {
(r + 0.5).floor() as _ // rust does a saturating cast since 1.45
}
#[test]
pub fn test_srgba_conversion() {
for b in 0..=255 {
let l = linear_f32_from_gamma_u8(b);
assert!(0.0 <= l && l <= 1.0);
assert_eq!(gamma_u8_from_linear_f32(l), b);
}
}
/// gamma [0, 1] -> linear [0, 1] (not clamped).
/// Works for numbers outside this range (e.g. negative numbers).
pub fn linear_from_gamma(gamma: f32) -> f32 {
if gamma < 0.0 {
-linear_from_gamma(-gamma)
} else if gamma <= 0.04045 {
gamma / 12.92
} else {
((gamma + 0.055) / 1.055).powf(2.4)
}
}
/// linear [0, 1] -> gamma [0, 1] (not clamped).
/// Works for numbers outside this range (e.g. negative numbers).
pub fn gamma_from_linear(linear: f32) -> f32 {
if linear < 0.0 {
-gamma_from_linear(-linear)
} else if linear <= 0.0031308 {
12.92 * linear
} else {
1.055 * linear.powf(1.0 / 2.4) - 0.055
}
}
// ----------------------------------------------------------------------------
/// An assert that is only active when `epaint` is compiled with the `extra_asserts` feature
/// or with the `extra_debug_asserts` feature in debug builds.
#[macro_export]
macro_rules! ecolor_assert {
($($arg: tt)*) => {
if cfg!(any(
feature = "extra_asserts",
all(feature = "extra_debug_asserts", debug_assertions),
)) {
assert!($($arg)*);
}
}
}
// ----------------------------------------------------------------------------
/// Cheap and ugly.
/// Made for graying out disabled `Ui`s.
pub fn tint_color_towards(color: Color32, target: Color32) -> Color32 {
let [mut r, mut g, mut b, mut a] = color.to_array();
if a == 0 {
r /= 2;
g /= 2;
b /= 2;
} else if a < 170 {
// Cheapish and looks ok.
// Works for e.g. grid stripes.
let div = (2 * 255 / a as i32) as u8;
r = r / 2 + target.r() / div;
g = g / 2 + target.g() / div;
b = b / 2 + target.b() / div;
a /= 2;
} else {
r = r / 2 + target.r() / 2;
g = g / 2 + target.g() / 2;
b = b / 2 + target.b() / 2;
}
Color32::from_rgba_premultiplied(r, g, b, a)
}

View file

@ -0,0 +1,266 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32,
};
/// 0-1 linear space `RGBA` color with premultiplied alpha.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))]
pub struct Rgba(pub(crate) [f32; 4]);
impl std::ops::Index<usize> for Rgba {
type Output = f32;
#[inline(always)]
fn index(&self, index: usize) -> &f32 {
&self.0[index]
}
}
impl std::ops::IndexMut<usize> for Rgba {
#[inline(always)]
fn index_mut(&mut self, index: usize) -> &mut f32 {
&mut self.0[index]
}
}
#[inline(always)]
pub(crate) fn f32_hash<H: std::hash::Hasher>(state: &mut H, f: f32) {
if f == 0.0 {
state.write_u8(0);
} else if f.is_nan() {
state.write_u8(1);
} else {
use std::hash::Hash;
f.to_bits().hash(state);
}
}
#[allow(clippy::derive_hash_xor_eq)]
impl std::hash::Hash for Rgba {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
crate::f32_hash(state, self.0[0]);
crate::f32_hash(state, self.0[1]);
crate::f32_hash(state, self.0[2]);
crate::f32_hash(state, self.0[3]);
}
}
impl Rgba {
pub const TRANSPARENT: Rgba = Rgba::from_rgba_premultiplied(0.0, 0.0, 0.0, 0.0);
pub const BLACK: Rgba = Rgba::from_rgb(0.0, 0.0, 0.0);
pub const WHITE: Rgba = Rgba::from_rgb(1.0, 1.0, 1.0);
pub const RED: Rgba = Rgba::from_rgb(1.0, 0.0, 0.0);
pub const GREEN: Rgba = Rgba::from_rgb(0.0, 1.0, 0.0);
pub const BLUE: Rgba = Rgba::from_rgb(0.0, 0.0, 1.0);
#[inline(always)]
pub const fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
Self([r, g, b, a])
}
#[inline(always)]
pub fn from_rgba_unmultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
Self([r * a, g * a, b * a, a])
}
#[inline(always)]
pub fn from_srgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r, g, b, a)
}
#[inline(always)]
pub fn from_srgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r * a, g * a, b * a, a)
}
#[inline(always)]
pub const fn from_rgb(r: f32, g: f32, b: f32) -> Self {
Self([r, g, b, 1.0])
}
#[inline(always)]
pub const fn from_gray(l: f32) -> Self {
Self([l, l, l, 1.0])
}
pub fn from_luminance_alpha(l: f32, a: f32) -> Self {
crate::ecolor_assert!(0.0 <= l && l <= 1.0);
crate::ecolor_assert!(0.0 <= a && a <= 1.0);
Self([l * a, l * a, l * a, a])
}
/// Transparent black
#[inline(always)]
pub fn from_black_alpha(a: f32) -> Self {
crate::ecolor_assert!(0.0 <= a && a <= 1.0);
Self([0.0, 0.0, 0.0, a])
}
/// Transparent white
#[inline(always)]
pub fn from_white_alpha(a: f32) -> Self {
crate::ecolor_assert!(0.0 <= a && a <= 1.0, "a: {}", a);
Self([a, a, a, a])
}
/// Return an additive version of this color (alpha = 0)
#[inline(always)]
pub fn additive(self) -> Self {
let [r, g, b, _] = self.0;
Self([r, g, b, 0.0])
}
/// Multiply with e.g. 0.5 to make us half transparent
#[inline(always)]
pub fn multiply(self, alpha: f32) -> Self {
Self([
alpha * self[0],
alpha * self[1],
alpha * self[2],
alpha * self[3],
])
}
#[inline(always)]
pub fn r(&self) -> f32 {
self.0[0]
}
#[inline(always)]
pub fn g(&self) -> f32 {
self.0[1]
}
#[inline(always)]
pub fn b(&self) -> f32 {
self.0[2]
}
#[inline(always)]
pub fn a(&self) -> f32 {
self.0[3]
}
/// How perceptually intense (bright) is the color?
#[inline]
pub fn intensity(&self) -> f32 {
0.3 * self.r() + 0.59 * self.g() + 0.11 * self.b()
}
/// Returns an opaque version of self
pub fn to_opaque(&self) -> Self {
if self.a() == 0.0 {
// Additive or fully transparent black.
Self::from_rgb(self.r(), self.g(), self.b())
} else {
// un-multiply alpha:
Self::from_rgb(
self.r() / self.a(),
self.g() / self.a(),
self.b() / self.a(),
)
}
}
/// Premultiplied RGBA
#[inline(always)]
pub fn to_array(&self) -> [f32; 4] {
[self.r(), self.g(), self.b(), self.a()]
}
/// Premultiplied RGBA
#[inline(always)]
pub fn to_tuple(&self) -> (f32, f32, f32, f32) {
(self.r(), self.g(), self.b(), self.a())
}
/// unmultiply the alpha
pub fn to_rgba_unmultiplied(&self) -> [f32; 4] {
let a = self.a();
if a == 0.0 {
// Additive, let's assume we are black
self.0
} else {
[self.r() / a, self.g() / a, self.b() / a, a]
}
}
/// unmultiply the alpha
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a.abs()),
]
}
}
impl std::ops::Add for Rgba {
type Output = Rgba;
#[inline(always)]
fn add(self, rhs: Rgba) -> Rgba {
Rgba([
self[0] + rhs[0],
self[1] + rhs[1],
self[2] + rhs[2],
self[3] + rhs[3],
])
}
}
impl std::ops::Mul<Rgba> for Rgba {
type Output = Rgba;
#[inline(always)]
fn mul(self, other: Rgba) -> Rgba {
Rgba([
self[0] * other[0],
self[1] * other[1],
self[2] * other[2],
self[3] * other[3],
])
}
}
impl std::ops::Mul<f32> for Rgba {
type Output = Rgba;
#[inline(always)]
fn mul(self, factor: f32) -> Rgba {
Rgba([
self[0] * factor,
self[1] * factor,
self[2] * factor,
self[3] * factor,
])
}
}
impl std::ops::Mul<Rgba> for f32 {
type Output = Rgba;
#[inline(always)]
fn mul(self, rgba: Rgba) -> Rgba {
Rgba([
self * rgba[0],
self * rgba[1],
self * rgba[2],
self * rgba[3],
])
}
}

View file

@ -0,0 +1,229 @@
# Changelog for eframe
All notable changes to the `eframe` crate.
NOTE: [`egui-winit`](../egui-winit/CHANGELOG.md), [`egui_glium`](../egui_glium/CHANGELOG.md), [`egui_glow`](../egui_glow/CHANGELOG.md),and [`egui-wgpu`](../egui-wgpu/CHANGELOG.md) have their own changelogs!
## Unreleased
## 0.21.3 - 2023-02-15
* Fix typing the letter 'P' on web ([#2740](https://github.com/emilk/egui/pull/2740)).
## 0.21.2 - 2023-02-12
* Allow compiling `eframe` with `--no-default-features` ([#2728](https://github.com/emilk/egui/pull/2728)).
## 0.21.1 - 2023-02-12
* Fixed crash when native window position is in an invalid state, which could happen e.g. due to changes in monitor size or DPI ([#2722](https://github.com/emilk/egui/issues/2722)).
## 0.21.0 - 2023-02-08 - Update to `winit` 0.28
* ⚠️ BREAKING: `App::clear_color` now expects you to return a raw float array ([#2666](https://github.com/emilk/egui/pull/2666)).
* The `screen_reader` feature has now been renamed `web_screen_reader` and only work on web. On other platforms, use the `accesskit` feature flag instead ([#2669](https://github.com/emilk/egui/pull/2669)).
#### Desktop/Native:
* `eframe::run_native` now returns a `Result` ([#2433](https://github.com/emilk/egui/pull/2433)).
* Update to `winit` 0.28, adding support for mac trackpad zoom ([#2654](https://github.com/emilk/egui/pull/2654)).
* Fix bug where the cursor could get stuck using the wrong icon.
* `NativeOptions::transparent` now works with the wgpu backend ([#2684](https://github.com/emilk/egui/pull/2684)).
* Add `Frame::set_minimized` and `set_maximized` ([#2292](https://github.com/emilk/egui/pull/2292), [#2672](https://github.com/emilk/egui/pull/2672)).
* Fixed persistence of native window position on Windows OS ([#2583](https://github.com/emilk/egui/issues/2583)).
#### Web:
* Prevent ctrl-P/cmd-P from opening the print dialog ([#2598](https://github.com/emilk/egui/pull/2598)).
## 0.20.1 - 2022-12-11
* Fix [docs.rs](https://docs.rs/eframe) build ([#2420](https://github.com/emilk/egui/pull/2420)).
## 0.20.0 - 2022-12-08 - AccessKit integration and `wgpu` web support
* MSRV (Minimum Supported Rust Version) is now `1.65.0` ([#2314](https://github.com/emilk/egui/pull/2314)).
* Allow empty textures with the glow renderer.
#### Desktop/Native:
* Don't repaint when just moving window ([#1980](https://github.com/emilk/egui/pull/1980)).
* Added `NativeOptions::event_loop_builder` hook for apps to change platform specific event loop options ([#1952](https://github.com/emilk/egui/pull/1952)).
* Enabled deferred render state initialization to support Android ([#1952](https://github.com/emilk/egui/pull/1952)).
* Added `shader_version` to `NativeOptions` for cross compiling support on different target OpenGL | ES versions (on native `glow` renderer only) ([#1993](https://github.com/emilk/egui/pull/1993)).
* Fix: app state is now saved when user presses Cmd-Q on Mac ([#2013](https://github.com/emilk/egui/pull/2013)).
* Added `center` to `NativeOptions` and `monitor_size` to `WindowInfo` on desktop ([#2035](https://github.com/emilk/egui/pull/2035)).
* Improve IME support ([#2046](https://github.com/emilk/egui/pull/2046)).
* Added mouse-passthrough option ([#2080](https://github.com/emilk/egui/pull/2080)).
* Added `NativeOptions::fullsize_content` option on Mac to build titlebar-less windows with floating window controls ([#2049](https://github.com/emilk/egui/pull/2049)).
* Wgpu device/adapter/surface creation has now various configuration options exposed via `NativeOptions/WebOptions::wgpu_options` ([#2207](https://github.com/emilk/egui/pull/2207)).
* Fix: Make sure that `native_pixels_per_point` is updated ([#2256](https://github.com/emilk/egui/pull/2256)).
* Added optional, but enabled by default, integration with [AccessKit](https://accesskit.dev/) for implementing platform accessibility APIs ([#2294](https://github.com/emilk/egui/pull/2294)).
* Fix: Less flickering on resize on Windows ([#2280](https://github.com/emilk/egui/pull/2280)).
#### Web:
* ⚠️ BREAKING: `start_web` is a now `async` ([#2107](https://github.com/emilk/egui/pull/2107)).
* Web: You can now use WebGL on top of `wgpu` by enabling the `wgpu` feature (and disabling `glow` via disabling default features) ([#2107](https://github.com/emilk/egui/pull/2107)).
* Web: Add `WebInfo::user_agent` ([#2202](https://github.com/emilk/egui/pull/2202)).
* Web: you can access your application from JS using `AppRunner::app_mut`. See `crates/egui_demo_app/src/lib.rs` ([#1886](https://github.com/emilk/egui/pull/1886)).
## 0.19.0 - 2022-08-20
* MSRV (Minimum Supported Rust Version) is now `1.61.0` ([#1846](https://github.com/emilk/egui/pull/1846)).
* Added `wgpu` rendering backed ([#1564](https://github.com/emilk/egui/pull/1564)):
* Added features `wgpu` and `glow`.
* Added `NativeOptions::renderer` to switch between the rendering backends.
* `egui_glow`: remove calls to `gl.get_error` in release builds to speed up rendering ([#1583](https://github.com/emilk/egui/pull/1583)).
* Added `App::post_rendering` for e.g. reading the framebuffer ([#1591](https://github.com/emilk/egui/pull/1591)).
* Use `Arc` for `glow::Context` instead of `Rc` ([#1640](https://github.com/emilk/egui/pull/1640)).
* Fixed bug where the result returned from `App::on_exit_event` would sometimes be ignored ([#1696](https://github.com/emilk/egui/pull/1696)).
* Added `NativeOptions::follow_system_theme` and `NativeOptions::default_theme` ([#1726](https://github.com/emilk/egui/pull/1726)).
* Selectively expose parts of the API based on target arch (`wasm32` or not) ([#1867](https://github.com/emilk/egui/pull/1867)).
#### Desktop/Native:
* Fixed clipboard on Wayland ([#1613](https://github.com/emilk/egui/pull/1613)).
* Added ability to read window position and size with `frame.info().window_info` ([#1617](https://github.com/emilk/egui/pull/1617)).
* Allow running on native without hardware accelerated rendering. Change with `NativeOptions::hardware_acceleration` ([#1681](https://github.com/emilk/egui/pull/1681), [#1693](https://github.com/emilk/egui/pull/1693)).
* Fixed window position persistence ([#1745](https://github.com/emilk/egui/pull/1745)).
* Fixed mouse cursor change on Linux ([#1747](https://github.com/emilk/egui/pull/1747)).
* Added `Frame::set_visible` ([#1808](https://github.com/emilk/egui/pull/1808)).
* Added fullscreen support ([#1866](https://github.com/emilk/egui/pull/1866)).
* You can now continue execution after closing the native desktop window ([#1889](https://github.com/emilk/egui/pull/1889)).
* `Frame::quit` has been renamed to `Frame::close` and `App::on_exit_event` is now `App::on_close_event` ([#1943](https://github.com/emilk/egui/pull/1943)).
#### Web:
* Added ability to stop/re-run web app from JavaScript. ⚠️ You need to update your CSS with `html, body: { height: 100%; width: 100%; }` ([#1803](https://github.com/emilk/egui/pull/1650)).
* Added `WebOptions::follow_system_theme` and `WebOptions::default_theme` ([#1726](https://github.com/emilk/egui/pull/1726)).
* Added option to select WebGL version ([#1803](https://github.com/emilk/egui/pull/1803)).
## 0.18.0 - 2022-04-30
* MSRV (Minimum Supported Rust Version) is now `1.60.0` ([#1467](https://github.com/emilk/egui/pull/1467)).
* Removed `eframe::epi` - everything is now in `eframe` (`eframe::App`, `eframe::Frame` etc) ([#1545](https://github.com/emilk/egui/pull/1545)).
* Removed `Frame::request_repaint` - just call `egui::Context::request_repaint` for the same effect ([#1366](https://github.com/emilk/egui/pull/1366)).
* Changed app creation/setup ([#1363](https://github.com/emilk/egui/pull/1363)):
* Removed `App::setup` and `App::name`.
* Provide `CreationContext` when creating app with egui context, storage, integration info and glow context.
* Change interface of `run_native` and `start_web`.
* Added `Frame::storage()` and `Frame::storage_mut()` ([#1418](https://github.com/emilk/egui/pull/1418)).
* You can now load/save state in `App::update`
* Changed `App::update` to take `&mut Frame` instead of `&Frame`.
* `Frame` is no longer `Clone` or `Sync`.
* Added `glow` (OpenGL) context to `Frame` ([#1425](https://github.com/emilk/egui/pull/1425)).
#### Desktop/Native:
* Remove the `egui_glium` feature. `eframe` will now always use `egui_glow` as the native backend ([#1357](https://github.com/emilk/egui/pull/1357)).
* Change default for `NativeOptions::drag_and_drop_support` to `true` ([#1329](https://github.com/emilk/egui/pull/1329)).
* Added new `NativeOptions`: `vsync`, `multisampling`, `depth_buffer`, `stencil_buffer`.
* `dark-light` (dark mode detection) is now an opt-in feature ([#1437](https://github.com/emilk/egui/pull/1437)).
* Fixed potential scale bug when DPI scaling changes (e.g. when dragging a window between different displays) ([#1441](https://github.com/emilk/egui/pull/1441)).
* Added new feature `puffin` to add [`puffin profiler`](https://github.com/EmbarkStudios/puffin) scopes ([#1483](https://github.com/emilk/egui/pull/1483)).
* Moved app persistence to a background thread, allowing for smoother frame rates (on native).
* Added `Frame::set_window_pos` ([#1505](https://github.com/emilk/egui/pull/1505)).
#### Web:
* Use full browser width by default ([#1378](https://github.com/emilk/egui/pull/1378)).
* egui code will no longer be called after panic ([#1306](https://github.com/emilk/egui/pull/1306)).
## 0.17.0 - 2022-02-22
* Removed `Frame::alloc_texture`. Use `egui::Context::load_texture` instead ([#1110](https://github.com/emilk/egui/pull/1110)).
* Shift-scroll will now result in horizontal scrolling on all platforms ([#1136](https://github.com/emilk/egui/pull/1136)).
* Log using the `tracing` crate. Log to stdout by adding `tracing_subscriber::fmt::init();` to your `main` ([#1192](https://github.com/emilk/egui/pull/1192)).
#### Desktop/Native:
* The default native backend is now `egui_glow` (instead of `egui_glium`) ([#1020](https://github.com/emilk/egui/pull/1020)).
* Automatically detect and apply dark or light mode from system ([#1045](https://github.com/emilk/egui/pull/1045)).
* Fixed horizontal scrolling direction on Linux.
* Added `App::on_exit_event` ([#1038](https://github.com/emilk/egui/pull/1038))
* Added `NativeOptions::initial_window_pos`.
* Fixed `enable_drag` for Windows OS ([#1108](https://github.com/emilk/egui/pull/1108)).
#### Web:
* The default web painter is now `egui_glow` (instead of WebGL) ([#1020](https://github.com/emilk/egui/pull/1020)).
* Fixed glow failure on Chromium ([#1092](https://github.com/emilk/egui/pull/1092)).
* Updated `eframe::IntegrationInfo::web_location_hash` on `hashchange` event ([#1140](https://github.com/emilk/egui/pull/1140)).
* Expose all parts of the location/url in `frame.info().web_info` ([#1258](https://github.com/emilk/egui/pull/1258)).
## 0.16.0 - 2021-12-29
* `Frame` can now be cloned, saved, and passed to background threads ([#999](https://github.com/emilk/egui/pull/999)).
* Added `Frame::request_repaint` to replace `repaint_signal` ([#999](https://github.com/emilk/egui/pull/999)).
* Added `Frame::alloc_texture/free_texture` to replace `tex_allocator` ([#999](https://github.com/emilk/egui/pull/999)).
#### Web:
* Fixed [dark rendering in WebKitGTK](https://github.com/emilk/egui/issues/794) ([#888](https://github.com/emilk/egui/pull/888/)).
* Added feature `glow` to switch to a [`glow`](https://github.com/grovesNL/glow) based painter ([#868](https://github.com/emilk/egui/pull/868)).
## 0.15.0 - 2021-10-24
* `Frame` now provides `set_window_title` to set window title dynamically ([#828](https://github.com/emilk/egui/pull/828)).
* `Frame` now provides `set_decorations` to set whether to show window decorations.
* Remove "http" feature (use https://github.com/emilk/ehttp instead!).
* Added `App::persist_native_window` and `App::persist_egui_memory` to control what gets persisted.
#### Desktop/Native:
* Increase native scroll speed.
* Added new backend `egui_glow` as an alternative to `egui_glium`. Enable with `default-features = false, features = ["default_fonts", "egui_glow"]`.
#### Web:
* Implement `eframe::NativeTexture` trait for the WebGL painter.
* Deprecate `Painter::register_webgl_texture.
* Fixed multiline paste.
* Fixed painting with non-opaque backgrounds.
* Improve text input on mobile and for IME.
## 0.14.0 - 2021-08-24
* Added dragging and dropping files into egui.
* Improve http fetch API.
* `run_native` now returns when the app is closed.
* Web: Made text thicker and less pixelated.
## 0.13.1 - 2021-06-24
* Fixed `http` feature flag and docs
## 0.13.0 - 2021-06-24
* `App::setup` now takes a `Frame` and `Storage` by argument.
* `App::load` has been removed. Implement `App::setup` instead.
* Web: Default to light visuals unless the system reports a preference for dark mode.
* Web: Improve alpha blending, making fonts look much better (especially in light mode)
* Web: Fix double-paste bug
## 0.12.0 - 2021-05-10
* Moved options out of `trait App` into new `NativeOptions`.
* Added option for `always_on_top`.
* Web: Scroll faster when scrolling with mouse wheel.
## 0.11.0 - 2021-04-05
* You can now turn your window transparent with the `App::transparent` option.
* You can now disable window decorations with the `App::decorated` option.
* Web: [Fix mobile and IME text input](https://github.com/emilk/egui/pull/253)
* Web: Hold down a modifier key when clicking a link to open it in a new tab.
Contributors: [n2](https://github.com/n2)
## 0.10.0 - 2021-02-28
* [You can now set your own app icons](https://github.com/emilk/egui/pull/193).
* You can control the initial size of the native window with `App::initial_window_size`.
* You can control the maximum egui web canvas size with `App::max_size_points`.
* `Frame::tex_allocator()` no longer returns an `Option` (there is always a texture allocator).
## 0.9.0 - 2021-02-07
* [Added support for HTTP body](https://github.com/emilk/egui/pull/139).
* Web: Right-clicks will no longer open browser context menu.
* Web: Fix a bug where one couldn't select items in a combo box on a touch screen.
## 0.8.0 - 2021-01-17
* Simplify `TextureAllocator` interface.
* WebGL2 is now supported, with improved texture sampler. WebGL1 will be used as a fallback.
* Web: Slightly improved alpha-blending (work-around for non-existing linear-space blending).
* Web: Call `prevent_default` for arrow keys when entering text
## 0.7.0 - 2021-01-04
* Initial release of `eframe`

View file

@ -0,0 +1,169 @@
[package]
name = "eframe"
version = "0.21.3"
authors = ["Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
description = "egui framework - write GUI apps that compiles to web and/or natively"
edition = "2021"
rust-version = "1.65"
homepage = "https://github.com/emilk/egui/tree/master/crates/eframe"
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/emilk/egui/tree/master/crates/eframe"
categories = ["gui", "game-development"]
keywords = ["egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[package.metadata.docs.rs]
all-features = true
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
[lib]
[features]
default = ["accesskit", "default_fonts", "glow"]
## Enable platform accessibility API implementations through [AccessKit](https://accesskit.dev/).
accesskit = ["egui/accesskit", "egui-winit/accesskit"]
## Detect dark mode system preference using [`dark-light`](https://docs.rs/dark-light).
##
## See also [`NativeOptions::follow_system_theme`] and [`NativeOptions::default_theme`].
dark-light = ["dep:dark-light"]
## If set, egui will use `include_bytes!` to bundle some fonts.
## If you plan on specifying your own fonts you may disable this feature.
default_fonts = ["egui/default_fonts"]
## Use [`glow`](https://github.com/grovesNL/glow) for painting, via [`egui_glow`](https://github.com/emilk/egui/tree/master/crates/egui_glow).
glow = ["dep:glow", "dep:egui_glow", "dep:glutin", "dep:glutin-winit"]
## Enable saving app state to disk.
persistence = [
"directories-next",
"egui-winit/serde",
"egui/persistence",
"ron",
"serde",
]
## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate.
##
## Only enabled on native, because of the low resolution (1ms) of time keeping in browsers.
## `eframe` will call `puffin::GlobalProfiler::lock().new_frame()` for you
puffin = ["dep:puffin", "egui_glow?/puffin", "egui-wgpu?/puffin"]
## Enable screen reader support (requires `ctx.options_mut(|o| o.screen_reader = true);`) on web.
##
## For other platforms, use the "accesskit" feature instead.
web_screen_reader = ["tts"]
## If set, eframe will look for the env-var `EFRAME_SCREENSHOT_TO` and write a screenshot to that location, and then quit.
## This is used to generate images for the examples.
__screenshot = ["dep:image"]
## Use [`wgpu`](https://docs.rs/wgpu) for painting (via [`egui-wgpu`](https://github.com/emilk/egui/tree/master/crates/egui-wgpu)).
## This overrides the `glow` feature.
wgpu = ["dep:wgpu", "dep:egui-wgpu", "dep:pollster"]
[dependencies]
egui = { version = "0.21.0", path = "../egui", default-features = false, features = [
"bytemuck",
"tracing",
] }
thiserror = "1.0.37"
tracing = { version = "0.1", default-features = false, features = ["std"] }
#! ### Optional dependencies
## Enable this when generating docs.
document-features = { version = "0.2", optional = true }
egui_glow = { version = "0.21.0", path = "../egui_glow", optional = true, default-features = false }
glow = { version = "0.12", optional = true }
ron = { version = "0.8", optional = true, features = ["integer128"] }
serde = { version = "1", optional = true, features = ["derive"] }
# -------------------------------------------
# native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
egui-winit = { version = "0.21.1", path = "../egui-winit", default-features = false, features = [
"clipboard",
"links",
] }
raw-window-handle = { version = "0.5.0" }
winit = "0.28.1"
# optional native:
dark-light = { version = "1.0", optional = true }
directories-next = { version = "2", optional = true }
egui-wgpu = { version = "0.21.0", path = "../egui-wgpu", optional = true, features = [
"winit",
] } # if wgpu is used, use it with winit
pollster = { version = "0.3", optional = true } # needed for wgpu
# we can expose these to user so that they can select which backends they want to enable to avoid compiling useless deps.
# this can be done at the same time we expose x11/wayland features of winit crate.
glutin = { version = "0.30", optional = true }
glutin-winit = { version = "0.3.0", optional = true }
image = { version = "0.24", optional = true, default-features = false, features = [
"png",
] }
puffin = { version = "0.14", optional = true }
wgpu = { version = "0.15.0", optional = true }
# -------------------------------------------
# web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
bytemuck = "1.7"
js-sys = "0.3"
percent-encoding = "2.1"
wasm-bindgen = "=0.2.84"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3.58", features = [
"BinaryType",
"Blob",
"Clipboard",
"ClipboardEvent",
"CompositionEvent",
"console",
"CssStyleDeclaration",
"DataTransfer",
"DataTransferItem",
"DataTransferItemList",
"Document",
"DomRect",
"DragEvent",
"Element",
"Event",
"EventListener",
"EventTarget",
"ExtSRgb",
"File",
"FileList",
"FocusEvent",
"HtmlCanvasElement",
"HtmlElement",
"HtmlInputElement",
"InputEvent",
"KeyboardEvent",
"Location",
"MediaQueryList",
"MouseEvent",
"Navigator",
"Performance",
"Storage",
"Touch",
"TouchEvent",
"TouchList",
"WebGl2RenderingContext",
"WebglDebugRendererInfo",
"WebGlRenderingContext",
"WheelEvent",
"Window",
] }
# optional web:
egui-wgpu = { version = "0.21.0", path = "../egui-wgpu", optional = true } # if wgpu is used, use it without (!) winit
tts = { version = "0.25", optional = true, default-features = false }
wgpu = { version = "0.15.0", optional = true, features = ["webgl"] }

View file

@ -0,0 +1,63 @@
# eframe: the [`egui`](https://github.com/emilk/egui) framework
[![Latest version](https://img.shields.io/crates/v/eframe.svg)](https://crates.io/crates/eframe)
[![Documentation](https://docs.rs/eframe/badge.svg)](https://docs.rs/eframe)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
`eframe` is the official framework library for writing apps using [`egui`](https://github.com/emilk/egui). The app can be compiled both to run natively (cross platform) or be compiled to a web app (using WASM).
To get started, see the [examples](https://github.com/emilk/egui/tree/master/examples).
To learn how to set up `eframe` for web and native, go to <https://github.com/emilk/eframe_template/> and follow the instructions there!
There is also a tutorial video at <https://www.youtube.com/watch?v=NtUkr_z7l84>.
For how to use `egui`, see [the egui docs](https://docs.rs/egui).
---
`eframe` uses [`egui_glow`](https://github.com/emilk/egui/tree/master/crates/egui_glow) for rendering, and on native it uses [`egui-winit`](https://github.com/emilk/egui/tree/master/crates/egui-winit).
To use on Linux, first run:
```
sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
```
You need to either use `edition = "2021"`, or set `resolver = "2"` in the `[workspace]` section of your to-level `Cargo.toml`. See [this link](https://doc.rust-lang.org/edition-guide/rust-2021/default-cargo-resolver.html) for more info.
You can opt-in to the using [`egui_wgpu`](https://github.com/emilk/egui/tree/master/crates/egui_wgpu) for rendering by enabling the `wgpu` feature and setting `NativeOptions::renderer` to `Renderer::Wgpu`.
## Alternatives
`eframe` is not the only way to write an app using `egui`! You can also try [`egui-miniquad`](https://github.com/not-fl3/egui-miniquad), [`bevy_egui`](https://github.com/mvlabat/bevy_egui), [`egui_sdl2_gl`](https://github.com/ArjunNair/egui_sdl2_gl), and others.
You can also use `egui_glow` and [`winit`](https://github.com/rust-windowing/winit) to build your own app as demonstrated in <https://github.com/emilk/egui/blob/master/crates/egui_glow/examples/pure_glow.rs>.
## Problems with running egui on the web
`eframe` uses WebGL (via [`glow`](https://crates.io/crates/glow)) and WASM, and almost nothing else from the web tech stack. This has some benefits, but also produces some challenges and serious downsides.
* Rendering: Getting pixel-perfect rendering right on the web is very difficult.
* Search: you cannot search an egui web page like you would a normal web page.
* Bringing up an on-screen keyboard on mobile: there is no JS function to do this, so `eframe` fakes it by adding some invisible DOM elements. It doesn't always work.
* Mobile text editing is not as good as for a normal web app.
* Accessibility: There is an experimental screen reader for `eframe`, but it has to be enabled explicitly. There is no JS function to ask "Does the user want a screen reader?" (and there should probably not be such a function, due to user tracking/integrity concerns).
* No integration with browser settings for colors and fonts.
In many ways, `eframe` is trying to make the browser do something it wasn't designed to do (though there are many things browser vendors could do to improve how well libraries like egui work).
The suggested use for `eframe` are for web apps where performance and responsiveness are more important than accessibility and mobile text editing.
## Companion crates
Not all rust crates work when compiled to WASM, but here are some useful crates have been designed to work well both natively and as WASM:
* Audio: [`cpal`](https://github.com/RustAudio/cpal).
* HTTP client: [`ehttp`](https://github.com/emilk/ehttp) and [`reqwest`](https://github.com/seanmonstar/reqwest).
* Time: [`chrono`](https://github.com/chronotope/chrono).
* WebSockets: [`ewebsock`](https://github.com/rerun-io/ewebsock).
## Name
The _frame_ in `eframe` stands both for the frame in which your `egui` app resides and also for "framework" (`frame` is a framework, `egui` is a library).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,262 @@
//! eframe - the [`egui`] framework crate
//!
//! If you are planning to write an app for web or native,
//! and want to use [`egui`] for everything, then `eframe` is for you!
//!
//! To get started, see the [examples](https://github.com/emilk/egui/tree/master/examples).
//! To learn how to set up `eframe` for web and native, go to <https://github.com/emilk/eframe_template/> and follow the instructions there!
//!
//! In short, you implement [`App`] (especially [`App::update`]) and then
//! call [`crate::run_native`] from your `main.rs`, and/or call `eframe::start_web` from your `lib.rs`.
//!
//! ## Usage, native:
//! ``` no_run
//! use eframe::egui;
//!
//! fn main() {
//! let native_options = eframe::NativeOptions::default();
//! eframe::run_native("My egui App", native_options, Box::new(|cc| Box::new(MyEguiApp::new(cc))));
//! }
//!
//! #[derive(Default)]
//! struct MyEguiApp {}
//!
//! impl MyEguiApp {
//! fn new(cc: &eframe::CreationContext<'_>) -> Self {
//! // Customize egui here with cc.egui_ctx.set_fonts and cc.egui_ctx.set_visuals.
//! // Restore app state using cc.storage (requires the "persistence" feature).
//! // Use the cc.gl (a glow::Context) to create graphics shaders and buffers that you can use
//! // for e.g. egui::PaintCallback.
//! Self::default()
//! }
//! }
//!
//! impl eframe::App for MyEguiApp {
//! fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
//! egui::CentralPanel::default().show(ctx, |ui| {
//! ui.heading("Hello World!");
//! });
//! }
//! }
//! ```
//!
//! ## Usage, web:
//! ``` no_run
//! #[cfg(target_arch = "wasm32")]
//! use wasm_bindgen::prelude::*;
//!
//! /// Call this once from the HTML.
//! #[cfg(target_arch = "wasm32")]
//! #[wasm_bindgen]
//! pub async fn start(canvas_id: &str) -> Result<AppRunnerRef, eframe::wasm_bindgen::JsValue> {
//! let web_options = eframe::WebOptions::default();
//! eframe::start_web(canvas_id, web_options, Box::new(|cc| Box::new(MyEguiApp::new(cc)))).await
//! }
//! ```
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
#![allow(clippy::needless_doctest_main)]
// Re-export all useful libraries:
pub use {egui, egui::emath, egui::epaint};
#[cfg(feature = "glow")]
pub use {egui_glow, glow};
#[cfg(feature = "wgpu")]
pub use {egui_wgpu, wgpu};
mod epi;
// Re-export everything in `epi` so `eframe` users don't have to care about what `epi` is:
pub use epi::*;
// ----------------------------------------------------------------------------
// When compiling for web
#[cfg(target_arch = "wasm32")]
pub mod web;
#[cfg(target_arch = "wasm32")]
pub use wasm_bindgen;
#[cfg(target_arch = "wasm32")]
use web::AppRunnerRef;
#[cfg(target_arch = "wasm32")]
pub use web_sys;
/// Install event listeners to register different input events
/// and start running the given app.
///
/// ``` no_run
/// #[cfg(target_arch = "wasm32")]
/// use wasm_bindgen::prelude::*;
///
/// /// This is the entry-point for all the web-assembly.
/// /// This is called from the HTML.
/// /// It loads the app, installs some callbacks, then returns.
/// /// It returns a handle to the running app that can be stopped calling `AppRunner::stop_web`.
/// /// You can add more callbacks like this if you want to call in to your code.
/// #[cfg(target_arch = "wasm32")]
/// #[wasm_bindgen]
/// pub struct WebHandle {
/// handle: AppRunnerRef,
/// }
/// #[cfg(target_arch = "wasm32")]
/// #[wasm_bindgen]
/// pub async fn start(canvas_id: &str) -> Result<WebHandle, eframe::wasm_bindgen::JsValue> {
/// let web_options = eframe::WebOptions::default();
/// eframe::start_web(
/// canvas_id,
/// web_options,
/// Box::new(|cc| Box::new(MyEguiApp::new(cc))),
/// )
/// .await
/// .map(|handle| WebHandle { handle })
/// }
/// ```
///
/// # Errors
/// Failing to initialize WebGL graphics.
#[cfg(target_arch = "wasm32")]
pub async fn start_web(
canvas_id: &str,
web_options: WebOptions,
app_creator: AppCreator,
) -> std::result::Result<AppRunnerRef, wasm_bindgen::JsValue> {
let handle = web::start(canvas_id, web_options, app_creator).await?;
Ok(handle)
}
// ----------------------------------------------------------------------------
// When compiling natively
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
mod native;
/// This is how you start a native (desktop) app.
///
/// The first argument is name of your app, used for the title bar of the native window
/// and the save location of persistence (see [`App::save`]).
///
/// Call from `fn main` like this:
/// ``` no_run
/// use eframe::egui;
///
/// fn main() {
/// let native_options = eframe::NativeOptions::default();
/// eframe::run_native("MyApp", native_options, Box::new(|cc| Box::new(MyEguiApp::new(cc))));
/// }
///
/// #[derive(Default)]
/// struct MyEguiApp {}
///
/// impl MyEguiApp {
/// fn new(cc: &eframe::CreationContext<'_>) -> Self {
/// // Customize egui here with cc.egui_ctx.set_fonts and cc.egui_ctx.set_visuals.
/// // Restore app state using cc.storage (requires the "persistence" feature).
/// // Use the cc.gl (a glow::Context) to create graphics shaders and buffers that you can use
/// // for e.g. egui::PaintCallback.
/// Self::default()
/// }
/// }
///
/// impl eframe::App for MyEguiApp {
/// fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
/// egui::CentralPanel::default().show(ctx, |ui| {
/// ui.heading("Hello World!");
/// });
/// }
/// }
/// ```
///
/// # Errors
/// This function can fail if we fail to set up a graphics context.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::needless_pass_by_value)]
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub fn run_native(
app_name: &str,
native_options: NativeOptions,
app_creator: AppCreator,
) -> Result<()> {
let renderer = native_options.renderer;
#[cfg(not(feature = "__screenshot"))]
assert!(
std::env::var("EFRAME_SCREENSHOT_TO").is_err(),
"EFRAME_SCREENSHOT_TO found without compiling with the '__screenshot' feature"
);
match renderer {
#[cfg(feature = "glow")]
Renderer::Glow => {
tracing::debug!("Using the glow renderer");
native::run::run_glow(app_name, native_options, app_creator)
}
#[cfg(feature = "wgpu")]
Renderer::Wgpu => {
tracing::debug!("Using the wgpu renderer");
native::run::run_wgpu(app_name, native_options, app_creator)
}
}
}
// ----------------------------------------------------------------------------
/// The different problems that can occur when trying to run `eframe`.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[cfg(not(target_arch = "wasm32"))]
#[error("winit error: {0}")]
Winit(#[from] winit::error::OsError),
#[cfg(all(feature = "glow", not(target_arch = "wasm32")))]
#[error("glutin error: {0}")]
Glutin(#[from] glutin::error::Error),
#[cfg(all(feature = "glow", not(target_arch = "wasm32")))]
#[error("Found no glutin configs matching the template: {0:?}. error: {1:?}")]
NoGlutinConfigs(glutin::config::ConfigTemplate, Box<dyn std::error::Error>),
#[cfg(feature = "wgpu")]
#[error("WGPU error: {0}")]
Wgpu(#[from] egui_wgpu::WgpuError),
}
pub type Result<T> = std::result::Result<T, Error>;
// ---------------------------------------------------------------------------
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
mod profiling_scopes {
/// Profiling macro for feature "puffin"
macro_rules! profile_function {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
puffin::profile_function!($($arg)*);
};
}
pub(crate) use profile_function;
/// Profiling macro for feature "puffin"
macro_rules! profile_scope {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
puffin::profile_scope!($($arg)*);
};
}
pub(crate) use profile_scope;
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub(crate) use profiling_scopes::*;

View file

@ -0,0 +1,568 @@
use winit::event_loop::EventLoopWindowTarget;
#[cfg(target_os = "macos")]
use winit::platform::macos::WindowBuilderExtMacOS as _;
#[cfg(feature = "accesskit")]
use egui::accesskit;
use egui::NumExt as _;
#[cfg(feature = "accesskit")]
use egui_winit::accesskit_winit;
use egui_winit::{native_pixels_per_point, EventResponse, WindowSettings};
use crate::{epi, Theme, WindowInfo};
#[derive(Default)]
pub struct WindowState {
// We cannot simply call `winit::Window::is_minimized/is_maximized`
// because that deadlocks on mac.
pub minimized: bool,
pub maximized: bool,
}
pub fn points_to_size(points: egui::Vec2) -> winit::dpi::LogicalSize<f64> {
winit::dpi::LogicalSize {
width: points.x as f64,
height: points.y as f64,
}
}
pub fn read_window_info(
window: &winit::window::Window,
pixels_per_point: f32,
window_state: &WindowState,
) -> WindowInfo {
let position = window
.outer_position()
.ok()
.map(|pos| pos.to_logical::<f32>(pixels_per_point.into()))
.map(|pos| egui::Pos2 { x: pos.x, y: pos.y });
let monitor = window.current_monitor().is_some();
let monitor_size = if monitor {
let size = window
.current_monitor()
.unwrap()
.size()
.to_logical::<f32>(pixels_per_point.into());
Some(egui::vec2(size.width, size.height))
} else {
None
};
let size = window
.inner_size()
.to_logical::<f32>(pixels_per_point.into());
// NOTE: calling window.is_minimized() or window.is_maximized() deadlocks on Mac.
WindowInfo {
position,
fullscreen: window.fullscreen().is_some(),
minimized: window_state.minimized,
maximized: window_state.maximized,
size: egui::Vec2 {
x: size.width,
y: size.height,
},
monitor_size,
}
}
pub fn window_builder<E>(
event_loop: &EventLoopWindowTarget<E>,
title: &str,
native_options: &epi::NativeOptions,
window_settings: Option<WindowSettings>,
) -> winit::window::WindowBuilder {
let epi::NativeOptions {
maximized,
decorated,
fullscreen,
#[cfg(target_os = "macos")]
fullsize_content,
drag_and_drop_support,
icon_data,
initial_window_pos,
initial_window_size,
min_window_size,
max_window_size,
resizable,
transparent,
centered,
..
} = native_options;
let window_icon = icon_data.clone().and_then(load_icon);
let mut window_builder = winit::window::WindowBuilder::new()
.with_title(title)
.with_decorations(*decorated)
.with_fullscreen(fullscreen.then(|| winit::window::Fullscreen::Borderless(None)))
.with_maximized(*maximized)
.with_resizable(*resizable)
.with_transparent(*transparent)
.with_window_icon(window_icon)
// Keep hidden until we've painted something. See https://github.com/emilk/egui/pull/2279
// We must also keep the window hidden until AccessKit is initialized.
.with_visible(false);
#[cfg(target_os = "macos")]
if *fullsize_content {
window_builder = window_builder
.with_title_hidden(true)
.with_titlebar_transparent(true)
.with_fullsize_content_view(true);
}
if let Some(min_size) = *min_window_size {
window_builder = window_builder.with_min_inner_size(points_to_size(min_size));
}
if let Some(max_size) = *max_window_size {
window_builder = window_builder.with_max_inner_size(points_to_size(max_size));
}
window_builder = window_builder_drag_and_drop(window_builder, *drag_and_drop_support);
let inner_size_points = if let Some(mut window_settings) = window_settings {
// Restore pos/size from previous session
window_settings.clamp_to_sane_values(largest_monitor_point_size(event_loop));
#[cfg(windows)]
window_settings.clamp_window_to_sane_position(&event_loop);
window_builder = window_settings.initialize_window(window_builder);
window_settings.inner_size_points()
} else {
if let Some(pos) = *initial_window_pos {
window_builder = window_builder.with_position(winit::dpi::LogicalPosition {
x: pos.x as f64,
y: pos.y as f64,
});
}
if let Some(initial_window_size) = *initial_window_size {
let initial_window_size =
initial_window_size.at_most(largest_monitor_point_size(event_loop));
window_builder = window_builder.with_inner_size(points_to_size(initial_window_size));
}
*initial_window_size
};
if *centered {
if let Some(monitor) = event_loop.available_monitors().next() {
let monitor_size = monitor.size().to_logical::<f64>(monitor.scale_factor());
let inner_size = inner_size_points.unwrap_or(egui::Vec2 { x: 800.0, y: 600.0 });
if monitor_size.width > 0.0 && monitor_size.height > 0.0 {
let x = (monitor_size.width - inner_size.x as f64) / 2.0;
let y = (monitor_size.height - inner_size.y as f64) / 2.0;
window_builder = window_builder.with_position(winit::dpi::LogicalPosition { x, y });
}
}
}
window_builder
}
pub fn apply_native_options_to_window(
window: &winit::window::Window,
native_options: &crate::NativeOptions,
) {
use winit::window::WindowLevel;
window.set_window_level(if native_options.always_on_top {
WindowLevel::AlwaysOnTop
} else {
WindowLevel::Normal
});
}
fn largest_monitor_point_size<E>(event_loop: &EventLoopWindowTarget<E>) -> egui::Vec2 {
let mut max_size = egui::Vec2::ZERO;
for monitor in event_loop.available_monitors() {
let size = monitor.size().to_logical::<f32>(monitor.scale_factor());
let size = egui::vec2(size.width, size.height);
max_size = max_size.max(size);
}
if max_size == egui::Vec2::ZERO {
egui::Vec2::splat(16000.0)
} else {
max_size
}
}
fn load_icon(icon_data: epi::IconData) -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(icon_data.rgba, icon_data.width, icon_data.height).ok()
}
#[cfg(target_os = "windows")]
fn window_builder_drag_and_drop(
window_builder: winit::window::WindowBuilder,
enable: bool,
) -> winit::window::WindowBuilder {
use winit::platform::windows::WindowBuilderExtWindows as _;
window_builder.with_drag_and_drop(enable)
}
#[cfg(not(target_os = "windows"))]
fn window_builder_drag_and_drop(
window_builder: winit::window::WindowBuilder,
_enable: bool,
) -> winit::window::WindowBuilder {
// drag and drop can only be disabled on windows
window_builder
}
pub fn handle_app_output(
window: &winit::window::Window,
current_pixels_per_point: f32,
app_output: epi::backend::AppOutput,
window_state: &mut WindowState,
) {
let epi::backend::AppOutput {
close: _,
window_size,
window_title,
decorated,
fullscreen,
drag_window,
window_pos,
visible: _, // handled in post_present
always_on_top,
minimized,
maximized,
} = app_output;
if let Some(decorated) = decorated {
window.set_decorations(decorated);
}
if let Some(window_size) = window_size {
window.set_inner_size(
winit::dpi::PhysicalSize {
width: (current_pixels_per_point * window_size.x).round(),
height: (current_pixels_per_point * window_size.y).round(),
}
.to_logical::<f32>(native_pixels_per_point(window) as f64),
);
}
if let Some(fullscreen) = fullscreen {
window.set_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)));
}
if let Some(window_title) = window_title {
window.set_title(&window_title);
}
if let Some(window_pos) = window_pos {
window.set_outer_position(winit::dpi::LogicalPosition {
x: window_pos.x as f64,
y: window_pos.y as f64,
});
}
if drag_window {
let _ = window.drag_window();
}
if let Some(always_on_top) = always_on_top {
use winit::window::WindowLevel;
window.set_window_level(if always_on_top {
WindowLevel::AlwaysOnTop
} else {
WindowLevel::Normal
});
}
if let Some(minimized) = minimized {
window.set_minimized(minimized);
window_state.minimized = minimized;
}
if let Some(maximized) = maximized {
window.set_maximized(maximized);
window_state.maximized = maximized;
}
}
// ----------------------------------------------------------------------------
/// For loading/saving app state and/or egui memory to disk.
pub fn create_storage(_app_name: &str) -> Option<Box<dyn epi::Storage>> {
#[cfg(feature = "persistence")]
if let Some(storage) = super::file_storage::FileStorage::from_app_name(_app_name) {
return Some(Box::new(storage));
}
None
}
// ----------------------------------------------------------------------------
/// Everything needed to make a winit-based integration for [`epi`].
pub struct EpiIntegration {
pub frame: epi::Frame,
last_auto_save: std::time::Instant,
pub egui_ctx: egui::Context,
pending_full_output: egui::FullOutput,
egui_winit: egui_winit::State,
/// When set, it is time to close the native window.
close: bool,
can_drag_window: bool,
window_state: WindowState,
}
impl EpiIntegration {
pub fn new<E>(
event_loop: &EventLoopWindowTarget<E>,
max_texture_side: usize,
window: &winit::window::Window,
system_theme: Option<Theme>,
storage: Option<Box<dyn epi::Storage>>,
#[cfg(feature = "glow")] gl: Option<std::sync::Arc<glow::Context>>,
#[cfg(feature = "wgpu")] wgpu_render_state: Option<egui_wgpu::RenderState>,
) -> Self {
let egui_ctx = egui::Context::default();
let memory = load_egui_memory(storage.as_deref()).unwrap_or_default();
egui_ctx.memory_mut(|mem| *mem = memory);
let native_pixels_per_point = window.scale_factor() as f32;
let window_state = WindowState {
minimized: window.is_minimized().unwrap_or(false),
maximized: window.is_maximized(),
};
let frame = epi::Frame {
info: epi::IntegrationInfo {
system_theme,
cpu_usage: None,
native_pixels_per_point: Some(native_pixels_per_point),
window_info: read_window_info(window, egui_ctx.pixels_per_point(), &window_state),
},
output: epi::backend::AppOutput {
visible: Some(true),
..Default::default()
},
storage,
#[cfg(feature = "glow")]
gl,
#[cfg(feature = "wgpu")]
wgpu_render_state,
};
let mut egui_winit = egui_winit::State::new(event_loop);
egui_winit.set_max_texture_side(max_texture_side);
egui_winit.set_pixels_per_point(native_pixels_per_point);
Self {
frame,
last_auto_save: std::time::Instant::now(),
egui_ctx,
egui_winit,
pending_full_output: Default::default(),
close: false,
can_drag_window: false,
window_state,
}
}
#[cfg(feature = "accesskit")]
pub fn init_accesskit<E: From<accesskit_winit::ActionRequestEvent> + Send>(
&mut self,
window: &winit::window::Window,
event_loop_proxy: winit::event_loop::EventLoopProxy<E>,
) {
let egui_ctx = self.egui_ctx.clone();
self.egui_winit
.init_accesskit(window, event_loop_proxy, move || {
// This function is called when an accessibility client
// (e.g. screen reader) makes its first request. If we got here,
// we know that an accessibility tree is actually wanted.
egui_ctx.enable_accesskit();
// Enqueue a repaint so we'll receive a full tree update soon.
egui_ctx.request_repaint();
egui_ctx.accesskit_placeholder_tree_update()
});
}
pub fn warm_up(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) {
crate::profile_function!();
let saved_memory: egui::Memory = self.egui_ctx.memory(|mem| mem.clone());
self.egui_ctx
.memory_mut(|mem| mem.set_everything_is_visible(true));
let full_output = self.update(app, window);
self.pending_full_output.append(full_output); // Handle it next frame
self.egui_ctx.memory_mut(|mem| *mem = saved_memory); // We don't want to remember that windows were huge.
self.egui_ctx.clear_animations();
}
/// If `true`, it is time to close the native window.
pub fn should_close(&self) -> bool {
self.close
}
pub fn on_event(
&mut self,
app: &mut dyn epi::App,
event: &winit::event::WindowEvent<'_>,
) -> EventResponse {
use winit::event::{ElementState, MouseButton, WindowEvent};
match event {
WindowEvent::CloseRequested => {
tracing::debug!("Received WindowEvent::CloseRequested");
self.close = app.on_close_event();
tracing::debug!("App::on_close_event returned {}", self.close);
}
WindowEvent::Destroyed => {
tracing::debug!("Received WindowEvent::Destroyed");
self.close = true;
}
WindowEvent::MouseInput {
button: MouseButton::Left,
state: ElementState::Pressed,
..
} => self.can_drag_window = true,
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.frame.info.native_pixels_per_point = Some(*scale_factor as _);
}
_ => {}
}
self.egui_winit.on_event(&self.egui_ctx, event)
}
#[cfg(feature = "accesskit")]
pub fn on_accesskit_action_request(&mut self, request: accesskit::ActionRequest) {
self.egui_winit.on_accesskit_action_request(request);
}
pub fn update(
&mut self,
app: &mut dyn epi::App,
window: &winit::window::Window,
) -> egui::FullOutput {
let frame_start = std::time::Instant::now();
self.frame.info.window_info =
read_window_info(window, self.egui_ctx.pixels_per_point(), &self.window_state);
let raw_input = self.egui_winit.take_egui_input(window);
// Run user code:
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
crate::profile_scope!("App::update");
app.update(egui_ctx, &mut self.frame);
});
self.pending_full_output.append(full_output);
let full_output = std::mem::take(&mut self.pending_full_output);
{
let mut app_output = self.frame.take_app_output();
app_output.drag_window &= self.can_drag_window; // Necessary on Windows; see https://github.com/emilk/egui/pull/1108
self.can_drag_window = false;
if app_output.close {
self.close = app.on_close_event();
tracing::debug!("App::on_close_event returned {}", self.close);
}
self.frame.output.visible = app_output.visible; // this is handled by post_present
handle_app_output(
window,
self.egui_ctx.pixels_per_point(),
app_output,
&mut self.window_state,
);
}
let frame_time = frame_start.elapsed().as_secs_f64() as f32;
self.frame.info.cpu_usage = Some(frame_time);
full_output
}
pub fn post_rendering(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) {
let inner_size = window.inner_size();
let window_size_px = [inner_size.width, inner_size.height];
app.post_rendering(window_size_px, &self.frame);
}
pub fn post_present(&mut self, window: &winit::window::Window) {
if let Some(visible) = self.frame.output.visible.take() {
window.set_visible(visible);
}
}
pub fn handle_platform_output(
&mut self,
window: &winit::window::Window,
platform_output: egui::PlatformOutput,
) {
self.egui_winit
.handle_platform_output(window, &self.egui_ctx, platform_output);
}
// ------------------------------------------------------------------------
// Persistance stuff:
pub fn maybe_autosave(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) {
let now = std::time::Instant::now();
if now - self.last_auto_save > app.auto_save_interval() {
self.save(app, window);
self.last_auto_save = now;
}
}
pub fn save(&mut self, _app: &mut dyn epi::App, _window: &winit::window::Window) {
#[cfg(feature = "persistence")]
if let Some(storage) = self.frame.storage_mut() {
crate::profile_function!();
if _app.persist_native_window() {
crate::profile_scope!("native_window");
epi::set_value(
storage,
STORAGE_WINDOW_KEY,
&WindowSettings::from_display(_window),
);
}
if _app.persist_egui_memory() {
crate::profile_scope!("egui_memory");
self.egui_ctx
.memory(|mem| epi::set_value(storage, STORAGE_EGUI_MEMORY_KEY, mem));
}
{
crate::profile_scope!("App::save");
_app.save(storage);
}
crate::profile_scope!("Storage::flush");
storage.flush();
}
}
}
#[cfg(feature = "persistence")]
const STORAGE_EGUI_MEMORY_KEY: &str = "egui";
#[cfg(feature = "persistence")]
const STORAGE_WINDOW_KEY: &str = "window";
pub fn load_window_settings(_storage: Option<&dyn epi::Storage>) -> Option<WindowSettings> {
#[cfg(feature = "persistence")]
{
epi::get_value(_storage?, STORAGE_WINDOW_KEY)
}
#[cfg(not(feature = "persistence"))]
None
}
pub fn load_egui_memory(_storage: Option<&dyn epi::Storage>) -> Option<egui::Memory> {
#[cfg(feature = "persistence")]
{
epi::get_value(_storage?, STORAGE_EGUI_MEMORY_KEY)
}
#[cfg(not(feature = "persistence"))]
None
}

View file

@ -0,0 +1,117 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
// ----------------------------------------------------------------------------
/// A key-value store backed by a [RON](https://github.com/ron-rs/ron) file on disk.
/// Used to restore egui state, glium window position/size and app state.
pub struct FileStorage {
ron_filepath: PathBuf,
kv: HashMap<String, String>,
dirty: bool,
last_save_join_handle: Option<std::thread::JoinHandle<()>>,
}
impl Drop for FileStorage {
fn drop(&mut self) {
if let Some(join_handle) = self.last_save_join_handle.take() {
join_handle.join().ok();
}
}
}
impl FileStorage {
/// Store the state in this .ron file.
pub fn from_ron_filepath(ron_filepath: impl Into<PathBuf>) -> Self {
let ron_filepath: PathBuf = ron_filepath.into();
tracing::debug!("Loading app state from {:?}…", ron_filepath);
Self {
kv: read_ron(&ron_filepath).unwrap_or_default(),
ron_filepath,
dirty: false,
last_save_join_handle: None,
}
}
/// Find a good place to put the files that the OS likes.
pub fn from_app_name(app_name: &str) -> Option<Self> {
if let Some(proj_dirs) = directories_next::ProjectDirs::from("", "", app_name) {
let data_dir = proj_dirs.data_dir().to_path_buf();
if let Err(err) = std::fs::create_dir_all(&data_dir) {
tracing::warn!(
"Saving disabled: Failed to create app path at {:?}: {}",
data_dir,
err
);
None
} else {
Some(Self::from_ron_filepath(data_dir.join("app.ron")))
}
} else {
tracing::warn!("Saving disabled: Failed to find path to data_dir.");
None
}
}
}
impl crate::Storage for FileStorage {
fn get_string(&self, key: &str) -> Option<String> {
self.kv.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
if self.kv.get(key) != Some(&value) {
self.kv.insert(key.to_owned(), value);
self.dirty = true;
}
}
fn flush(&mut self) {
if self.dirty {
self.dirty = false;
let file_path = self.ron_filepath.clone();
let kv = self.kv.clone();
if let Some(join_handle) = self.last_save_join_handle.take() {
// wait for previous save to complete.
join_handle.join().ok();
}
let join_handle = std::thread::spawn(move || {
let file = std::fs::File::create(&file_path).unwrap();
let config = Default::default();
ron::ser::to_writer_pretty(file, &kv, config).unwrap();
tracing::trace!("Persisted to {:?}", file_path);
});
self.last_save_join_handle = Some(join_handle);
}
}
}
// ----------------------------------------------------------------------------
fn read_ron<T>(ron_path: impl AsRef<Path>) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
match std::fs::File::open(ron_path) {
Ok(file) => {
let reader = std::io::BufReader::new(file);
match ron::de::from_reader(reader) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!("Failed to parse RON: {}", err);
None
}
}
}
Err(_err) => {
// File probably doesn't exist. That's fine.
None
}
}
}

View file

@ -0,0 +1,6 @@
mod epi_integration;
pub mod run;
/// File storage which can be used by native backends.
#[cfg(feature = "persistence")]
pub mod file_storage;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,585 @@
use egui::{
mutex::{Mutex, MutexGuard},
TexturesDelta,
};
use crate::{epi, App};
use super::{web_painter::WebPainter, *};
// ----------------------------------------------------------------------------
/// Data gathered between frames.
#[derive(Default)]
pub struct WebInput {
/// Required because we don't get a position on touched
pub latest_touch_pos: Option<egui::Pos2>,
/// Required to maintain a stable touch position for multi-touch gestures.
pub latest_touch_pos_id: Option<egui::TouchId>,
pub raw: egui::RawInput,
}
impl WebInput {
pub fn new_frame(&mut self, canvas_size: egui::Vec2) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Default::default(), canvas_size)),
pixels_per_point: Some(native_pixels_per_point()), // We ALWAYS use the native pixels-per-point
time: Some(now_sec()),
..self.raw.take()
}
}
}
// ----------------------------------------------------------------------------
use std::sync::atomic::Ordering::SeqCst;
/// Stores when to do the next repaint.
pub struct NeedRepaint(Mutex<f64>);
impl Default for NeedRepaint {
fn default() -> Self {
Self(Mutex::new(f64::NEG_INFINITY)) // start with a repaint
}
}
impl NeedRepaint {
/// Returns the time (in [`now_sec`] scale) when
/// we should next repaint.
pub fn when_to_repaint(&self) -> f64 {
*self.0.lock()
}
/// Unschedule repainting.
pub fn clear(&self) {
*self.0.lock() = f64::INFINITY;
}
pub fn repaint_after(&self, num_seconds: f64) {
let mut repaint_time = self.0.lock();
*repaint_time = repaint_time.min(now_sec() + num_seconds);
}
pub fn repaint_asap(&self) {
*self.0.lock() = f64::NEG_INFINITY;
}
}
pub struct IsDestroyed(std::sync::atomic::AtomicBool);
impl Default for IsDestroyed {
fn default() -> Self {
Self(false.into())
}
}
impl IsDestroyed {
pub fn fetch(&self) -> bool {
self.0.load(SeqCst)
}
pub fn set_true(&self) {
self.0.store(true, SeqCst);
}
}
// ----------------------------------------------------------------------------
fn user_agent() -> Option<String> {
web_sys::window()?.navigator().user_agent().ok()
}
fn web_location() -> epi::Location {
let location = web_sys::window().unwrap().location();
let hash = percent_decode(&location.hash().unwrap_or_default());
let query = location
.search()
.unwrap_or_default()
.strip_prefix('?')
.map(percent_decode)
.unwrap_or_default();
let query_map = parse_query_map(&query)
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
epi::Location {
url: percent_decode(&location.href().unwrap_or_default()),
protocol: percent_decode(&location.protocol().unwrap_or_default()),
host: percent_decode(&location.host().unwrap_or_default()),
hostname: percent_decode(&location.hostname().unwrap_or_default()),
port: percent_decode(&location.port().unwrap_or_default()),
hash,
query,
query_map,
origin: percent_decode(&location.origin().unwrap_or_default()),
}
}
fn parse_query_map(query: &str) -> BTreeMap<&str, &str> {
query
.split('&')
.filter_map(|pair| {
if pair.is_empty() {
None
} else {
Some(if let Some((key, value)) = pair.split_once('=') {
(key, value)
} else {
(pair, "")
})
}
})
.collect()
}
#[test]
fn test_parse_query() {
assert_eq!(parse_query_map(""), BTreeMap::default());
assert_eq!(parse_query_map("foo"), BTreeMap::from_iter([("foo", "")]));
assert_eq!(
parse_query_map("foo=bar"),
BTreeMap::from_iter([("foo", "bar")])
);
assert_eq!(
parse_query_map("foo=bar&baz=42"),
BTreeMap::from_iter([("foo", "bar"), ("baz", "42")])
);
assert_eq!(
parse_query_map("foo&baz=42"),
BTreeMap::from_iter([("foo", ""), ("baz", "42")])
);
assert_eq!(
parse_query_map("foo&baz&&"),
BTreeMap::from_iter([("foo", ""), ("baz", "")])
);
}
// ----------------------------------------------------------------------------
pub struct AppRunner {
pub(crate) frame: epi::Frame,
egui_ctx: egui::Context,
painter: ActiveWebPainter,
pub(crate) input: WebInput,
app: Box<dyn epi::App>,
pub(crate) needs_repaint: std::sync::Arc<NeedRepaint>,
pub(crate) is_destroyed: std::sync::Arc<IsDestroyed>,
last_save_time: f64,
screen_reader: super::screen_reader::ScreenReader,
pub(crate) text_cursor_pos: Option<egui::Pos2>,
pub(crate) mutable_text_under_cursor: bool,
textures_delta: TexturesDelta,
pub events_to_unsubscribe: Vec<EventToUnsubscribe>,
}
impl Drop for AppRunner {
fn drop(&mut self) {
tracing::debug!("AppRunner has fully dropped");
}
}
impl AppRunner {
/// # Errors
/// Failure to initialize WebGL renderer.
pub async fn new(
canvas_id: &str,
web_options: crate::WebOptions,
app_creator: epi::AppCreator,
) -> Result<Self, String> {
let painter = ActiveWebPainter::new(canvas_id, &web_options).await?;
let system_theme = if web_options.follow_system_theme {
super::system_theme()
} else {
None
};
let info = epi::IntegrationInfo {
web_info: epi::WebInfo {
user_agent: user_agent().unwrap_or_default(),
location: web_location(),
},
system_theme,
cpu_usage: None,
native_pixels_per_point: Some(native_pixels_per_point()),
};
let storage = LocalStorage::default();
let egui_ctx = egui::Context::default();
egui_ctx.set_os(egui::os::OperatingSystem::from_user_agent(
&user_agent().unwrap_or_default(),
));
load_memory(&egui_ctx);
let theme = system_theme.unwrap_or(web_options.default_theme);
egui_ctx.set_visuals(theme.egui_visuals());
let app = app_creator(&epi::CreationContext {
egui_ctx: egui_ctx.clone(),
integration_info: info.clone(),
storage: Some(&storage),
#[cfg(feature = "glow")]
gl: Some(painter.gl().clone()),
#[cfg(all(feature = "wgpu", not(feature = "glow")))]
wgpu_render_state: painter.render_state(),
#[cfg(all(feature = "wgpu", feature = "glow"))]
wgpu_render_state: None,
});
let frame = epi::Frame {
info,
output: Default::default(),
storage: Some(Box::new(storage)),
#[cfg(feature = "glow")]
gl: Some(painter.gl().clone()),
#[cfg(all(feature = "wgpu", not(feature = "glow")))]
wgpu_render_state: painter.render_state(),
#[cfg(all(feature = "wgpu", feature = "glow"))]
wgpu_render_state: None,
};
let needs_repaint: std::sync::Arc<NeedRepaint> = Default::default();
{
let needs_repaint = needs_repaint.clone();
egui_ctx.set_request_repaint_callback(move || {
needs_repaint.repaint_asap();
});
}
let mut runner = Self {
frame,
egui_ctx,
painter,
input: Default::default(),
app,
needs_repaint,
is_destroyed: Default::default(),
last_save_time: now_sec(),
screen_reader: Default::default(),
text_cursor_pos: None,
mutable_text_under_cursor: false,
textures_delta: Default::default(),
events_to_unsubscribe: Default::default(),
};
runner.input.raw.max_texture_side = Some(runner.painter.max_texture_side());
Ok(runner)
}
pub fn egui_ctx(&self) -> &egui::Context {
&self.egui_ctx
}
/// Get mutable access to the concrete [`App`] we enclose.
///
/// This will panic if your app does not implement [`App::as_any_mut`].
pub fn app_mut<ConreteApp: 'static + App>(&mut self) -> &mut ConreteApp {
self.app
.as_any_mut()
.expect("Your app must implement `as_any_mut`, but it doesn't")
.downcast_mut::<ConreteApp>()
.unwrap()
}
pub fn auto_save(&mut self) {
let now = now_sec();
let time_since_last_save = now - self.last_save_time;
if time_since_last_save > self.app.auto_save_interval().as_secs_f64() {
if self.app.persist_egui_memory() {
save_memory(&self.egui_ctx);
}
if let Some(storage) = self.frame.storage_mut() {
self.app.save(storage);
}
self.last_save_time = now;
}
}
pub fn canvas_id(&self) -> &str {
self.painter.canvas_id()
}
pub fn warm_up(&mut self) -> Result<(), JsValue> {
if self.app.warm_up_enabled() {
let saved_memory: egui::Memory = self.egui_ctx.memory(|m| m.clone());
self.egui_ctx
.memory_mut(|m| m.set_everything_is_visible(true));
self.logic()?;
self.egui_ctx.memory_mut(|m| *m = saved_memory); // We don't want to remember that windows were huge.
self.egui_ctx.clear_animations();
}
Ok(())
}
pub fn destroy(&mut self) -> Result<(), JsValue> {
let is_destroyed_already = self.is_destroyed.fetch();
if is_destroyed_already {
tracing::warn!("App was destroyed already");
Ok(())
} else {
tracing::debug!("Destroying");
for x in self.events_to_unsubscribe.drain(..) {
x.unsubscribe()?;
}
self.painter.destroy();
self.is_destroyed.set_true();
Ok(())
}
}
/// Returns how long to wait until the next repaint.
///
/// Call [`Self::paint`] later to paint
pub fn logic(&mut self) -> Result<(std::time::Duration, Vec<egui::ClippedPrimitive>), JsValue> {
let frame_start = now_sec();
resize_canvas_to_screen_size(self.canvas_id(), self.app.max_size_points());
let canvas_size = canvas_size_in_points(self.canvas_id());
let raw_input = self.input.new_frame(canvas_size);
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
self.app.update(egui_ctx, &mut self.frame);
});
let egui::FullOutput {
platform_output,
repaint_after,
textures_delta,
shapes,
} = full_output;
self.handle_platform_output(platform_output);
self.textures_delta.append(textures_delta);
let clipped_primitives = self.egui_ctx.tessellate(shapes);
{
let app_output = self.frame.take_app_output();
let epi::backend::AppOutput {} = app_output;
}
self.frame.info.cpu_usage = Some((now_sec() - frame_start) as f32);
Ok((repaint_after, clipped_primitives))
}
/// Paint the results of the last call to [`Self::logic`].
pub fn paint(&mut self, clipped_primitives: &[egui::ClippedPrimitive]) -> Result<(), JsValue> {
let textures_delta = std::mem::take(&mut self.textures_delta);
self.painter.paint_and_update_textures(
self.app.clear_color(&self.egui_ctx.style().visuals),
clipped_primitives,
self.egui_ctx.pixels_per_point(),
&textures_delta,
)?;
Ok(())
}
fn handle_platform_output(&mut self, platform_output: egui::PlatformOutput) {
if self.egui_ctx.options(|o| o.screen_reader) {
self.screen_reader
.speak(&platform_output.events_description());
}
let egui::PlatformOutput {
cursor_icon,
open_url,
copied_text,
events: _, // already handled
mutable_text_under_cursor,
text_cursor_pos,
#[cfg(feature = "accesskit")]
accesskit_update: _, // not currently implemented
} = platform_output;
set_cursor_icon(cursor_icon);
if let Some(open) = open_url {
super::open_url(&open.url, open.new_tab);
}
#[cfg(web_sys_unstable_apis)]
if !copied_text.is_empty() {
set_clipboard_text(&copied_text);
}
#[cfg(not(web_sys_unstable_apis))]
let _ = copied_text;
self.mutable_text_under_cursor = mutable_text_under_cursor;
if self.text_cursor_pos != text_cursor_pos {
text_agent::move_text_cursor(text_cursor_pos, self.canvas_id());
self.text_cursor_pos = text_cursor_pos;
}
}
}
// ----------------------------------------------------------------------------
pub type AppRunnerRef = Arc<Mutex<AppRunner>>;
pub struct TargetEvent {
target: EventTarget,
event_name: String,
closure: Closure<dyn FnMut(web_sys::Event)>,
}
pub struct IntervalHandle {
pub handle: i32,
pub closure: Closure<dyn FnMut()>,
}
pub enum EventToUnsubscribe {
TargetEvent(TargetEvent),
#[allow(dead_code)]
IntervalHandle(IntervalHandle),
}
impl EventToUnsubscribe {
pub fn unsubscribe(self) -> Result<(), JsValue> {
match self {
EventToUnsubscribe::TargetEvent(handle) => {
handle.target.remove_event_listener_with_callback(
handle.event_name.as_str(),
handle.closure.as_ref().unchecked_ref(),
)?;
Ok(())
}
EventToUnsubscribe::IntervalHandle(handle) => {
let window = web_sys::window().unwrap();
window.clear_interval_with_handle(handle.handle);
Ok(())
}
}
}
}
pub struct AppRunnerContainer {
pub runner: AppRunnerRef,
/// Set to `true` if there is a panic.
/// Used to ignore callbacks after a panic.
pub panicked: Arc<AtomicBool>,
pub events: Vec<EventToUnsubscribe>,
}
impl AppRunnerContainer {
/// Convenience function to reduce boilerplate and ensure that all event handlers
/// are dealt with in the same way
pub fn add_event_listener<E: wasm_bindgen::JsCast>(
&mut self,
target: &EventTarget,
event_name: &'static str,
mut closure: impl FnMut(E, MutexGuard<'_, AppRunner>) + 'static,
) -> Result<(), JsValue> {
// Create a JS closure based on the FnMut provided
let closure = Closure::wrap({
// Clone atomics
let runner_ref = self.runner.clone();
let panicked = self.panicked.clone();
Box::new(move |event: web_sys::Event| {
// Only call the wrapped closure if the egui code has not panicked
if !panicked.load(Ordering::SeqCst) {
// Cast the event to the expected event type
let event = event.unchecked_into::<E>();
closure(event, runner_ref.lock());
}
}) as Box<dyn FnMut(web_sys::Event)>
});
// Add the event listener to the target
target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?;
let handle = TargetEvent {
target: target.clone(),
event_name: event_name.to_owned(),
closure,
};
self.events.push(EventToUnsubscribe::TargetEvent(handle));
Ok(())
}
}
// ----------------------------------------------------------------------------
/// Install event listeners to register different input events
/// and start running the given app.
pub async fn start(
canvas_id: &str,
web_options: crate::WebOptions,
app_creator: epi::AppCreator,
) -> Result<AppRunnerRef, JsValue> {
#[cfg(not(web_sys_unstable_apis))]
tracing::warn!(
"eframe compiled without RUSTFLAGS='--cfg=web_sys_unstable_apis'. Copying text won't work."
);
let mut runner = AppRunner::new(canvas_id, web_options, app_creator).await?;
runner.warm_up()?;
start_runner(runner)
}
/// Install event listeners to register different input events
/// and starts running the given [`AppRunner`].
fn start_runner(app_runner: AppRunner) -> Result<AppRunnerRef, JsValue> {
let mut runner_container = AppRunnerContainer {
runner: Arc::new(Mutex::new(app_runner)),
panicked: Arc::new(AtomicBool::new(false)),
events: Vec::with_capacity(20),
};
super::events::install_canvas_events(&mut runner_container)?;
super::events::install_document_events(&mut runner_container)?;
text_agent::install_text_agent(&mut runner_container)?;
super::events::paint_and_schedule(&runner_container.runner, runner_container.panicked.clone())?;
// Disable all event handlers on panic
let previous_hook = std::panic::take_hook();
runner_container.runner.lock().events_to_unsubscribe = runner_container.events;
std::panic::set_hook(Box::new(move |panic_info| {
tracing::info!("egui disabled all event handlers due to panic");
runner_container.panicked.store(true, SeqCst);
// Propagate panic info to the previously registered panic hook
previous_hook(panic_info);
}));
Ok(runner_container.runner)
}
// ----------------------------------------------------------------------------
#[derive(Default)]
struct LocalStorage {}
impl epi::Storage for LocalStorage {
fn get_string(&self, key: &str) -> Option<String> {
local_storage_get(key)
}
fn set_string(&mut self, key: &str, value: String) {
local_storage_set(key, &value);
}
fn flush(&mut self) {}
}

View file

@ -0,0 +1,538 @@
use std::sync::atomic::{AtomicBool, Ordering};
use egui::Key;
use super::*;
struct IsDestroyed(pub bool);
pub fn paint_and_schedule(
runner_ref: &AppRunnerRef,
panicked: Arc<AtomicBool>,
) -> Result<(), JsValue> {
fn paint_if_needed(runner_ref: &AppRunnerRef) -> Result<IsDestroyed, JsValue> {
let mut runner_lock = runner_ref.lock();
let is_destroyed = runner_lock.is_destroyed.fetch();
if !is_destroyed && runner_lock.needs_repaint.when_to_repaint() <= now_sec() {
runner_lock.needs_repaint.clear();
let (repaint_after, clipped_primitives) = runner_lock.logic()?;
runner_lock.paint(&clipped_primitives)?;
runner_lock
.needs_repaint
.repaint_after(repaint_after.as_secs_f64());
runner_lock.auto_save();
}
Ok(IsDestroyed(is_destroyed))
}
fn request_animation_frame(
runner_ref: AppRunnerRef,
panicked: Arc<AtomicBool>,
) -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
let closure = Closure::once(move || paint_and_schedule(&runner_ref, panicked));
window.request_animation_frame(closure.as_ref().unchecked_ref())?;
closure.forget(); // We must forget it, or else the callback is canceled on drop
Ok(())
}
// Only paint and schedule if there has been no panic
if !panicked.load(Ordering::SeqCst) {
let is_destroyed = paint_if_needed(runner_ref)?;
if !is_destroyed.0 {
request_animation_frame(runner_ref.clone(), panicked)?;
}
}
Ok(())
}
pub fn install_document_events(runner_container: &mut AppRunnerContainer) -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
runner_container.add_event_listener(
&document,
"keydown",
|event: web_sys::KeyboardEvent, mut runner_lock| {
if event.is_composing() || event.key_code() == 229 {
// https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
return;
}
let modifiers = modifiers_from_event(&event);
runner_lock.input.raw.modifiers = modifiers;
let key = event.key();
let egui_key = translate_key(&key);
if let Some(key) = egui_key {
runner_lock.input.raw.events.push(egui::Event::Key {
key,
pressed: true,
repeat: false, // egui will fill this in for us!
modifiers,
});
}
if !modifiers.ctrl
&& !modifiers.command
&& !should_ignore_key(&key)
// When text agent is shown, it sends text event instead.
&& text_agent::text_agent().hidden()
{
runner_lock.input.raw.events.push(egui::Event::Text(key));
}
runner_lock.needs_repaint.repaint_asap();
let egui_wants_keyboard = runner_lock.egui_ctx().wants_keyboard_input();
#[allow(clippy::if_same_then_else)]
let prevent_default = if egui_key == Some(Key::Tab) {
// Always prevent moving cursor to url bar.
// egui wants to use tab to move to the next text field.
true
} else if egui_key == Some(Key::P) {
#[allow(clippy::needless_bool)]
if modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
true // Prevent ctrl-P opening the print dialog. Users may want to use it for a command palette.
} else {
false // let normal P:s through
}
} else if egui_wants_keyboard {
matches!(
event.key().as_str(),
"Backspace" // so we don't go back to previous page when deleting text
| "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp" // cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58)
)
} else {
// We never want to prevent:
// * F5 / cmd-R (refresh)
// * cmd-shift-C (debug tools)
// * cmd/ctrl-c/v/x (or we stop copy/past/cut events)
false
};
// tracing::debug!(
// "On key-down {:?}, egui_wants_keyboard: {}, prevent_default: {}",
// event.key().as_str(),
// egui_wants_keyboard,
// prevent_default
// );
if prevent_default {
event.prevent_default();
// event.stop_propagation();
}
},
)?;
runner_container.add_event_listener(
&document,
"keyup",
|event: web_sys::KeyboardEvent, mut runner_lock| {
let modifiers = modifiers_from_event(&event);
runner_lock.input.raw.modifiers = modifiers;
if let Some(key) = translate_key(&event.key()) {
runner_lock.input.raw.events.push(egui::Event::Key {
key,
pressed: false,
repeat: false,
modifiers,
});
}
runner_lock.needs_repaint.repaint_asap();
},
)?;
#[cfg(web_sys_unstable_apis)]
runner_container.add_event_listener(
&document,
"paste",
|event: web_sys::ClipboardEvent, mut runner_lock| {
if let Some(data) = event.clipboard_data() {
if let Ok(text) = data.get_data("text") {
let text = text.replace("\r\n", "\n");
if !text.is_empty() {
runner_lock.input.raw.events.push(egui::Event::Paste(text));
runner_lock.needs_repaint.repaint_asap();
}
event.stop_propagation();
event.prevent_default();
}
}
},
)?;
#[cfg(web_sys_unstable_apis)]
runner_container.add_event_listener(
&document,
"cut",
|_: web_sys::ClipboardEvent, mut runner_lock| {
runner_lock.input.raw.events.push(egui::Event::Cut);
runner_lock.needs_repaint.repaint_asap();
},
)?;
#[cfg(web_sys_unstable_apis)]
runner_container.add_event_listener(
&document,
"copy",
|_: web_sys::ClipboardEvent, mut runner_lock| {
runner_lock.input.raw.events.push(egui::Event::Copy);
runner_lock.needs_repaint.repaint_asap();
},
)?;
for event_name in &["load", "pagehide", "pageshow", "resize"] {
runner_container.add_event_listener(
&window,
event_name,
|_: web_sys::Event, runner_lock| {
runner_lock.needs_repaint.repaint_asap();
},
)?;
}
runner_container.add_event_listener(
&window,
"hashchange",
|_: web_sys::Event, mut runner_lock| {
// `epi::Frame::info(&self)` clones `epi::IntegrationInfo`, but we need to modify the original here
runner_lock.frame.info.web_info.location.hash = location_hash();
},
)?;
Ok(())
}
pub fn install_canvas_events(runner_container: &mut AppRunnerContainer) -> Result<(), JsValue> {
let canvas = canvas_element(runner_container.runner.lock().canvas_id()).unwrap();
let prevent_default_events = [
// By default, right-clicks open a context menu.
// We don't want to do that (right clicks is handled by egui):
"contextmenu",
// Allow users to use ctrl-p for e.g. a command palette
"afterprint",
];
for event_name in prevent_default_events {
let closure =
move |event: web_sys::MouseEvent,
mut _runner_lock: egui::mutex::MutexGuard<'_, AppRunner>| {
event.prevent_default();
// event.stop_propagation();
// tracing::debug!("Preventing event {:?}", event_name);
};
runner_container.add_event_listener(&canvas, event_name, closure)?;
}
runner_container.add_event_listener(
&canvas,
"mousedown",
|event: web_sys::MouseEvent, mut runner_lock: egui::mutex::MutexGuard<'_, AppRunner>| {
if let Some(button) = button_from_mouse_event(&event) {
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
let modifiers = runner_lock.input.raw.modifiers;
runner_lock
.input
.raw
.events
.push(egui::Event::PointerButton {
pos,
button,
pressed: true,
modifiers,
});
runner_lock.needs_repaint.repaint_asap();
}
event.stop_propagation();
// Note: prevent_default breaks VSCode tab focusing, hence why we don't call it here.
},
)?;
runner_container.add_event_listener(
&canvas,
"mousemove",
|event: web_sys::MouseEvent, mut runner_lock| {
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
runner_lock
.input
.raw
.events
.push(egui::Event::PointerMoved(pos));
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"mouseup",
|event: web_sys::MouseEvent, mut runner_lock| {
if let Some(button) = button_from_mouse_event(&event) {
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
let modifiers = runner_lock.input.raw.modifiers;
runner_lock
.input
.raw
.events
.push(egui::Event::PointerButton {
pos,
button,
pressed: false,
modifiers,
});
runner_lock.needs_repaint.repaint_asap();
text_agent::update_text_agent(runner_lock);
}
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"mouseleave",
|event: web_sys::MouseEvent, mut runner_lock| {
runner_lock.input.raw.events.push(egui::Event::PointerGone);
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"touchstart",
|event: web_sys::TouchEvent, mut runner_lock| {
let mut latest_touch_pos_id = runner_lock.input.latest_touch_pos_id;
let pos =
pos_from_touch_event(runner_lock.canvas_id(), &event, &mut latest_touch_pos_id);
runner_lock.input.latest_touch_pos_id = latest_touch_pos_id;
runner_lock.input.latest_touch_pos = Some(pos);
let modifiers = runner_lock.input.raw.modifiers;
runner_lock
.input
.raw
.events
.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers,
});
push_touches(&mut runner_lock, egui::TouchPhase::Start, &event);
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"touchmove",
|event: web_sys::TouchEvent, mut runner_lock| {
let mut latest_touch_pos_id = runner_lock.input.latest_touch_pos_id;
let pos =
pos_from_touch_event(runner_lock.canvas_id(), &event, &mut latest_touch_pos_id);
runner_lock.input.latest_touch_pos_id = latest_touch_pos_id;
runner_lock.input.latest_touch_pos = Some(pos);
runner_lock
.input
.raw
.events
.push(egui::Event::PointerMoved(pos));
push_touches(&mut runner_lock, egui::TouchPhase::Move, &event);
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"touchend",
|event: web_sys::TouchEvent, mut runner_lock| {
if let Some(pos) = runner_lock.input.latest_touch_pos {
let modifiers = runner_lock.input.raw.modifiers;
// First release mouse to click:
runner_lock
.input
.raw
.events
.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers,
});
// Then remove hover effect:
runner_lock.input.raw.events.push(egui::Event::PointerGone);
push_touches(&mut runner_lock, egui::TouchPhase::End, &event);
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
}
// Finally, focus or blur text agent to toggle mobile keyboard:
text_agent::update_text_agent(runner_lock);
},
)?;
runner_container.add_event_listener(
&canvas,
"touchcancel",
|event: web_sys::TouchEvent, mut runner_lock| {
push_touches(&mut runner_lock, egui::TouchPhase::Cancel, &event);
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"wheel",
|event: web_sys::WheelEvent, mut runner_lock| {
let scroll_multiplier = match event.delta_mode() {
web_sys::WheelEvent::DOM_DELTA_PAGE => {
canvas_size_in_points(runner_lock.canvas_id()).y
}
web_sys::WheelEvent::DOM_DELTA_LINE => {
#[allow(clippy::let_and_return)]
let points_per_scroll_line = 8.0; // Note that this is intentionally different from what we use in winit.
points_per_scroll_line
}
_ => 1.0, // DOM_DELTA_PIXEL
};
let mut delta =
-scroll_multiplier * egui::vec2(event.delta_x() as f32, event.delta_y() as f32);
// Report a zoom event in case CTRL (on Windows or Linux) or CMD (on Mac) is pressed.
// This if-statement is equivalent to how `Modifiers.command` is determined in
// `modifiers_from_event()`, but we cannot directly use that fn for a [`WheelEvent`].
if event.ctrl_key() || event.meta_key() {
let factor = (delta.y / 200.0).exp();
runner_lock.input.raw.events.push(egui::Event::Zoom(factor));
} else {
if event.shift_key() {
// Treat as horizontal scrolling.
// Note: one Mac we already get horizontal scroll events when shift is down.
delta = egui::vec2(delta.x + delta.y, 0.0);
}
runner_lock
.input
.raw
.events
.push(egui::Event::Scroll(delta));
}
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(
&canvas,
"dragover",
|event: web_sys::DragEvent, mut runner_lock| {
if let Some(data_transfer) = event.data_transfer() {
runner_lock.input.raw.hovered_files.clear();
for i in 0..data_transfer.items().length() {
if let Some(item) = data_transfer.items().get(i) {
runner_lock.input.raw.hovered_files.push(egui::HoveredFile {
mime: item.type_(),
..Default::default()
});
}
}
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
}
},
)?;
runner_container.add_event_listener(
&canvas,
"dragleave",
|event: web_sys::DragEvent, mut runner_lock| {
runner_lock.input.raw.hovered_files.clear();
runner_lock.needs_repaint.repaint_asap();
event.stop_propagation();
event.prevent_default();
},
)?;
runner_container.add_event_listener(&canvas, "drop", {
let runner_ref = runner_container.runner.clone();
move |event: web_sys::DragEvent, mut runner_lock| {
if let Some(data_transfer) = event.data_transfer() {
runner_lock.input.raw.hovered_files.clear();
runner_lock.needs_repaint.repaint_asap();
// Unlock the runner so it can be locked after a future await point
drop(runner_lock);
if let Some(files) = data_transfer.files() {
for i in 0..files.length() {
if let Some(file) = files.get(i) {
let name = file.name();
let last_modified = std::time::UNIX_EPOCH
+ std::time::Duration::from_millis(file.last_modified() as u64);
tracing::debug!("Loading {:?} ({} bytes)…", name, file.size());
let future = wasm_bindgen_futures::JsFuture::from(file.array_buffer());
let runner_ref = runner_ref.clone();
let future = async move {
match future.await {
Ok(array_buffer) => {
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
tracing::debug!(
"Loaded {:?} ({} bytes).",
name,
bytes.len()
);
// Re-lock the mutex on the other side of the await point
let mut runner_lock = runner_ref.lock();
runner_lock.input.raw.dropped_files.push(
egui::DroppedFile {
name,
last_modified: Some(last_modified),
bytes: Some(bytes.into()),
..Default::default()
},
);
runner_lock.needs_repaint.repaint_asap();
}
Err(err) => {
tracing::error!("Failed to read file: {:?}", err);
}
}
};
wasm_bindgen_futures::spawn_local(future);
}
}
}
event.stop_propagation();
event.prevent_default();
}
}
})?;
Ok(())
}

View file

@ -0,0 +1,217 @@
use super::{canvas_element, canvas_origin, AppRunner};
pub fn pos_from_mouse_event(canvas_id: &str, event: &web_sys::MouseEvent) -> egui::Pos2 {
let canvas = canvas_element(canvas_id).unwrap();
let rect = canvas.get_bounding_client_rect();
egui::Pos2 {
x: event.client_x() as f32 - rect.left() as f32,
y: event.client_y() as f32 - rect.top() as f32,
}
}
pub fn button_from_mouse_event(event: &web_sys::MouseEvent) -> Option<egui::PointerButton> {
match event.button() {
0 => Some(egui::PointerButton::Primary),
1 => Some(egui::PointerButton::Middle),
2 => Some(egui::PointerButton::Secondary),
3 => Some(egui::PointerButton::Extra1),
4 => Some(egui::PointerButton::Extra2),
_ => None,
}
}
/// A single touch is translated to a pointer movement. When a second touch is added, the pointer
/// should not jump to a different position. Therefore, we do not calculate the average position
/// of all touches, but we keep using the same touch as long as it is available.
///
/// `touch_id_for_pos` is the [`TouchId`](egui::TouchId) of the [`Touch`](web_sys::Touch) we previously used to determine the
/// pointer position.
pub fn pos_from_touch_event(
canvas_id: &str,
event: &web_sys::TouchEvent,
touch_id_for_pos: &mut Option<egui::TouchId>,
) -> egui::Pos2 {
let touch_for_pos = if let Some(touch_id_for_pos) = touch_id_for_pos {
// search for the touch we previously used for the position
// (unfortunately, `event.touches()` is not a rust collection):
(0..event.touches().length())
.into_iter()
.map(|i| event.touches().get(i).unwrap())
.find(|touch| egui::TouchId::from(touch.identifier()) == *touch_id_for_pos)
} else {
None
};
// Use the touch found above or pick the first, or return a default position if there is no
// touch at all. (The latter is not expected as the current method is only called when there is
// at least one touch.)
touch_for_pos
.or_else(|| event.touches().get(0))
.map_or(Default::default(), |touch| {
*touch_id_for_pos = Some(egui::TouchId::from(touch.identifier()));
pos_from_touch(canvas_origin(canvas_id), &touch)
})
}
fn pos_from_touch(canvas_origin: egui::Pos2, touch: &web_sys::Touch) -> egui::Pos2 {
egui::Pos2 {
x: touch.page_x() as f32 - canvas_origin.x,
y: touch.page_y() as f32 - canvas_origin.y,
}
}
pub fn push_touches(runner: &mut AppRunner, phase: egui::TouchPhase, event: &web_sys::TouchEvent) {
let canvas_origin = canvas_origin(runner.canvas_id());
for touch_idx in 0..event.changed_touches().length() {
if let Some(touch) = event.changed_touches().item(touch_idx) {
runner.input.raw.events.push(egui::Event::Touch {
device_id: egui::TouchDeviceId(0),
id: egui::TouchId::from(touch.identifier()),
phase,
pos: pos_from_touch(canvas_origin, &touch),
force: touch.force(),
});
}
}
}
/// Web sends all keys as strings, so it is up to us to figure out if it is
/// a real text input or the name of a key.
pub fn should_ignore_key(key: &str) -> bool {
let is_function_key = key.starts_with('F') && key.len() > 1;
is_function_key
|| matches!(
key,
"Alt"
| "ArrowDown"
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "Backspace"
| "CapsLock"
| "ContextMenu"
| "Control"
| "Delete"
| "End"
| "Enter"
| "Esc"
| "Escape"
| "GroupNext" // https://github.com/emilk/egui/issues/510
| "Help"
| "Home"
| "Insert"
| "Meta"
| "NumLock"
| "PageDown"
| "PageUp"
| "Pause"
| "ScrollLock"
| "Shift"
| "Tab"
)
}
/// Web sends all all keys as strings, so it is up to us to figure out if it is
/// a real text input or the name of a key.
pub fn translate_key(key: &str) -> Option<egui::Key> {
use egui::Key;
match key {
"ArrowDown" => Some(Key::ArrowDown),
"ArrowLeft" => Some(Key::ArrowLeft),
"ArrowRight" => Some(Key::ArrowRight),
"ArrowUp" => Some(Key::ArrowUp),
"Esc" | "Escape" => Some(Key::Escape),
"Tab" => Some(Key::Tab),
"Backspace" => Some(Key::Backspace),
"Enter" => Some(Key::Enter),
"Space" | " " => Some(Key::Space),
"Help" | "Insert" => Some(Key::Insert),
"Delete" => Some(Key::Delete),
"Home" => Some(Key::Home),
"End" => Some(Key::End),
"PageUp" => Some(Key::PageUp),
"PageDown" => Some(Key::PageDown),
"-" => Some(Key::Minus),
"+" | "=" => Some(Key::PlusEquals),
"0" => Some(Key::Num0),
"1" => Some(Key::Num1),
"2" => Some(Key::Num2),
"3" => Some(Key::Num3),
"4" => Some(Key::Num4),
"5" => Some(Key::Num5),
"6" => Some(Key::Num6),
"7" => Some(Key::Num7),
"8" => Some(Key::Num8),
"9" => Some(Key::Num9),
"a" | "A" => Some(Key::A),
"b" | "B" => Some(Key::B),
"c" | "C" => Some(Key::C),
"d" | "D" => Some(Key::D),
"e" | "E" => Some(Key::E),
"f" | "F" => Some(Key::F),
"g" | "G" => Some(Key::G),
"h" | "H" => Some(Key::H),
"i" | "I" => Some(Key::I),
"j" | "J" => Some(Key::J),
"k" | "K" => Some(Key::K),
"l" | "L" => Some(Key::L),
"m" | "M" => Some(Key::M),
"n" | "N" => Some(Key::N),
"o" | "O" => Some(Key::O),
"p" | "P" => Some(Key::P),
"q" | "Q" => Some(Key::Q),
"r" | "R" => Some(Key::R),
"s" | "S" => Some(Key::S),
"t" | "T" => Some(Key::T),
"u" | "U" => Some(Key::U),
"v" | "V" => Some(Key::V),
"w" | "W" => Some(Key::W),
"x" | "X" => Some(Key::X),
"y" | "Y" => Some(Key::Y),
"z" | "Z" => Some(Key::Z),
"F1" => Some(Key::F1),
"F2" => Some(Key::F2),
"F3" => Some(Key::F3),
"F4" => Some(Key::F4),
"F5" => Some(Key::F5),
"F6" => Some(Key::F6),
"F7" => Some(Key::F7),
"F8" => Some(Key::F8),
"F9" => Some(Key::F9),
"F10" => Some(Key::F10),
"F11" => Some(Key::F11),
"F12" => Some(Key::F12),
"F13" => Some(Key::F13),
"F14" => Some(Key::F14),
"F15" => Some(Key::F15),
"F16" => Some(Key::F16),
"F17" => Some(Key::F17),
"F18" => Some(Key::F18),
"F19" => Some(Key::F19),
"F20" => Some(Key::F20),
_ => None,
}
}
pub fn modifiers_from_event(event: &web_sys::KeyboardEvent) -> egui::Modifiers {
egui::Modifiers {
alt: event.alt_key(),
ctrl: event.ctrl_key(),
shift: event.shift_key(),
// Ideally we should know if we are running or mac or not,
// but this works good enough for now.
mac_cmd: event.meta_key(),
// Ideally we should know if we are running or mac or not,
// but this works good enough for now.
command: event.ctrl_key() || event.meta_key(),
}
}

View file

@ -0,0 +1,258 @@
//! [`egui`] bindings for web apps (compiling to WASM).
#![allow(clippy::missing_errors_doc)] // So many `-> Result<_, JsValue>`
pub mod backend;
mod events;
mod input;
pub mod screen_reader;
pub mod storage;
mod text_agent;
#[cfg(not(any(feature = "glow", feature = "wgpu")))]
compile_error!("You must enable either the 'glow' or 'wgpu' feature");
mod web_painter;
#[cfg(feature = "glow")]
mod web_painter_glow;
#[cfg(feature = "glow")]
pub(crate) type ActiveWebPainter = web_painter_glow::WebPainterGlow;
#[cfg(feature = "wgpu")]
mod web_painter_wgpu;
#[cfg(all(feature = "wgpu", not(feature = "glow")))]
pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu;
pub use backend::*;
pub use events::*;
pub use storage::*;
use std::collections::BTreeMap;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use egui::Vec2;
use wasm_bindgen::prelude::*;
use web_sys::EventTarget;
use input::*;
use crate::Theme;
// ----------------------------------------------------------------------------
/// Current time in seconds (since undefined point in time).
///
/// Monotonically increasing.
pub fn now_sec() -> f64 {
web_sys::window()
.expect("should have a Window")
.performance()
.expect("should have a Performance")
.now()
/ 1000.0
}
#[allow(dead_code)]
pub fn screen_size_in_native_points() -> Option<egui::Vec2> {
let window = web_sys::window()?;
Some(egui::vec2(
window.inner_width().ok()?.as_f64()? as f32,
window.inner_height().ok()?.as_f64()? as f32,
))
}
pub fn native_pixels_per_point() -> f32 {
let pixels_per_point = web_sys::window().unwrap().device_pixel_ratio() as f32;
if pixels_per_point > 0.0 && pixels_per_point.is_finite() {
pixels_per_point
} else {
1.0
}
}
pub fn system_theme() -> Option<Theme> {
let dark_mode = web_sys::window()?
.match_media("(prefers-color-scheme: dark)")
.ok()??
.matches();
Some(if dark_mode { Theme::Dark } else { Theme::Light })
}
pub fn canvas_element(canvas_id: &str) -> Option<web_sys::HtmlCanvasElement> {
let document = web_sys::window()?.document()?;
let canvas = document.get_element_by_id(canvas_id)?;
canvas.dyn_into::<web_sys::HtmlCanvasElement>().ok()
}
pub fn canvas_element_or_die(canvas_id: &str) -> web_sys::HtmlCanvasElement {
canvas_element(canvas_id)
.unwrap_or_else(|| panic!("Failed to find canvas with id {:?}", canvas_id))
}
fn canvas_origin(canvas_id: &str) -> egui::Pos2 {
let rect = canvas_element(canvas_id)
.unwrap()
.get_bounding_client_rect();
egui::pos2(rect.left() as f32, rect.top() as f32)
}
pub fn canvas_size_in_points(canvas_id: &str) -> egui::Vec2 {
let canvas = canvas_element(canvas_id).unwrap();
let pixels_per_point = native_pixels_per_point();
egui::vec2(
canvas.width() as f32 / pixels_per_point,
canvas.height() as f32 / pixels_per_point,
)
}
pub fn resize_canvas_to_screen_size(canvas_id: &str, max_size_points: egui::Vec2) -> Option<()> {
let canvas = canvas_element(canvas_id)?;
let parent = canvas.parent_element()?;
let width = parent.scroll_width();
let height = parent.scroll_height();
let canvas_real_size = Vec2 {
x: width as f32,
y: height as f32,
};
if width <= 0 || height <= 0 {
tracing::error!("egui canvas parent size is {}x{}. Try adding `html, body {{ height: 100%; width: 100% }}` to your CSS!", width, height);
}
let pixels_per_point = native_pixels_per_point();
let max_size_pixels = pixels_per_point * max_size_points;
let canvas_size_pixels = pixels_per_point * canvas_real_size;
let canvas_size_pixels = canvas_size_pixels.min(max_size_pixels);
let canvas_size_points = canvas_size_pixels / pixels_per_point;
// Make sure that the height and width are always even numbers.
// otherwise, the page renders blurry on some platforms.
// See https://github.com/emilk/egui/issues/103
fn round_to_even(v: f32) -> f32 {
(v / 2.0).round() * 2.0
}
canvas
.style()
.set_property(
"width",
&format!("{}px", round_to_even(canvas_size_points.x)),
)
.ok()?;
canvas
.style()
.set_property(
"height",
&format!("{}px", round_to_even(canvas_size_points.y)),
)
.ok()?;
canvas.set_width(round_to_even(canvas_size_pixels.x) as u32);
canvas.set_height(round_to_even(canvas_size_pixels.y) as u32);
Some(())
}
// ----------------------------------------------------------------------------
pub fn set_cursor_icon(cursor: egui::CursorIcon) -> Option<()> {
let document = web_sys::window()?.document()?;
document
.body()?
.style()
.set_property("cursor", cursor_web_name(cursor))
.ok()
}
#[cfg(web_sys_unstable_apis)]
pub fn set_clipboard_text(s: &str) {
if let Some(window) = web_sys::window() {
if let Some(clipboard) = window.navigator().clipboard() {
let promise = clipboard.write_text(s);
let future = wasm_bindgen_futures::JsFuture::from(promise);
let future = async move {
if let Err(err) = future.await {
tracing::error!("Copy/cut action denied: {:?}", err);
}
};
wasm_bindgen_futures::spawn_local(future);
}
}
}
fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str {
match cursor {
egui::CursorIcon::Alias => "alias",
egui::CursorIcon::AllScroll => "all-scroll",
egui::CursorIcon::Cell => "cell",
egui::CursorIcon::ContextMenu => "context-menu",
egui::CursorIcon::Copy => "copy",
egui::CursorIcon::Crosshair => "crosshair",
egui::CursorIcon::Default => "default",
egui::CursorIcon::Grab => "grab",
egui::CursorIcon::Grabbing => "grabbing",
egui::CursorIcon::Help => "help",
egui::CursorIcon::Move => "move",
egui::CursorIcon::NoDrop => "no-drop",
egui::CursorIcon::None => "none",
egui::CursorIcon::NotAllowed => "not-allowed",
egui::CursorIcon::PointingHand => "pointer",
egui::CursorIcon::Progress => "progress",
egui::CursorIcon::ResizeHorizontal => "ew-resize",
egui::CursorIcon::ResizeNeSw => "nesw-resize",
egui::CursorIcon::ResizeNwSe => "nwse-resize",
egui::CursorIcon::ResizeVertical => "ns-resize",
egui::CursorIcon::ResizeEast => "e-resize",
egui::CursorIcon::ResizeSouthEast => "se-resize",
egui::CursorIcon::ResizeSouth => "s-resize",
egui::CursorIcon::ResizeSouthWest => "sw-resize",
egui::CursorIcon::ResizeWest => "w-resize",
egui::CursorIcon::ResizeNorthWest => "nw-resize",
egui::CursorIcon::ResizeNorth => "n-resize",
egui::CursorIcon::ResizeNorthEast => "ne-resize",
egui::CursorIcon::ResizeColumn => "col-resize",
egui::CursorIcon::ResizeRow => "row-resize",
egui::CursorIcon::Text => "text",
egui::CursorIcon::VerticalText => "vertical-text",
egui::CursorIcon::Wait => "wait",
egui::CursorIcon::ZoomIn => "zoom-in",
egui::CursorIcon::ZoomOut => "zoom-out",
}
}
pub fn open_url(url: &str, new_tab: bool) -> Option<()> {
let name = if new_tab { "_blank" } else { "_self" };
web_sys::window()?
.open_with_url_and_target(url, name)
.ok()?;
Some(())
}
/// e.g. "#fragment" part of "www.example.com/index.html#fragment",
///
/// Percent decoded
pub fn location_hash() -> String {
percent_decode(
&web_sys::window()
.unwrap()
.location()
.hash()
.unwrap_or_default(),
)
}
pub fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.to_string()
}

View file

@ -0,0 +1,49 @@
pub struct ScreenReader {
#[cfg(feature = "tts")]
tts: Option<tts::Tts>,
}
#[cfg(not(feature = "tts"))]
#[allow(clippy::derivable_impls)] // False positive
impl Default for ScreenReader {
fn default() -> Self {
Self {}
}
}
#[cfg(feature = "tts")]
impl Default for ScreenReader {
fn default() -> Self {
let tts = match tts::Tts::default() {
Ok(screen_reader) => {
tracing::debug!("Initialized screen reader.");
Some(screen_reader)
}
Err(err) => {
tracing::warn!("Failed to load screen reader: {}", err);
None
}
};
Self { tts }
}
}
impl ScreenReader {
#[cfg(not(feature = "tts"))]
#[allow(clippy::unused_self)]
pub fn speak(&mut self, _text: &str) {}
#[cfg(feature = "tts")]
pub fn speak(&mut self, text: &str) {
if text.is_empty() {
return;
}
if let Some(tts) = &mut self.tts {
tracing::debug!("Speaking: {:?}", text);
let interrupt = true;
if let Err(err) = tts.speak(text, interrupt) {
tracing::warn!("Failed to read: {}", err);
}
}
}
}

View file

@ -0,0 +1,43 @@
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
pub fn local_storage_get(key: &str) -> Option<String> {
local_storage().map(|storage| storage.get_item(key).ok())??
}
pub fn local_storage_set(key: &str, value: &str) {
local_storage().map(|storage| storage.set_item(key, value));
}
#[cfg(feature = "persistence")]
pub fn load_memory(ctx: &egui::Context) {
if let Some(memory_string) = local_storage_get("egui_memory_ron") {
match ron::from_str(&memory_string) {
Ok(memory) => {
ctx.memory_mut(|m| *m = memory);
}
Err(err) => {
tracing::error!("Failed to parse memory RON: {}", err);
}
}
}
}
#[cfg(not(feature = "persistence"))]
pub fn load_memory(_: &egui::Context) {}
#[cfg(feature = "persistence")]
pub fn save_memory(ctx: &egui::Context) {
match ctx.memory(|mem| ron::to_string(mem)) {
Ok(ron) => {
local_storage_set("egui_memory_ron", &ron);
}
Err(err) => {
tracing::error!("Failed to serialize memory as RON: {}", err);
}
}
}
#[cfg(not(feature = "persistence"))]
pub fn save_memory(_: &egui::Context) {}

View file

@ -0,0 +1,225 @@
//! The text agent is an `<input>` element used to trigger
//! mobile keyboard and IME input.
use super::{canvas_element, AppRunner, AppRunnerContainer};
use egui::mutex::MutexGuard;
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
static AGENT_ID: &str = "egui_text_agent";
pub fn text_agent() -> web_sys::HtmlInputElement {
web_sys::window()
.unwrap()
.document()
.unwrap()
.get_element_by_id(AGENT_ID)
.unwrap()
.dyn_into()
.unwrap()
}
/// Text event handler,
pub fn install_text_agent(runner_container: &mut AppRunnerContainer) -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().expect("document should have a body");
let input = document
.create_element("input")?
.dyn_into::<web_sys::HtmlInputElement>()?;
let input = std::rc::Rc::new(input);
input.set_id(AGENT_ID);
let is_composing = Rc::new(Cell::new(false));
{
let style = input.style();
// Transparent
style.set_property("opacity", "0").unwrap();
// Hide under canvas
style.set_property("z-index", "-1").unwrap();
}
// Set size as small as possible, in case user may click on it.
input.set_size(1);
input.set_autofocus(true);
input.set_hidden(true);
// When IME is off
runner_container.add_event_listener(&input, "input", {
let input_clone = input.clone();
let is_composing = is_composing.clone();
move |_event: web_sys::InputEvent, mut runner_lock| {
let text = input_clone.value();
if !text.is_empty() && !is_composing.get() {
input_clone.set_value("");
runner_lock.input.raw.events.push(egui::Event::Text(text));
runner_lock.needs_repaint.repaint_asap();
}
}
})?;
{
// When IME is on, handle composition event
runner_container.add_event_listener(&input, "compositionstart", {
let input_clone = input.clone();
let is_composing = is_composing.clone();
move |_event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
is_composing.set(true);
input_clone.set_value("");
runner_lock
.input
.raw
.events
.push(egui::Event::CompositionStart);
runner_lock.needs_repaint.repaint_asap();
}
})?;
runner_container.add_event_listener(
&input,
"compositionupdate",
move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
if let Some(event) = event.data().map(egui::Event::CompositionUpdate) {
runner_lock.input.raw.events.push(event);
runner_lock.needs_repaint.repaint_asap();
}
},
)?;
runner_container.add_event_listener(&input, "compositionend", {
let input_clone = input.clone();
move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
is_composing.set(false);
input_clone.set_value("");
if let Some(event) = event.data().map(egui::Event::CompositionEnd) {
runner_lock.input.raw.events.push(event);
runner_lock.needs_repaint.repaint_asap();
}
}
})?;
}
// When input lost focus, focus on it again.
// It is useful when user click somewhere outside canvas.
runner_container.add_event_listener(
&input,
"focusout",
move |_event: web_sys::MouseEvent, _| {
// Delay 10 ms, and focus again.
let func = js_sys::Function::new_no_args(&format!(
"document.getElementById('{}').focus()",
AGENT_ID
));
window
.set_timeout_with_callback_and_timeout_and_arguments_0(&func, 10)
.unwrap();
},
)?;
body.append_child(&input)?;
Ok(())
}
/// Focus or blur text agent to toggle mobile keyboard.
pub fn update_text_agent(runner: MutexGuard<'_, AppRunner>) -> Option<()> {
use web_sys::HtmlInputElement;
let window = web_sys::window()?;
let document = window.document()?;
let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap();
let canvas_style = canvas_element(runner.canvas_id())?.style();
if runner.mutable_text_under_cursor {
let is_already_editing = input.hidden();
if is_already_editing {
input.set_hidden(false);
input.focus().ok()?;
// Move up canvas so that text edit is shown at ~30% of screen height.
// Only on touch screens, when keyboard popups.
if let Some(latest_touch_pos) = runner.input.latest_touch_pos {
let window_height = window.inner_height().ok()?.as_f64()? as f32;
let current_rel = latest_touch_pos.y / window_height;
// estimated amount of screen covered by keyboard
let keyboard_fraction = 0.5;
if current_rel > keyboard_fraction {
// below the keyboard
let target_rel = 0.3;
// Note: `delta` is negative, since we are moving the canvas UP
let delta = target_rel - current_rel;
let delta = delta.max(-keyboard_fraction); // Don't move it crazy much
let new_pos_percent = format!("{}%", (delta * 100.0).round());
canvas_style.set_property("position", "absolute").ok()?;
canvas_style.set_property("top", &new_pos_percent).ok()?;
}
}
}
} else {
// Drop runner lock
drop(runner);
// Holding the runner lock while calling input.blur() causes a panic.
// This is most probably caused by the browser running the event handler
// for the triggered blur event synchronously, meaning that the mutex
// lock does not get dropped by the time another event handler is called.
//
// Why this didn't exist before #1290 is a mystery to me, but it exists now
// and this apparently is the fix for it
//
// ¯\_(ツ)_/¯ - @DusterTheFirst
input.blur().ok()?;
input.set_hidden(true);
canvas_style.set_property("position", "absolute").ok()?;
canvas_style.set_property("top", "0%").ok()?; // move back to normal position
}
Some(())
}
/// If context is running under mobile device?
fn is_mobile() -> Option<bool> {
const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"];
let user_agent = web_sys::window()?.navigator().user_agent().ok()?;
let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name));
Some(is_mobile)
}
// Move text agent to text cursor's position, on desktop/laptop,
// candidate window moves following text element (agent),
// so it appears that the IME candidate window moves with text cursor.
// On mobile devices, there is no need to do that.
pub fn move_text_cursor(cursor: Option<egui::Pos2>, canvas_id: &str) -> Option<()> {
let style = text_agent().style();
// Note: movint agent on mobile devices will lead to unpredictable scroll.
if is_mobile() == Some(false) {
cursor.as_ref().and_then(|&egui::Pos2 { x, y }| {
let canvas = canvas_element(canvas_id)?;
let bounding_rect = text_agent().get_bounding_client_rect();
let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32)
.min(canvas.client_height() as f32 - bounding_rect.height() as f32);
let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32;
// Canvas is translated 50% horizontally in html.
let x = (x - canvas.offset_width() as f32 / 2.0)
.min(canvas.client_width() as f32 - bounding_rect.width() as f32);
style.set_property("position", "absolute").ok()?;
style.set_property("top", &format!("{}px", y)).ok()?;
style.set_property("left", &format!("{}px", x)).ok()
})
} else {
style.set_property("position", "absolute").ok()?;
style.set_property("top", "0px").ok()?;
style.set_property("left", "0px").ok()
}
}

View file

@ -0,0 +1,29 @@
use wasm_bindgen::JsValue;
/// Renderer for a browser canvas.
/// As of writing we're not allowing to decide on the painter at runtime,
/// therefore this trait is merely there for specifying and documenting the interface.
pub(crate) trait WebPainter {
// Create a new web painter targeting a given canvas.
// fn new(canvas_id: &str, options: &WebOptions) -> Result<Self, String>
// where
// Self: Sized;
/// Id of the canvas in use.
fn canvas_id(&self) -> &str;
/// Maximum size of a texture in one direction.
fn max_texture_side(&self) -> usize;
/// Update all internal textures and paint gui.
fn paint_and_update_textures(
&mut self,
clear_color: [f32; 4],
clipped_primitives: &[egui::ClippedPrimitive],
pixels_per_point: f32,
textures_delta: &egui::TexturesDelta,
) -> Result<(), JsValue>;
/// Destroy all resources.
fn destroy(&mut self);
}

View file

@ -0,0 +1,184 @@
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use web_sys::HtmlCanvasElement;
use egui_glow::glow;
use crate::{WebGlContextOption, WebOptions};
use super::web_painter::WebPainter;
pub(crate) struct WebPainterGlow {
canvas: HtmlCanvasElement,
canvas_id: String,
painter: egui_glow::Painter,
}
impl WebPainterGlow {
pub fn gl(&self) -> &std::sync::Arc<glow::Context> {
self.painter.gl()
}
pub async fn new(canvas_id: &str, options: &WebOptions) -> Result<Self, String> {
let canvas = super::canvas_element_or_die(canvas_id);
let (gl, shader_prefix) =
init_glow_context_from_canvas(&canvas, options.webgl_context_option)?;
let gl = std::sync::Arc::new(gl);
let painter = egui_glow::Painter::new(gl, shader_prefix, None)
.map_err(|error| format!("Error starting glow painter: {}", error))?;
Ok(Self {
canvas,
canvas_id: canvas_id.to_owned(),
painter,
})
}
}
impl WebPainter for WebPainterGlow {
fn max_texture_side(&self) -> usize {
self.painter.max_texture_side()
}
fn canvas_id(&self) -> &str {
&self.canvas_id
}
fn paint_and_update_textures(
&mut self,
clear_color: [f32; 4],
clipped_primitives: &[egui::ClippedPrimitive],
pixels_per_point: f32,
textures_delta: &egui::TexturesDelta,
) -> Result<(), JsValue> {
let canvas_dimension = [self.canvas.width(), self.canvas.height()];
for (id, image_delta) in &textures_delta.set {
self.painter.set_texture(*id, image_delta);
}
egui_glow::painter::clear(self.painter.gl(), canvas_dimension, clear_color);
self.painter
.paint_primitives(canvas_dimension, pixels_per_point, clipped_primitives);
for &id in &textures_delta.free {
self.painter.free_texture(id);
}
Ok(())
}
fn destroy(&mut self) {
self.painter.destroy();
}
}
/// Returns glow context and shader prefix.
fn init_glow_context_from_canvas(
canvas: &HtmlCanvasElement,
options: WebGlContextOption,
) -> Result<(glow::Context, &'static str), String> {
let result = match options {
// Force use WebGl1
WebGlContextOption::WebGl1 => init_webgl1(canvas),
// Force use WebGl2
WebGlContextOption::WebGl2 => init_webgl2(canvas),
// Trying WebGl2 first
WebGlContextOption::BestFirst => init_webgl2(canvas).or_else(|| init_webgl1(canvas)),
// Trying WebGl1 first (useful for testing).
WebGlContextOption::CompatibilityFirst => {
init_webgl1(canvas).or_else(|| init_webgl2(canvas))
}
};
if let Some(result) = result {
Ok(result)
} else {
Err("WebGL isn't supported".into())
}
}
fn init_webgl1(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> {
let gl1_ctx = canvas
.get_context("webgl")
.expect("Failed to query about WebGL2 context");
let gl1_ctx = gl1_ctx?;
tracing::debug!("WebGL1 selected.");
let gl1_ctx = gl1_ctx
.dyn_into::<web_sys::WebGlRenderingContext>()
.unwrap();
let shader_prefix = if webgl1_requires_brightening(&gl1_ctx) {
tracing::debug!("Enabling webkitGTK brightening workaround.");
"#define APPLY_BRIGHTENING_GAMMA"
} else {
""
};
let gl = glow::Context::from_webgl1_context(gl1_ctx);
Some((gl, shader_prefix))
}
fn init_webgl2(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> {
let gl2_ctx = canvas
.get_context("webgl2")
.expect("Failed to query about WebGL2 context");
let gl2_ctx = gl2_ctx?;
tracing::debug!("WebGL2 selected.");
let gl2_ctx = gl2_ctx
.dyn_into::<web_sys::WebGl2RenderingContext>()
.unwrap();
let gl = glow::Context::from_webgl2_context(gl2_ctx);
let shader_prefix = "";
Some((gl, shader_prefix))
}
fn webgl1_requires_brightening(gl: &web_sys::WebGlRenderingContext) -> bool {
// See https://github.com/emilk/egui/issues/794
// detect WebKitGTK
// WebKitGTK use WebKit default unmasked vendor and renderer
// but safari use same vendor and renderer
// so exclude "Mac OS X" user-agent.
let user_agent = web_sys::window().unwrap().navigator().user_agent().unwrap();
!user_agent.contains("Mac OS X") && is_safari_and_webkit_gtk(gl)
}
/// detecting Safari and `webkitGTK`.
///
/// Safari and `webkitGTK` use unmasked renderer :Apple GPU
///
/// If we detect safari or `webkitGTKs` returns true.
///
/// This function used to avoid displaying linear color with `sRGB` supported systems.
fn is_safari_and_webkit_gtk(gl: &web_sys::WebGlRenderingContext) -> bool {
// This call produces a warning in Firefox ("WEBGL_debug_renderer_info is deprecated in Firefox and will be removed.")
// but unless we call it we get errors in Chrome when we call `get_parameter` below.
// TODO(emilk): do something smart based on user agent?
if gl
.get_extension("WEBGL_debug_renderer_info")
.unwrap()
.is_some()
{
if let Ok(renderer) =
gl.get_parameter(web_sys::WebglDebugRendererInfo::UNMASKED_RENDERER_WEBGL)
{
if let Some(renderer) = renderer.as_string() {
if renderer.contains("Apple") {
return true;
}
}
}
}
false
}

View file

@ -0,0 +1,282 @@
use std::sync::Arc;
use wasm_bindgen::JsValue;
use web_sys::HtmlCanvasElement;
use egui::mutex::RwLock;
use egui_wgpu::{renderer::ScreenDescriptor, RenderState, SurfaceErrorAction};
use crate::WebOptions;
use super::web_painter::WebPainter;
pub(crate) struct WebPainterWgpu {
canvas: HtmlCanvasElement,
canvas_id: String,
surface: wgpu::Surface,
surface_configuration: wgpu::SurfaceConfiguration,
limits: wgpu::Limits,
render_state: Option<RenderState>,
on_surface_error: Arc<dyn Fn(wgpu::SurfaceError) -> SurfaceErrorAction>,
depth_format: Option<wgpu::TextureFormat>,
depth_texture_view: Option<wgpu::TextureView>,
}
impl WebPainterWgpu {
#[allow(unused)] // only used if `wgpu` is the only active feature.
pub fn render_state(&self) -> Option<RenderState> {
self.render_state.clone()
}
pub fn generate_depth_texture_view(
&self,
render_state: &RenderState,
width_in_pixels: u32,
height_in_pixels: u32,
) -> Option<wgpu::TextureView> {
let device = &render_state.device;
self.depth_format.map(|depth_format| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("egui_depth_texture"),
size: wgpu::Extent3d {
width: width_in_pixels,
height: height_in_pixels,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: depth_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[depth_format],
})
.create_view(&wgpu::TextureViewDescriptor::default())
})
}
#[allow(unused)] // only used if `wgpu` is the only active feature.
pub async fn new(canvas_id: &str, options: &WebOptions) -> Result<Self, String> {
tracing::debug!("Creating wgpu painter");
let canvas = super::canvas_element_or_die(canvas_id);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: options.wgpu_options.backends,
dx12_shader_compiler: Default::default(),
});
let surface = instance
.create_surface_from_canvas(&canvas)
.map_err(|err| format!("failed to create wgpu surface: {err}"))?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: options.wgpu_options.power_preference,
force_fallback_adapter: false,
compatible_surface: None,
})
.await
.ok_or_else(|| "No suitable GPU adapters found on the system".to_owned())?;
let (device, queue) = adapter
.request_device(
&options.wgpu_options.device_descriptor,
None, // Capture doesn't work in the browser environment.
)
.await
.map_err(|err| format!("Failed to find wgpu device: {}", err))?;
let target_format =
egui_wgpu::preferred_framebuffer_format(&surface.get_capabilities(&adapter).formats);
let depth_format = options.wgpu_options.depth_format;
let renderer = egui_wgpu::Renderer::new(&device, target_format, depth_format, 1);
let render_state = RenderState {
device: Arc::new(device),
queue: Arc::new(queue),
target_format,
renderer: Arc::new(RwLock::new(renderer)),
};
let surface_configuration = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: target_format,
width: 0,
height: 0,
present_mode: options.wgpu_options.present_mode,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![target_format],
};
tracing::debug!("wgpu painter initialized.");
Ok(Self {
canvas,
canvas_id: canvas_id.to_owned(),
render_state: Some(render_state),
surface,
surface_configuration,
depth_format,
depth_texture_view: None,
limits: options.wgpu_options.device_descriptor.limits.clone(),
on_surface_error: options.wgpu_options.on_surface_error.clone(),
})
}
}
impl WebPainter for WebPainterWgpu {
fn canvas_id(&self) -> &str {
&self.canvas_id
}
fn max_texture_side(&self) -> usize {
self.limits.max_texture_dimension_2d as _
}
fn paint_and_update_textures(
&mut self,
clear_color: [f32; 4],
clipped_primitives: &[egui::ClippedPrimitive],
pixels_per_point: f32,
textures_delta: &egui::TexturesDelta,
) -> Result<(), JsValue> {
let size_in_pixels = [self.canvas.width(), self.canvas.height()];
let render_state = if let Some(render_state) = &self.render_state {
render_state
} else {
return Err(JsValue::from_str(
"Can't paint, wgpu renderer was already disposed",
));
};
let mut encoder =
render_state
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("egui_webpainter_paint_and_update_textures"),
});
// Upload all resources for the GPU.
let screen_descriptor = ScreenDescriptor {
size_in_pixels,
pixels_per_point,
};
let user_cmd_bufs = {
let mut renderer = render_state.renderer.write();
for (id, image_delta) in &textures_delta.set {
renderer.update_texture(
&render_state.device,
&render_state.queue,
*id,
image_delta,
);
}
renderer.update_buffers(
&render_state.device,
&render_state.queue,
&mut encoder,
clipped_primitives,
&screen_descriptor,
)
};
// Resize surface if needed
let is_zero_sized_surface = size_in_pixels[0] == 0 || size_in_pixels[1] == 0;
let frame = if is_zero_sized_surface {
None
} else {
if size_in_pixels[0] != self.surface_configuration.width
|| size_in_pixels[1] != self.surface_configuration.height
{
self.surface_configuration.width = size_in_pixels[0];
self.surface_configuration.height = size_in_pixels[1];
self.surface
.configure(&render_state.device, &self.surface_configuration);
self.depth_texture_view = self.generate_depth_texture_view(
render_state,
size_in_pixels[0],
size_in_pixels[1],
);
}
let frame = match self.surface.get_current_texture() {
Ok(frame) => frame,
#[allow(clippy::single_match_else)]
Err(e) => match (*self.on_surface_error)(e) {
SurfaceErrorAction::RecreateSurface => {
self.surface
.configure(&render_state.device, &self.surface_configuration);
return Ok(());
}
SurfaceErrorAction::SkipFrame => {
return Ok(());
}
},
};
{
let renderer = render_state.renderer.read();
let frame_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: clear_color[0] as f64,
g: clear_color[1] as f64,
b: clear_color[2] as f64,
a: clear_color[3] as f64,
}),
store: true,
},
})],
depth_stencil_attachment: self.depth_texture_view.as_ref().map(|view| {
wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: false,
}),
stencil_ops: None,
}
}),
label: Some("egui_render"),
});
renderer.render(&mut render_pass, clipped_primitives, &screen_descriptor);
}
Some(frame)
};
{
let mut renderer = render_state.renderer.write();
for id in &textures_delta.free {
renderer.free_texture(id);
}
}
// Submit the commands: both the main buffer and user-defined ones.
render_state.queue.submit(
user_cmd_bufs
.into_iter()
.chain(std::iter::once(encoder.finish())),
);
if let Some(frame) = frame {
frame.present();
}
Ok(())
}
fn destroy(&mut self) {
self.render_state = None;
}
}

View file

@ -0,0 +1,37 @@
# Changelog for egui-wgpu
All notable changes to the `egui-wgpu` integration will be noted in this file.
## Unreleased
## 0.21.0 - 2023-02-08
* Update to `wgpu` 0.15 ([#2629](https://github.com/emilk/egui/pull/2629))
* Return `Err` instead of panic if we can't find a device ([#2428](https://github.com/emilk/egui/pull/2428)).
* `winit::Painter::set_window` is now `async` ([#2434](https://github.com/emilk/egui/pull/2434)).
* `egui-wgpu` now only depends on `epaint` instead of the entire `egui` ([#2438](https://github.com/emilk/egui/pull/2438)).
* `winit::Painter` now supports transparent backbuffer ([#2684](https://github.com/emilk/egui/pull/2684)).
## 0.20.0 - 2022-12-08 - web support
* Renamed `RenderPass` to `Renderer`.
* Renamed `RenderPass::execute` to `RenderPass::render`.
* Renamed `RenderPass::execute_with_renderpass` to `Renderer::render` (replacing existing `Renderer::render`)
* Reexported `Renderer`.
* You can now use `egui-wgpu` on web, using WebGL ([#2107](https://github.com/emilk/egui/pull/2107)).
* `Renderer` no longer handles pass creation and depth buffer creation ([#2136](https://github.com/emilk/egui/pull/2136))
* `PrepareCallback` now passes `wgpu::CommandEncoder` ([#2136](https://github.com/emilk/egui/pull/2136))
* `PrepareCallback` can now returns `wgpu::CommandBuffer` that are bundled into a single `wgpu::Queue::submit` call ([#2230](https://github.com/emilk/egui/pull/2230))
* Only a single vertex & index buffer is now created and resized when necessary (previously, vertex/index buffers were allocated for every mesh) ([#2148](https://github.com/emilk/egui/pull/2148)).
* `Renderer::update_texture` no longer creates a new `wgpu::Sampler` with every new texture ([#2198](https://github.com/emilk/egui/pull/2198))
* `Painter`'s instance/device/adapter/surface creation is now configurable via `WgpuConfiguration` ([#2207](https://github.com/emilk/egui/pull/2207))
* Fix panic on using a depth buffer ([#2316](https://github.com/emilk/egui/pull/2316))
## 0.19.0 - 2022-08-20
* Enables deferred render + surface state initialization for Android ([#1634](https://github.com/emilk/egui/pull/1634)).
* Make `RenderPass` `Send` and `Sync` ([#1883](https://github.com/emilk/egui/pull/1883)).
## 0.18.0 - 2022-05-15
First published version since moving the code into the `egui` repository from <https://github.com/LU15W1R7H/eww>.

View file

@ -0,0 +1,56 @@
[package]
name = "egui-wgpu"
version = "0.21.0"
description = "Bindings for using egui natively using the wgpu library"
authors = [
"Nils Hasenbanck <nils@hasenbanck.de>",
"embotech <opensource@embotech.com>",
"Emil Ernerfeldt <emil.ernerfeldt@gmail.com>",
]
edition = "2021"
rust-version = "1.65"
homepage = "https://github.com/emilk/egui/tree/master/crates/egui-wgpu"
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/emilk/egui/tree/master/crates/egui-wgpu"
categories = ["gui", "game-development"]
keywords = ["wgpu", "egui", "gui", "gamedev"]
include = [
"../LICENSE-APACHE",
"../LICENSE-MIT",
"**/*.rs",
"**/*.wgsl",
"Cargo.toml",
]
[package.metadata.docs.rs]
all-features = true
[features]
## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate.
puffin = ["dep:puffin"]
## Enable [`winit`](https://docs.rs/winit) integration.
winit = ["dep:winit"]
[dependencies]
epaint = { version = "0.21.0", path = "../epaint", default-features = false, features = [
"bytemuck",
] }
bytemuck = "1.7"
tracing = { version = "0.1", default-features = false, features = ["std"] }
type-map = "0.5.0"
wgpu = "0.15.0"
#! ### Optional dependencies
## Enable this when generating docs.
document-features = { version = "0.2", optional = true }
winit = { version = "0.28", optional = true }
# Native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
puffin = { version = "0.14", optional = true }

View file

@ -0,0 +1,10 @@
# egui-wgpu
[![Latest version](https://img.shields.io/crates/v/egui-wgpu.svg)](https://crates.io/crates/egui-wgpu)
[![Documentation](https://docs.rs/egui-wgpu/badge.svg)](https://docs.rs/egui-wgpu)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [wgpu](https://crates.io/crates/wgpu).
This was originally hosted at https://github.com/hasenbanck/egui_wgpu_backend

View file

@ -0,0 +1,91 @@
// Vertex shader bindings
struct VertexOutput {
@location(0) tex_coord: vec2<f32>,
@location(1) color: vec4<f32>, // gamma 0-1
@builtin(position) position: vec4<f32>,
};
struct Locals {
screen_size: vec2<f32>,
// Uniform buffers need to be at least 16 bytes in WebGL.
// See https://github.com/gfx-rs/wgpu/issues/2072
_padding: vec2<u32>,
};
@group(0) @binding(0) var<uniform> r_locals: Locals;
// 0-1 linear from 0-1 sRGB gamma
fn linear_from_gamma_rgb(srgb: vec3<f32>) -> vec3<f32> {
let cutoff = srgb < vec3<f32>(0.04045);
let lower = srgb / vec3<f32>(12.92);
let higher = pow((srgb + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4));
return select(higher, lower, cutoff);
}
// 0-1 sRGB gamma from 0-1 linear
fn gamma_from_linear_rgb(rgb: vec3<f32>) -> vec3<f32> {
let cutoff = rgb < vec3<f32>(0.0031308);
let lower = rgb * vec3<f32>(12.92);
let higher = vec3<f32>(1.055) * pow(rgb, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(higher, lower, cutoff);
}
// 0-1 sRGBA gamma from 0-1 linear
fn gamma_from_linear_rgba(linear_rgba: vec4<f32>) -> vec4<f32> {
return vec4<f32>(gamma_from_linear_rgb(linear_rgba.rgb), linear_rgba.a);
}
// [u8; 4] SRGB as u32 -> [r, g, b, a] in 0.-1
fn unpack_color(color: u32) -> vec4<f32> {
return vec4<f32>(
f32(color & 255u),
f32((color >> 8u) & 255u),
f32((color >> 16u) & 255u),
f32((color >> 24u) & 255u),
) / 255.0;
}
fn position_from_screen(screen_pos: vec2<f32>) -> vec4<f32> {
return vec4<f32>(
2.0 * screen_pos.x / r_locals.screen_size.x - 1.0,
1.0 - 2.0 * screen_pos.y / r_locals.screen_size.y,
0.0,
1.0,
);
}
@vertex
fn vs_main(
@location(0) a_pos: vec2<f32>,
@location(1) a_tex_coord: vec2<f32>,
@location(2) a_color: u32,
) -> VertexOutput {
var out: VertexOutput;
out.tex_coord = a_tex_coord;
out.color = unpack_color(a_color);
out.position = position_from_screen(a_pos);
return out;
}
// Fragment shader bindings
@group(1) @binding(0) var r_tex_color: texture_2d<f32>;
@group(1) @binding(1) var r_tex_sampler: sampler;
@fragment
fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
// We always have an sRGB aware texture at the moment.
let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord);
let tex_gamma = gamma_from_linear_rgba(tex_linear);
let out_color_gamma = in.color * tex_gamma;
return vec4<f32>(linear_from_gamma_rgb(out_color_gamma.rgb), out_color_gamma.a);
}
@fragment
fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
// We always have an sRGB aware texture at the moment.
let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord);
let tex_gamma = gamma_from_linear_rgba(tex_linear);
let out_color_gamma = in.color * tex_gamma;
return out_color_gamma;
}

View file

@ -0,0 +1,155 @@
//! This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [wgpu](https://crates.io/crates/wgpu).
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
#![allow(unsafe_code)]
pub use wgpu;
/// Low-level painting of [`egui`](https://github.com/emilk/egui) on [`wgpu`].
pub mod renderer;
pub use renderer::CallbackFn;
pub use renderer::Renderer;
/// Module for painting [`egui`](https://github.com/emilk/egui) with [`wgpu`] on [`winit`].
#[cfg(feature = "winit")]
pub mod winit;
use std::sync::Arc;
use epaint::mutex::RwLock;
/// Access to the render state for egui.
#[derive(Clone)]
pub struct RenderState {
pub device: Arc<wgpu::Device>,
pub queue: Arc<wgpu::Queue>,
pub target_format: wgpu::TextureFormat,
pub renderer: Arc<RwLock<Renderer>>,
}
/// Specifies which action should be taken as consequence of a [`wgpu::SurfaceError`]
pub enum SurfaceErrorAction {
/// Do nothing and skip the current frame.
SkipFrame,
/// Instructs egui to recreate the surface, then skip the current frame.
RecreateSurface,
}
/// Configuration for using wgpu with eframe or the egui-wgpu winit feature.
#[derive(Clone)]
pub struct WgpuConfiguration {
/// Configuration passed on device request.
pub device_descriptor: wgpu::DeviceDescriptor<'static>,
/// Backends that should be supported (wgpu will pick one of these)
pub backends: wgpu::Backends,
/// Present mode used for the primary surface.
pub present_mode: wgpu::PresentMode,
/// Power preference for the adapter.
pub power_preference: wgpu::PowerPreference,
/// Callback for surface errors.
pub on_surface_error: Arc<dyn Fn(wgpu::SurfaceError) -> SurfaceErrorAction>,
pub depth_format: Option<wgpu::TextureFormat>,
}
impl Default for WgpuConfiguration {
fn default() -> Self {
Self {
device_descriptor: wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
features: wgpu::Features::default(),
limits: wgpu::Limits::default(),
},
backends: wgpu::Backends::PRIMARY | wgpu::Backends::GL,
present_mode: wgpu::PresentMode::AutoVsync,
power_preference: wgpu::PowerPreference::HighPerformance,
depth_format: None,
on_surface_error: Arc::new(|err| {
if err == wgpu::SurfaceError::Outdated {
// This error occurs when the app is minimized on Windows.
// Silently return here to prevent spamming the console with:
// "The underlying surface has changed, and therefore the swap chain must be updated"
} else {
tracing::warn!("Dropped frame with error: {err}");
}
SurfaceErrorAction::SkipFrame
}),
}
}
}
/// Find the framebuffer format that egui prefers
pub fn preferred_framebuffer_format(formats: &[wgpu::TextureFormat]) -> wgpu::TextureFormat {
for &format in formats {
if matches!(
format,
wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm
) {
return format;
}
}
formats[0] // take the first
}
// maybe use this-error?
#[derive(Debug)]
pub enum WgpuError {
DeviceError(wgpu::RequestDeviceError),
SurfaceError(wgpu::CreateSurfaceError),
}
impl std::fmt::Display for WgpuError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
impl std::error::Error for WgpuError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
WgpuError::DeviceError(e) => e.source(),
WgpuError::SurfaceError(e) => e.source(),
}
}
}
impl From<wgpu::RequestDeviceError> for WgpuError {
fn from(e: wgpu::RequestDeviceError) -> Self {
Self::DeviceError(e)
}
}
impl From<wgpu::CreateSurfaceError> for WgpuError {
fn from(e: wgpu::CreateSurfaceError) -> Self {
Self::SurfaceError(e)
}
}
// ---------------------------------------------------------------------------
/// Profiling macro for feature "puffin"
macro_rules! profile_function {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
#[cfg(not(target_arch = "wasm32"))]
puffin::profile_function!($($arg)*);
};
}
pub(crate) use profile_function;
/// Profiling macro for feature "puffin"
macro_rules! profile_scope {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
#[cfg(not(target_arch = "wasm32"))]
puffin::profile_scope!($($arg)*);
};
}
pub(crate) use profile_scope;

View file

@ -0,0 +1,931 @@
#![allow(unsafe_code)]
use std::num::NonZeroU64;
use std::ops::Range;
use std::{borrow::Cow, collections::HashMap, num::NonZeroU32};
use type_map::concurrent::TypeMap;
use wgpu;
use wgpu::util::DeviceExt as _;
use epaint::{emath::NumExt, PaintCallbackInfo, Primitive, Vertex};
/// A callback function that can be used to compose an [`epaint::PaintCallback`] for custom WGPU
/// rendering.
///
/// The callback is composed of two functions: `prepare` and `paint`:
/// - `prepare` is called every frame before `paint`, and can use the passed-in
/// [`wgpu::Device`] and [`wgpu::Buffer`] to allocate or modify GPU resources such as buffers.
/// - `paint` is called after `prepare` and is given access to the [`wgpu::RenderPass`] so
/// that it can issue draw commands into the same [`wgpu::RenderPass`] that is used for
/// all other egui elements.
///
/// The final argument of both the `prepare` and `paint` callbacks is a the
/// [`paint_callback_resources`][crate::renderer::Renderer::paint_callback_resources].
/// `paint_callback_resources` has the same lifetime as the Egui render pass, so it can be used to
/// store buffers, pipelines, and other information that needs to be accessed during the render
/// pass.
///
/// # Example
///
/// See the [`custom3d_wgpu`](https://github.com/emilk/egui/blob/master/crates/egui_demo_app/src/apps/custom3d_wgpu.rs) demo source for a detailed usage example.
pub struct CallbackFn {
prepare: Box<PrepareCallback>,
paint: Box<PaintCallback>,
}
type PrepareCallback = dyn Fn(
&wgpu::Device,
&wgpu::Queue,
&mut wgpu::CommandEncoder,
&mut TypeMap,
) -> Vec<wgpu::CommandBuffer>
+ Sync
+ Send;
type PaintCallback =
dyn for<'a, 'b> Fn(PaintCallbackInfo, &'a mut wgpu::RenderPass<'b>, &'b TypeMap) + Sync + Send;
impl Default for CallbackFn {
fn default() -> Self {
CallbackFn {
prepare: Box::new(|_, _, _, _| Vec::new()),
paint: Box::new(|_, _, _| ()),
}
}
}
impl CallbackFn {
pub fn new() -> Self {
Self::default()
}
/// Set the prepare callback.
///
/// The passed-in `CommandEncoder` is egui's and can be used directly to register
/// wgpu commands for simple use cases.
/// This allows reusing the same [`wgpu::CommandEncoder`] for all callbacks and egui
/// rendering itself.
///
/// For more complicated use cases, one can also return a list of arbitrary
/// `CommandBuffer`s and have complete control over how they get created and fed.
/// In particular, this gives an opportunity to parallelize command registration and
/// prevents a faulty callback from poisoning the main wgpu pipeline.
///
/// When using eframe, the main egui command buffer, as well as all user-defined
/// command buffers returned by this function, are guaranteed to all be submitted
/// at once in a single call.
pub fn prepare<F>(mut self, prepare: F) -> Self
where
F: Fn(
&wgpu::Device,
&wgpu::Queue,
&mut wgpu::CommandEncoder,
&mut TypeMap,
) -> Vec<wgpu::CommandBuffer>
+ Sync
+ Send
+ 'static,
{
self.prepare = Box::new(prepare) as _;
self
}
/// Set the paint callback
pub fn paint<F>(mut self, paint: F) -> Self
where
F: for<'a, 'b> Fn(PaintCallbackInfo, &'a mut wgpu::RenderPass<'b>, &'b TypeMap)
+ Sync
+ Send
+ 'static,
{
self.paint = Box::new(paint) as _;
self
}
}
/// Information about the screen used for rendering.
pub struct ScreenDescriptor {
/// Size of the window in physical pixels.
pub size_in_pixels: [u32; 2],
/// HiDPI scale factor (pixels per point).
pub pixels_per_point: f32,
}
impl ScreenDescriptor {
/// size in "logical" points
fn screen_size_in_points(&self) -> [f32; 2] {
[
self.size_in_pixels[0] as f32 / self.pixels_per_point,
self.size_in_pixels[1] as f32 / self.pixels_per_point,
]
}
}
/// Uniform buffer used when rendering.
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
struct UniformBuffer {
screen_size_in_points: [f32; 2],
// Uniform buffers need to be at least 16 bytes in WebGL.
// See https://github.com/gfx-rs/wgpu/issues/2072
_padding: [u32; 2],
}
struct SlicedBuffer {
buffer: wgpu::Buffer,
slices: Vec<Range<wgpu::BufferAddress>>,
capacity: wgpu::BufferAddress,
}
/// Renderer for a egui based GUI.
pub struct Renderer {
pipeline: wgpu::RenderPipeline,
index_buffer: SlicedBuffer,
vertex_buffer: SlicedBuffer,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
texture_bind_group_layout: wgpu::BindGroupLayout,
/// Map of egui texture IDs to textures and their associated bindgroups (texture view +
/// sampler). The texture may be None if the TextureId is just a handle to a user-provided
/// sampler.
textures: HashMap<epaint::TextureId, (Option<wgpu::Texture>, wgpu::BindGroup)>,
next_user_texture_id: u64,
samplers: HashMap<epaint::textures::TextureOptions, wgpu::Sampler>,
/// Storage for use by [`epaint::PaintCallback`]'s that need to store resources such as render
/// pipelines that must have the lifetime of the renderpass.
pub paint_callback_resources: TypeMap,
}
impl Renderer {
/// Creates a renderer for a egui UI.
///
/// `output_color_format` should preferably be [`wgpu::TextureFormat::Rgba8Unorm`] or
/// [`wgpu::TextureFormat::Bgra8Unorm`], i.e. in gamma-space.
pub fn new(
device: &wgpu::Device,
output_color_format: wgpu::TextureFormat,
output_depth_format: Option<wgpu::TextureFormat>,
msaa_samples: u32,
) -> Self {
crate::profile_function!();
let shader = wgpu::ShaderModuleDescriptor {
label: Some("egui"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("egui.wgsl"))),
};
let module = device.create_shader_module(shader);
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("egui_uniform_buffer"),
contents: bytemuck::cast_slice(&[UniformBuffer {
screen_size_in_points: [0.0, 0.0],
_padding: Default::default(),
}]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("egui_uniform_bind_group_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
has_dynamic_offset: false,
min_binding_size: NonZeroU64::new(std::mem::size_of::<UniformBuffer>() as _),
ty: wgpu::BufferBindingType::Uniform,
},
count: None,
}],
});
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("egui_uniform_bind_group"),
layout: &uniform_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform_buffer,
offset: 0,
size: None,
}),
}],
});
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("egui_texture_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("egui_pipeline_layout"),
bind_group_layouts: &[&uniform_bind_group_layout, &texture_bind_group_layout],
push_constant_ranges: &[],
});
let depth_stencil = output_depth_format.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("egui_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
entry_point: "vs_main",
module: &module,
buffers: &[wgpu::VertexBufferLayout {
array_stride: 5 * 4,
step_mode: wgpu::VertexStepMode::Vertex,
// 0: vec2 position
// 1: vec2 texture coordinates
// 2: uint color
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
unclipped_depth: false,
conservative: false,
cull_mode: None,
front_face: wgpu::FrontFace::default(),
polygon_mode: wgpu::PolygonMode::default(),
strip_index_format: None,
},
depth_stencil,
multisample: wgpu::MultisampleState {
alpha_to_coverage_enabled: false,
count: msaa_samples,
mask: !0,
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: if output_color_format.describe().srgb {
tracing::warn!("Detected a linear (sRGBA aware) framebuffer {:?}. egui prefers Rgba8Unorm or Bgra8Unorm", output_color_format);
"fs_main_linear_framebuffer"
} else {
"fs_main_gamma_framebuffer" // this is what we prefer
},
targets: &[Some(wgpu::ColorTargetState {
format: output_color_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::OneMinusDstAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
});
const VERTEX_BUFFER_START_CAPACITY: wgpu::BufferAddress =
(std::mem::size_of::<Vertex>() * 1024) as _;
const INDEX_BUFFER_START_CAPACITY: wgpu::BufferAddress =
(std::mem::size_of::<u32>() * 1024 * 3) as _;
Self {
pipeline,
vertex_buffer: SlicedBuffer {
buffer: create_vertex_buffer(device, VERTEX_BUFFER_START_CAPACITY),
slices: Vec::with_capacity(64),
capacity: VERTEX_BUFFER_START_CAPACITY,
},
index_buffer: SlicedBuffer {
buffer: create_index_buffer(device, INDEX_BUFFER_START_CAPACITY),
slices: Vec::with_capacity(64),
capacity: INDEX_BUFFER_START_CAPACITY,
},
uniform_buffer,
uniform_bind_group,
texture_bind_group_layout,
textures: HashMap::new(),
next_user_texture_id: 0,
samplers: HashMap::new(),
paint_callback_resources: TypeMap::default(),
}
}
/// Executes the egui renderer onto an existing wgpu renderpass.
pub fn render<'rp>(
&'rp self,
render_pass: &mut wgpu::RenderPass<'rp>,
paint_jobs: &[epaint::ClippedPrimitive],
screen_descriptor: &ScreenDescriptor,
) {
crate::profile_function!();
let pixels_per_point = screen_descriptor.pixels_per_point;
let size_in_pixels = screen_descriptor.size_in_pixels;
// Whether or not we need to reset the render pass because a paint callback has just
// run.
let mut needs_reset = true;
let mut index_buffer_slices = self.index_buffer.slices.iter();
let mut vertex_buffer_slices = self.vertex_buffer.slices.iter();
for epaint::ClippedPrimitive {
clip_rect,
primitive,
} in paint_jobs
{
if needs_reset {
render_pass.set_viewport(
0.0,
0.0,
size_in_pixels[0] as f32,
size_in_pixels[1] as f32,
0.0,
1.0,
);
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
needs_reset = false;
}
{
let rect = ScissorRect::new(clip_rect, pixels_per_point, size_in_pixels);
if rect.width == 0 || rect.height == 0 {
// Skip rendering zero-sized clip areas.
if let Primitive::Mesh(_) = primitive {
// If this is a mesh, we need to advance the index and vertex buffer iterators:
index_buffer_slices.next().unwrap();
vertex_buffer_slices.next().unwrap();
}
continue;
}
render_pass.set_scissor_rect(rect.x, rect.y, rect.width, rect.height);
}
match primitive {
Primitive::Mesh(mesh) => {
let index_buffer_slice = index_buffer_slices.next().unwrap();
let vertex_buffer_slice = vertex_buffer_slices.next().unwrap();
if let Some((_texture, bind_group)) = self.textures.get(&mesh.texture_id) {
render_pass.set_bind_group(1, bind_group, &[]);
render_pass.set_index_buffer(
self.index_buffer.buffer.slice(index_buffer_slice.clone()),
wgpu::IndexFormat::Uint32,
);
render_pass.set_vertex_buffer(
0,
self.vertex_buffer.buffer.slice(vertex_buffer_slice.clone()),
);
render_pass.draw_indexed(0..mesh.indices.len() as u32, 0, 0..1);
} else {
tracing::warn!("Missing texture: {:?}", mesh.texture_id);
}
}
Primitive::Callback(callback) => {
let cbfn = if let Some(c) = callback.callback.downcast_ref::<CallbackFn>() {
c
} else {
// We already warned in the `prepare` callback
continue;
};
if callback.rect.is_positive() {
crate::profile_scope!("callback");
needs_reset = true;
{
// We're setting a default viewport for the render pass as a
// courtesy for the user, so that they don't have to think about
// it in the simple case where they just want to fill the whole
// paint area.
//
// The user still has the possibility of setting their own custom
// viewport during the paint callback, effectively overriding this
// one.
let min = (callback.rect.min.to_vec2() * pixels_per_point).round();
let max = (callback.rect.max.to_vec2() * pixels_per_point).round();
render_pass.set_viewport(
min.x,
min.y,
max.x - min.x,
max.y - min.y,
0.0,
1.0,
);
}
(cbfn.paint)(
PaintCallbackInfo {
viewport: callback.rect,
clip_rect: *clip_rect,
pixels_per_point,
screen_size_px: size_in_pixels,
},
render_pass,
&self.paint_callback_resources,
);
}
}
}
}
render_pass.set_scissor_rect(0, 0, size_in_pixels[0], size_in_pixels[1]);
}
/// Should be called before `render()`.
pub fn update_texture(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
id: epaint::TextureId,
image_delta: &epaint::ImageDelta,
) {
crate::profile_function!();
let width = image_delta.image.width() as u32;
let height = image_delta.image.height() as u32;
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
let data_color32 = match &image_delta.image {
epaint::ImageData::Color(image) => {
assert_eq!(
width as usize * height as usize,
image.pixels.len(),
"Mismatch between texture size and texel count"
);
Cow::Borrowed(&image.pixels)
}
epaint::ImageData::Font(image) => {
assert_eq!(
width as usize * height as usize,
image.pixels.len(),
"Mismatch between texture size and texel count"
);
Cow::Owned(image.srgba_pixels(None).collect::<Vec<_>>())
}
};
let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice());
let queue_write_data_to_texture = |texture, origin| {
queue.write_texture(
wgpu::ImageCopyTexture {
texture,
mip_level: 0,
origin,
aspect: wgpu::TextureAspect::All,
},
data_bytes,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: NonZeroU32::new(4 * width),
rows_per_image: NonZeroU32::new(height),
},
size,
);
};
if let Some(pos) = image_delta.pos {
// update the existing texture
let (texture, _bind_group) = self
.textures
.get(&id)
.expect("Tried to update a texture that has not been allocated yet.");
let origin = wgpu::Origin3d {
x: pos[0] as u32,
y: pos[1] as u32,
z: 0,
};
queue_write_data_to_texture(
texture.as_ref().expect("Tried to update user texture."),
origin,
);
} else {
// allocate a new texture
// Use same label for all resources associated with this texture id (no point in retyping the type)
let label_str = format!("egui_texid_{:?}", id);
let label = Some(label_str.as_str());
let texture = device.create_texture(&wgpu::TextureDescriptor {
label,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb, // Minspec for wgpu WebGL emulation is WebGL2, so this should always be supported.
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
});
let sampler = self
.samplers
.entry(image_delta.options)
.or_insert_with(|| create_sampler(image_delta.options, device));
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label,
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(
&texture.create_view(&wgpu::TextureViewDescriptor::default()),
),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
});
let origin = wgpu::Origin3d::ZERO;
queue_write_data_to_texture(&texture, origin);
self.textures.insert(id, (Some(texture), bind_group));
};
}
pub fn free_texture(&mut self, id: &epaint::TextureId) {
self.textures.remove(id);
}
/// Get the WGPU texture and bind group associated to a texture that has been allocated by egui.
///
/// This could be used by custom paint hooks to render images that have been added through with
/// [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html)
/// or [`epaint::Context::load_texture`](https://docs.rs/egui/latest/egui/struct.Context.html#method.load_texture).
pub fn texture(
&self,
id: &epaint::TextureId,
) -> Option<&(Option<wgpu::Texture>, wgpu::BindGroup)> {
self.textures.get(id)
}
/// Registers a `wgpu::Texture` with a `epaint::TextureId`.
///
/// This enables the application to reference the texture inside an image ui element.
/// This effectively enables off-screen rendering inside the egui UI. Texture must have
/// the texture format `TextureFormat::Rgba8UnormSrgb` and
/// Texture usage `TextureUsage::SAMPLED`.
pub fn register_native_texture(
&mut self,
device: &wgpu::Device,
texture: &wgpu::TextureView,
texture_filter: wgpu::FilterMode,
) -> epaint::TextureId {
self.register_native_texture_with_sampler_options(
device,
texture,
wgpu::SamplerDescriptor {
label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()),
mag_filter: texture_filter,
min_filter: texture_filter,
..Default::default()
},
)
}
/// Registers a `wgpu::Texture` with an existing `epaint::TextureId`.
///
/// This enables applications to reuse `TextureId`s.
pub fn update_egui_texture_from_wgpu_texture(
&mut self,
device: &wgpu::Device,
texture: &wgpu::TextureView,
texture_filter: wgpu::FilterMode,
id: epaint::TextureId,
) {
self.update_egui_texture_from_wgpu_texture_with_sampler_options(
device,
texture,
wgpu::SamplerDescriptor {
label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()),
mag_filter: texture_filter,
min_filter: texture_filter,
..Default::default()
},
id,
);
}
/// Registers a `wgpu::Texture` with a `epaint::TextureId` while also accepting custom
/// `wgpu::SamplerDescriptor` options.
///
/// This allows applications to specify individual minification/magnification filters as well as
/// custom mipmap and tiling options.
///
/// The `Texture` must have the format `TextureFormat::Rgba8UnormSrgb` and usage
/// `TextureUsage::SAMPLED`. Any compare function supplied in the `SamplerDescriptor` will be
/// ignored.
#[allow(clippy::needless_pass_by_value)] // false positive
pub fn register_native_texture_with_sampler_options(
&mut self,
device: &wgpu::Device,
texture: &wgpu::TextureView,
sampler_descriptor: wgpu::SamplerDescriptor<'_>,
) -> epaint::TextureId {
crate::profile_function!();
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
compare: None,
..sampler_descriptor
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()),
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(texture),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let id = epaint::TextureId::User(self.next_user_texture_id);
self.textures.insert(id, (None, bind_group));
self.next_user_texture_id += 1;
id
}
/// Registers a `wgpu::Texture` with an existing `epaint::TextureId` while also accepting custom
/// `wgpu::SamplerDescriptor` options.
///
/// This allows applications to reuse `TextureId`s created with custom sampler options.
#[allow(clippy::needless_pass_by_value)] // false positive
pub fn update_egui_texture_from_wgpu_texture_with_sampler_options(
&mut self,
device: &wgpu::Device,
texture: &wgpu::TextureView,
sampler_descriptor: wgpu::SamplerDescriptor<'_>,
id: epaint::TextureId,
) {
crate::profile_function!();
let (_user_texture, user_texture_binding) = self
.textures
.get_mut(&id)
.expect("Tried to update a texture that has not been allocated yet.");
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
compare: None,
..sampler_descriptor
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()),
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(texture),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
*user_texture_binding = bind_group;
}
/// Uploads the uniform, vertex and index data used by the renderer.
/// Should be called before `render()`.
///
/// Returns all user-defined command buffers gathered from prepare callbacks.
pub fn update_buffers(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
paint_jobs: &[epaint::ClippedPrimitive],
screen_descriptor: &ScreenDescriptor,
) -> Vec<wgpu::CommandBuffer> {
crate::profile_function!();
let screen_size_in_points = screen_descriptor.screen_size_in_points();
{
crate::profile_scope!("uniforms");
// Update uniform buffer
queue.write_buffer(
&self.uniform_buffer,
0,
bytemuck::cast_slice(&[UniformBuffer {
screen_size_in_points,
_padding: Default::default(),
}]),
);
}
// Determine how many vertices & indices need to be rendered.
let (vertex_count, index_count) = {
crate::profile_scope!("count_vertices_indices");
paint_jobs.iter().fold((0, 0), |acc, clipped_primitive| {
match &clipped_primitive.primitive {
Primitive::Mesh(mesh) => {
(acc.0 + mesh.vertices.len(), acc.1 + mesh.indices.len())
}
Primitive::Callback(_) => acc,
}
})
};
{
// Resize index buffer if needed:
self.index_buffer.slices.clear();
let required_size = (std::mem::size_of::<u32>() * index_count) as u64;
if self.index_buffer.capacity < required_size {
self.index_buffer.capacity =
(self.index_buffer.capacity * 2).at_least(required_size);
self.index_buffer.buffer = create_index_buffer(device, self.index_buffer.capacity);
}
}
{
// Resize vertex buffer if needed:
self.vertex_buffer.slices.clear();
let required_size = (std::mem::size_of::<Vertex>() * vertex_count) as u64;
if self.vertex_buffer.capacity < required_size {
self.vertex_buffer.capacity =
(self.vertex_buffer.capacity * 2).at_least(required_size);
self.vertex_buffer.buffer =
create_vertex_buffer(device, self.vertex_buffer.capacity);
}
}
// Upload index & vertex data and call user callbacks
let mut user_cmd_bufs = Vec::new(); // collect user command buffers
crate::profile_scope!("primitives");
for epaint::ClippedPrimitive { primitive, .. } in paint_jobs.iter() {
match primitive {
Primitive::Mesh(mesh) => {
{
let index_offset = self.index_buffer.slices.last().unwrap_or(&(0..0)).end;
let data = bytemuck::cast_slice(&mesh.indices);
queue.write_buffer(&self.index_buffer.buffer, index_offset, data);
self.index_buffer
.slices
.push(index_offset..(data.len() as wgpu::BufferAddress + index_offset));
}
{
let vertex_offset = self.vertex_buffer.slices.last().unwrap_or(&(0..0)).end;
let data = bytemuck::cast_slice(&mesh.vertices);
queue.write_buffer(&self.vertex_buffer.buffer, vertex_offset, data);
self.vertex_buffer.slices.push(
vertex_offset..(data.len() as wgpu::BufferAddress + vertex_offset),
);
}
}
Primitive::Callback(callback) => {
let cbfn = if let Some(c) = callback.callback.downcast_ref::<CallbackFn>() {
c
} else {
tracing::warn!("Unknown paint callback: expected `egui_wgpu::CallbackFn`");
continue;
};
crate::profile_scope!("callback");
user_cmd_bufs.extend((cbfn.prepare)(
device,
queue,
encoder,
&mut self.paint_callback_resources,
));
}
}
}
user_cmd_bufs
}
}
fn create_sampler(
options: epaint::textures::TextureOptions,
device: &wgpu::Device,
) -> wgpu::Sampler {
let mag_filter = match options.magnification {
epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest,
epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear,
};
let min_filter = match options.minification {
epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest,
epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear,
};
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&format!(
"egui sampler (mag: {:?}, min {:?})",
mag_filter, min_filter
)),
mag_filter,
min_filter,
..Default::default()
})
}
fn create_vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
crate::profile_function!();
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("egui_vertex_buffer"),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
size,
mapped_at_creation: false,
})
}
fn create_index_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
crate::profile_function!();
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("egui_index_buffer"),
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
size,
mapped_at_creation: false,
})
}
/// A Rect in physical pixel space, used for setting cliipping rectangles.
struct ScissorRect {
x: u32,
y: u32,
width: u32,
height: u32,
}
impl ScissorRect {
fn new(clip_rect: &epaint::Rect, pixels_per_point: f32, target_size: [u32; 2]) -> Self {
// Transform clip rect to physical pixels:
let clip_min_x = pixels_per_point * clip_rect.min.x;
let clip_min_y = pixels_per_point * clip_rect.min.y;
let clip_max_x = pixels_per_point * clip_rect.max.x;
let clip_max_y = pixels_per_point * clip_rect.max.y;
// Round to integer:
let clip_min_x = clip_min_x.round() as u32;
let clip_min_y = clip_min_y.round() as u32;
let clip_max_x = clip_max_x.round() as u32;
let clip_max_y = clip_max_y.round() as u32;
// Clamp:
let clip_min_x = clip_min_x.clamp(0, target_size[0]);
let clip_min_y = clip_min_y.clamp(0, target_size[1]);
let clip_max_x = clip_max_x.clamp(clip_min_x, target_size[0]);
let clip_max_y = clip_max_y.clamp(clip_min_y, target_size[1]);
Self {
x: clip_min_x,
y: clip_min_y,
width: clip_max_x - clip_min_x,
height: clip_max_y - clip_min_y,
}
}
}
#[test]
fn renderer_impl_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Renderer>();
}

View file

@ -0,0 +1,418 @@
use std::sync::Arc;
use epaint::mutex::RwLock;
use tracing::error;
use crate::{renderer, RenderState, Renderer, SurfaceErrorAction, WgpuConfiguration};
struct SurfaceState {
surface: wgpu::Surface,
alpha_mode: wgpu::CompositeAlphaMode,
width: u32,
height: u32,
}
/// Everything you need to paint egui with [`wgpu`] on [`winit`].
///
/// Alternatively you can use [`crate::renderer`] directly.
pub struct Painter {
configuration: WgpuConfiguration,
msaa_samples: u32,
support_transparent_backbuffer: bool,
depth_format: Option<wgpu::TextureFormat>,
depth_texture_view: Option<wgpu::TextureView>,
instance: wgpu::Instance,
adapter: Option<wgpu::Adapter>,
render_state: Option<RenderState>,
surface_state: Option<SurfaceState>,
}
impl Painter {
/// Manages [`wgpu`] state, including surface state, required to render egui.
///
/// Only the [`wgpu::Instance`] is initialized here. Device selection and the initialization
/// of render + surface state is deferred until the painter is given its first window target
/// via [`set_window()`](Self::set_window). (Ensuring that a device that's compatible with the
/// native window is chosen)
///
/// Before calling [`paint_and_update_textures()`](Self::paint_and_update_textures) a
/// [`wgpu::Surface`] must be initialized (and corresponding render state) by calling
/// [`set_window()`](Self::set_window) once you have
/// a [`winit::window::Window`] with a valid `.raw_window_handle()`
/// associated.
pub fn new(
configuration: WgpuConfiguration,
msaa_samples: u32,
depth_bits: u8,
support_transparent_backbuffer: bool,
) -> Self {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: configuration.backends,
dx12_shader_compiler: Default::default(), //
});
Self {
configuration,
msaa_samples,
support_transparent_backbuffer,
depth_format: (depth_bits > 0).then_some(wgpu::TextureFormat::Depth32Float),
depth_texture_view: None,
instance,
adapter: None,
render_state: None,
surface_state: None,
}
}
/// Get the [`RenderState`].
///
/// Will return [`None`] if the render state has not been initialized yet.
pub fn render_state(&self) -> Option<RenderState> {
self.render_state.clone()
}
async fn init_render_state(
&self,
adapter: &wgpu::Adapter,
target_format: wgpu::TextureFormat,
) -> Result<RenderState, wgpu::RequestDeviceError> {
adapter
.request_device(&self.configuration.device_descriptor, None)
.await
.map(|(device, queue)| {
let renderer =
Renderer::new(&device, target_format, self.depth_format, self.msaa_samples);
RenderState {
device: Arc::new(device),
queue: Arc::new(queue),
target_format,
renderer: Arc::new(RwLock::new(renderer)),
}
})
}
// We want to defer the initialization of our render state until we have a surface
// so we can take its format into account.
//
// After we've initialized our render state once though we expect all future surfaces
// will have the same format and so this render state will remain valid.
async fn ensure_render_state_for_surface(
&mut self,
surface: &wgpu::Surface,
) -> Result<(), wgpu::RequestDeviceError> {
if self.adapter.is_none() {
self.adapter = self
.instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: self.configuration.power_preference,
compatible_surface: Some(surface),
force_fallback_adapter: false,
})
.await;
}
if self.render_state.is_none() {
match &self.adapter {
Some(adapter) => {
let swapchain_format = crate::preferred_framebuffer_format(
&surface.get_capabilities(adapter).formats,
);
let rs = self.init_render_state(adapter, swapchain_format).await?;
self.render_state = Some(rs);
}
None => return Err(wgpu::RequestDeviceError {}),
}
}
Ok(())
}
fn configure_surface(
surface_state: &SurfaceState,
render_state: &RenderState,
present_mode: wgpu::PresentMode,
) {
surface_state.surface.configure(
&render_state.device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: render_state.target_format,
width: surface_state.width,
height: surface_state.height,
present_mode,
alpha_mode: surface_state.alpha_mode,
view_formats: vec![render_state.target_format],
},
);
}
/// Updates (or clears) the [`winit::window::Window`] associated with the [`Painter`]
///
/// This creates a [`wgpu::Surface`] for the given Window (as well as initializing render
/// state if needed) that is used for egui rendering.
///
/// This must be called before trying to render via
/// [`paint_and_update_textures`](Self::paint_and_update_textures)
///
/// # Portability
///
/// _In particular it's important to note that on Android a it's only possible to create
/// a window surface between `Resumed` and `Paused` lifecycle events, and Winit will panic on
/// attempts to query the raw window handle while paused._
///
/// On Android [`set_window`](Self::set_window) should be called with `Some(window)` for each
/// `Resumed` event and `None` for each `Paused` event. Currently, on all other platforms
/// [`set_window`](Self::set_window) may be called with `Some(window)` as soon as you have a
/// valid [`winit::window::Window`].
///
/// # Safety
///
/// The raw Window handle associated with the given `window` must be a valid object to create a
/// surface upon and must remain valid for the lifetime of the created surface. (The surface may
/// be cleared by passing `None`).
///
/// # Errors
/// If the provided wgpu configuration does not match an available device.
pub async unsafe fn set_window(
&mut self,
window: Option<&winit::window::Window>,
) -> Result<(), crate::WgpuError> {
match window {
Some(window) => {
let surface = self.instance.create_surface(&window)?;
self.ensure_render_state_for_surface(&surface).await?;
let alpha_mode = if self.support_transparent_backbuffer {
let supported_alpha_modes = surface
.get_capabilities(self.adapter.as_ref().unwrap())
.alpha_modes;
// Prefer pre multiplied over post multiplied!
if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {
wgpu::CompositeAlphaMode::PreMultiplied
} else if supported_alpha_modes
.contains(&wgpu::CompositeAlphaMode::PostMultiplied)
{
wgpu::CompositeAlphaMode::PostMultiplied
} else {
tracing::warn!("Transparent window was requested, but the active wgpu surface does not support a `CompositeAlphaMode` with transparency.");
wgpu::CompositeAlphaMode::Auto
}
} else {
wgpu::CompositeAlphaMode::Auto
};
let size = window.inner_size();
self.surface_state = Some(SurfaceState {
surface,
width: size.width,
height: size.height,
alpha_mode,
});
self.resize_and_generate_depth_texture_view(size.width, size.height);
}
None => {
self.surface_state = None;
}
}
Ok(())
}
/// Returns the maximum texture dimension supported if known
///
/// This API will only return a known dimension after `set_window()` has been called
/// at least once, since the underlying device and render state are initialized lazily
/// once we have a window (that may determine the choice of adapter/device).
pub fn max_texture_side(&self) -> Option<usize> {
self.render_state
.as_ref()
.map(|rs| rs.device.limits().max_texture_dimension_2d as usize)
}
fn resize_and_generate_depth_texture_view(
&mut self,
width_in_pixels: u32,
height_in_pixels: u32,
) {
let render_state = self.render_state.as_ref().unwrap();
let surface_state = self.surface_state.as_mut().unwrap();
surface_state.width = width_in_pixels;
surface_state.height = height_in_pixels;
Self::configure_surface(surface_state, render_state, self.configuration.present_mode);
self.depth_texture_view = self.depth_format.map(|depth_format| {
render_state
.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("egui_depth_texture"),
size: wgpu::Extent3d {
width: width_in_pixels,
height: height_in_pixels,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: depth_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[depth_format],
})
.create_view(&wgpu::TextureViewDescriptor::default())
});
}
pub fn on_window_resized(&mut self, width_in_pixels: u32, height_in_pixels: u32) {
if self.surface_state.is_some() {
self.resize_and_generate_depth_texture_view(width_in_pixels, height_in_pixels);
} else {
error!("Ignoring window resize notification with no surface created via Painter::set_window()");
}
}
pub fn paint_and_update_textures(
&mut self,
pixels_per_point: f32,
clear_color: [f32; 4],
clipped_primitives: &[epaint::ClippedPrimitive],
textures_delta: &epaint::textures::TexturesDelta,
) {
crate::profile_function!();
let render_state = match self.render_state.as_mut() {
Some(rs) => rs,
None => return,
};
let surface_state = match self.surface_state.as_ref() {
Some(rs) => rs,
None => return,
};
let output_frame = {
crate::profile_scope!("get_current_texture");
// This is what vsync-waiting happens, at least on Mac.
surface_state.surface.get_current_texture()
};
let output_frame = match output_frame {
Ok(frame) => frame,
#[allow(clippy::single_match_else)]
Err(e) => match (*self.configuration.on_surface_error)(e) {
SurfaceErrorAction::RecreateSurface => {
Self::configure_surface(
surface_state,
render_state,
self.configuration.present_mode,
);
return;
}
SurfaceErrorAction::SkipFrame => {
return;
}
},
};
let mut encoder =
render_state
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("encoder"),
});
// Upload all resources for the GPU.
let screen_descriptor = renderer::ScreenDescriptor {
size_in_pixels: [surface_state.width, surface_state.height],
pixels_per_point,
};
let user_cmd_bufs = {
let mut renderer = render_state.renderer.write();
for (id, image_delta) in &textures_delta.set {
renderer.update_texture(
&render_state.device,
&render_state.queue,
*id,
image_delta,
);
}
renderer.update_buffers(
&render_state.device,
&render_state.queue,
&mut encoder,
clipped_primitives,
&screen_descriptor,
)
};
{
let renderer = render_state.renderer.read();
let frame_view = output_frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: clear_color[0] as f64,
g: clear_color[1] as f64,
b: clear_color[2] as f64,
a: clear_color[3] as f64,
}),
store: true,
},
})],
depth_stencil_attachment: self.depth_texture_view.as_ref().map(|view| {
wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: true,
}),
stencil_ops: None,
}
}),
label: Some("egui_render"),
});
renderer.render(&mut render_pass, clipped_primitives, &screen_descriptor);
}
{
let mut renderer = render_state.renderer.write();
for id in &textures_delta.free {
renderer.free_texture(id);
}
}
let encoded = {
crate::profile_scope!("CommandEncoder::finish");
encoder.finish()
};
// Submit the commands: both the main buffer and user-defined ones.
{
crate::profile_scope!("Queue::submit");
render_state
.queue
.submit(user_cmd_bufs.into_iter().chain(std::iter::once(encoded)));
};
// Redraw egui
{
crate::profile_scope!("present");
output_frame.present();
}
}
#[allow(clippy::unused_self)]
pub fn destroy(&mut self) {
// TODO(emilk): something here?
}
}

View file

@ -0,0 +1,64 @@
# Changelog for egui-winit
All notable changes to the `egui-winit` integration will be noted in this file.
## Unreleased
## 0.21.1 - 2023-02-12
* Fixed crash when window position is in an invalid state, which could happen e.g. due to changes in monitor size or DPI ([#2722](https://github.com/emilk/egui/issues/2722)).
## 0.21.0 - 2023-02-08
* Fixed persistence of native window position on Windows OS ([#2583](https://github.com/emilk/egui/issues/2583)).
* Update to `winit` 0.28, adding support for mac trackpad zoom ([#2654](https://github.com/emilk/egui/pull/2654)).
* Remove the `screen_reader` feature. Use the `accesskit` feature flag instead ([#2669](https://github.com/emilk/egui/pull/2669)).
* Fix bug where the cursor could get stuck using the wrong icon.
## 0.20.1 - 2022-12-11
* Fix [docs.rs](https://docs.rs/egui-winit) build ([#2420](https://github.com/emilk/egui/pull/2420)).
## 0.20.0 - 2022-12-08
* The default features of the `winit` crate are not enabled if the default features of `egui-winit` are disabled too ([#1971](https://github.com/emilk/egui/pull/1971)).
* Added new feature `wayland` which enables Wayland support ([#1971](https://github.com/emilk/egui/pull/1971)).
* Don't repaint when just moving window ([#1980](https://github.com/emilk/egui/pull/1980)).
* Added optional integration with [AccessKit](https://accesskit.dev/) for implementing platform accessibility APIs ([#2294](https://github.com/emilk/egui/pull/2294)).
## 0.19.0 - 2022-08-20
* MSRV (Minimum Supported Rust Version) is now `1.61.0` ([#1846](https://github.com/emilk/egui/pull/1846)).
* Fixed clipboard on Wayland ([#1613](https://github.com/emilk/egui/pull/1613)).
* Allow deferred render + surface state initialization for Android ([#1634](https://github.com/emilk/egui/pull/1634)).
* Fixed window position persistence ([#1745](https://github.com/emilk/egui/pull/1745)).
* Fixed mouse cursor change on Linux ([#1747](https://github.com/emilk/egui/pull/1747)).
* Use the new `RawInput::has_focus` field to indicate whether the window has the keyboard focus ([#1859](https://github.com/emilk/egui/pull/1859)).
## 0.18.0 - 2022-04-30
* Reexport `egui` crate
* MSRV (Minimum Supported Rust Version) is now `1.60.0` ([#1467](https://github.com/emilk/egui/pull/1467)).
* Added new feature `puffin` to add [`puffin profiler`](https://github.com/EmbarkStudios/puffin) scopes ([#1483](https://github.com/emilk/egui/pull/1483)).
* Renamed the feature `convert_bytemuck` to `bytemuck` ([#1467](https://github.com/emilk/egui/pull/1467)).
* Renamed the feature `serialize` to `serde` ([#1467](https://github.com/emilk/egui/pull/1467)).
* Removed the features `dark-light` and `persistence` ([#1542](https://github.com/emilk/egui/pull/1542)).
## 0.17.0 - 2022-02-22
* Fixed horizontal scrolling direction on Linux.
* Replaced `std::time::Instant` with `instant::Instant` for WebAssembly compatability ([#1023](https://github.com/emilk/egui/pull/1023))
* Automatically detect and apply dark or light mode from system ([#1045](https://github.com/emilk/egui/pull/1045)).
* Fixed `enable_drag` on Windows OS ([#1108](https://github.com/emilk/egui/pull/1108)).
* Shift-scroll will now result in horizontal scrolling on all platforms ([#1136](https://github.com/emilk/egui/pull/1136)).
* Require knowledge about max texture side (e.g. `GL_MAX_TEXTURE_SIZE`)) ([#1154](https://github.com/emilk/egui/pull/1154)).
## 0.16.0 - 2021-12-29
* Added helper `EpiIntegration` ([#871](https://github.com/emilk/egui/pull/871)).
* Fixed shift key getting stuck enabled with the X11 option `shift:both_capslock` enabled ([#849](https://github.com/emilk/egui/pull/849)).
* Removed `State::is_quit_event` and `State::is_quit_shortcut` ([#881](https://github.com/emilk/egui/pull/881)).
* Updated `winit` to 0.26 ([#930](https://github.com/emilk/egui/pull/930)).
## 0.15.0 - 2021-10-24
First stand-alone release. Previously part of `egui_glium`.

View file

@ -0,0 +1,76 @@
[package]
name = "egui-winit"
version = "0.21.1"
authors = ["Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
description = "Bindings for using egui with winit"
edition = "2021"
rust-version = "1.65"
homepage = "https://github.com/emilk/egui/tree/master/crates/egui-winit"
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/emilk/egui/tree/master/crates/egui-winit"
categories = ["gui", "game-development"]
keywords = ["winit", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[package.metadata.docs.rs]
all-features = true
[features]
default = ["clipboard", "links", "wayland", "winit/default"]
## Enable platform accessibility API implementations through [AccessKit](https://accesskit.dev/).
accesskit = ["accesskit_winit", "egui/accesskit"]
## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`egui::epaint::Vertex`], [`egui::Vec2`] etc to `&[u8]`.
bytemuck = ["egui/bytemuck"]
## Enable cut/copy/paste to OS clipboard.
## If disabled a clipboard will be simulated so you can still copy/paste within the egui app.
clipboard = ["arboard", "smithay-clipboard"]
## Enable opening links in a browser when an egui hyperlink is clicked.
links = ["webbrowser"]
## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate.
puffin = ["dep:puffin"]
## Allow serialization of [`WindowSettings`] using [`serde`](https://docs.rs/serde).
serde = ["egui/serde", "dep:serde"]
## Enables Wayland support.
wayland = ["winit/wayland"]
[dependencies]
egui = { version = "0.21.0", path = "../egui", default-features = false, features = [
"tracing",
] }
instant = { version = "0.1", features = [
"wasm-bindgen",
] } # We use instant so we can (maybe) compile for web
tracing = { version = "0.1", default-features = false, features = ["std"] }
winit = { version = "0.28", default-features = false }
#! ### Optional dependencies
# feature accesskit
accesskit_winit = { version = "0.10.0", optional = true }
## Enable this when generating docs.
document-features = { version = "0.2", optional = true }
puffin = { version = "0.14", optional = true }
serde = { version = "1.0", optional = true, features = ["derive"] }
webbrowser = { version = "0.8.3", optional = true }
[target.'cfg(any(target_os="linux", target_os="dragonfly", target_os="freebsd", target_os="netbsd", target_os="openbsd"))'.dependencies]
smithay-clipboard = { version = "0.6.3", optional = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { version = "3.2", optional = true, default-features = false }
[target.'cfg(target_os = "android")'.dependencies]
# TODO(emilk): this is probably not the right place for specifying native-activity, but we need to do it somewhere for the CI
android-activity = { version = "0.4", features = ["native-activity"] }

View file

@ -0,0 +1,10 @@
# egui-winit
[![Latest version](https://img.shields.io/crates/v/egui-winit.svg)](https://crates.io/crates/egui-winit)
[![Documentation](https://docs.rs/egui-winit/badge.svg)](https://docs.rs/egui-winit)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [`winit`](https://crates.io/crates/winit).
The library translates winit events to egui, handled copy/paste, updates the cursor, open links clicked in egui, etc.

View file

@ -0,0 +1,146 @@
use std::os::raw::c_void;
/// Handles interfacing with the OS clipboard.
///
/// If the "clipboard" feature is off, or we cannot connect to the OS clipboard,
/// then a fallback clipboard that just works works within the same app is used instead.
pub struct Clipboard {
#[cfg(all(feature = "arboard", not(target_os = "android")))]
arboard: Option<arboard::Clipboard>,
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
smithay: Option<smithay_clipboard::Clipboard>,
/// Fallback manual clipboard.
clipboard: String,
}
impl Clipboard {
#[allow(unused_variables)]
pub fn new(#[allow(unused_variables)] wayland_display: Option<*mut c_void>) -> Self {
Self {
#[cfg(all(feature = "arboard", not(target_os = "android")))]
arboard: init_arboard(),
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
smithay: init_smithay_clipboard(wayland_display),
clipboard: Default::default(),
}
}
pub fn get(&mut self) -> Option<String> {
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
if let Some(clipboard) = &mut self.smithay {
return match clipboard.load() {
Ok(text) => Some(text),
Err(err) => {
tracing::error!("smithay paste error: {err}");
None
}
};
}
#[cfg(all(feature = "arboard", not(target_os = "android")))]
if let Some(clipboard) = &mut self.arboard {
return match clipboard.get_text() {
Ok(text) => Some(text),
Err(err) => {
tracing::error!("arboard paste error: {err}");
None
}
};
}
Some(self.clipboard.clone())
}
pub fn set(&mut self, text: String) {
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
if let Some(clipboard) = &mut self.smithay {
clipboard.store(text);
return;
}
#[cfg(all(feature = "arboard", not(target_os = "android")))]
if let Some(clipboard) = &mut self.arboard {
if let Err(err) = clipboard.set_text(text) {
tracing::error!("arboard copy/cut error: {err}");
}
return;
}
self.clipboard = text;
}
}
#[cfg(all(feature = "arboard", not(target_os = "android")))]
fn init_arboard() -> Option<arboard::Clipboard> {
tracing::debug!("Initializing arboard clipboard…");
match arboard::Clipboard::new() {
Ok(clipboard) => Some(clipboard),
Err(err) => {
tracing::warn!("Failed to initialize arboard clipboard: {err}");
None
}
}
}
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
fn init_smithay_clipboard(
wayland_display: Option<*mut c_void>,
) -> Option<smithay_clipboard::Clipboard> {
if let Some(display) = wayland_display {
tracing::debug!("Initializing smithay clipboard…");
#[allow(unsafe_code)]
Some(unsafe { smithay_clipboard::Clipboard::new(display) })
} else {
tracing::debug!("Cannot initialize smithay clipboard without a display handle");
None
}
}

View file

@ -0,0 +1,920 @@
//! [`egui`] bindings for [`winit`](https://github.com/rust-windowing/winit).
//!
//! The library translates winit events to egui, handled copy/paste,
//! updates the cursor, open links clicked in egui, etc.
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
#![allow(clippy::manual_range_contains)]
use std::os::raw::c_void;
#[cfg(feature = "accesskit")]
pub use accesskit_winit;
pub use egui;
#[cfg(feature = "accesskit")]
use egui::accesskit;
pub use winit;
pub mod clipboard;
mod window_settings;
pub use window_settings::WindowSettings;
use winit::event_loop::EventLoopWindowTarget;
pub fn native_pixels_per_point(window: &winit::window::Window) -> f32 {
window.scale_factor() as f32
}
pub fn screen_size_in_pixels(window: &winit::window::Window) -> egui::Vec2 {
let size = window.inner_size();
egui::vec2(size.width as f32, size.height as f32)
}
// ----------------------------------------------------------------------------
#[must_use]
pub struct EventResponse {
/// If true, egui consumed this event, i.e. wants exclusive use of this event
/// (e.g. a mouse click on an egui window, or entering text into a text field).
///
/// For instance, if you use egui for a game, you should only
/// pass on the events to your game when [`Self::consumed`] is `false.
///
/// Note that egui uses `tab` to move focus between elements, so this will always be `true` for tabs.
pub consumed: bool,
/// Do we need an egui refresh because of this event?
pub repaint: bool,
}
// ----------------------------------------------------------------------------
/// Handles the integration between egui and winit.
pub struct State {
start_time: instant::Instant,
egui_input: egui::RawInput,
pointer_pos_in_points: Option<egui::Pos2>,
any_pointer_button_down: bool,
current_cursor_icon: Option<egui::CursorIcon>,
/// What egui uses.
current_pixels_per_point: f32,
clipboard: clipboard::Clipboard,
/// If `true`, mouse inputs will be treated as touches.
/// Useful for debugging touch support in egui.
///
/// Creates duplicate touches, if real touch inputs are coming.
simulate_touch_screen: bool,
/// Is Some(…) when a touch is being translated to a pointer.
///
/// Only one touch will be interpreted as pointer at any time.
pointer_touch_id: Option<u64>,
/// track ime state
input_method_editor_started: bool,
#[cfg(feature = "accesskit")]
accesskit: Option<accesskit_winit::Adapter>,
}
impl State {
pub fn new<T>(event_loop: &EventLoopWindowTarget<T>) -> Self {
Self::new_with_wayland_display(wayland_display(event_loop))
}
pub fn new_with_wayland_display(wayland_display: Option<*mut c_void>) -> Self {
let egui_input = egui::RawInput {
has_focus: false, // winit will tell us when we have focus
..Default::default()
};
Self {
start_time: instant::Instant::now(),
egui_input,
pointer_pos_in_points: None,
any_pointer_button_down: false,
current_cursor_icon: None,
current_pixels_per_point: 1.0,
clipboard: clipboard::Clipboard::new(wayland_display),
simulate_touch_screen: false,
pointer_touch_id: None,
input_method_editor_started: false,
#[cfg(feature = "accesskit")]
accesskit: None,
}
}
#[cfg(feature = "accesskit")]
pub fn init_accesskit<T: From<accesskit_winit::ActionRequestEvent> + Send>(
&mut self,
window: &winit::window::Window,
event_loop_proxy: winit::event_loop::EventLoopProxy<T>,
initial_tree_update_factory: impl 'static + FnOnce() -> accesskit::TreeUpdate + Send,
) {
self.accesskit = Some(accesskit_winit::Adapter::new(
window,
initial_tree_update_factory,
event_loop_proxy,
));
}
/// Call this once a graphics context has been created to update the maximum texture dimensions
/// that egui will use.
pub fn set_max_texture_side(&mut self, max_texture_side: usize) {
self.egui_input.max_texture_side = Some(max_texture_side);
}
/// Call this when a new native Window is created for rendering to initialize the `pixels_per_point`
/// for that window.
///
/// In particular, on Android it is necessary to call this after each `Resumed` lifecycle
/// event, each time a new native window is created.
///
/// Once this has been initialized for a new window then this state will be maintained by handling
/// [`winit::event::WindowEvent::ScaleFactorChanged`] events.
pub fn set_pixels_per_point(&mut self, pixels_per_point: f32) {
self.egui_input.pixels_per_point = Some(pixels_per_point);
self.current_pixels_per_point = pixels_per_point;
}
/// The number of physical pixels per logical point,
/// as configured on the current egui context (see [`egui::Context::pixels_per_point`]).
#[inline]
pub fn pixels_per_point(&self) -> f32 {
self.current_pixels_per_point
}
/// The current input state.
/// This is changed by [`Self::on_event`] and cleared by [`Self::take_egui_input`].
#[inline]
pub fn egui_input(&self) -> &egui::RawInput {
&self.egui_input
}
/// Prepare for a new frame by extracting the accumulated input,
/// as well as setting [the time](egui::RawInput::time) and [screen rectangle](egui::RawInput::screen_rect).
pub fn take_egui_input(&mut self, window: &winit::window::Window) -> egui::RawInput {
let pixels_per_point = self.pixels_per_point();
self.egui_input.time = Some(self.start_time.elapsed().as_secs_f64());
// On Windows, a minimized window will have 0 width and height.
// See: https://github.com/rust-windowing/winit/issues/208
// This solves an issue where egui window positions would be changed when minimizing on Windows.
let screen_size_in_pixels = screen_size_in_pixels(window);
let screen_size_in_points = screen_size_in_pixels / pixels_per_point;
self.egui_input.screen_rect =
if screen_size_in_points.x > 0.0 && screen_size_in_points.y > 0.0 {
Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
screen_size_in_points,
))
} else {
None
};
self.egui_input.take()
}
/// Call this when there is a new event.
///
/// The result can be found in [`Self::egui_input`] and be extracted with [`Self::take_egui_input`].
pub fn on_event(
&mut self,
egui_ctx: &egui::Context,
event: &winit::event::WindowEvent<'_>,
) -> EventResponse {
use winit::event::WindowEvent;
match event {
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
let pixels_per_point = *scale_factor as f32;
self.egui_input.pixels_per_point = Some(pixels_per_point);
self.current_pixels_per_point = pixels_per_point;
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::MouseInput { state, button, .. } => {
self.on_mouse_button_input(*state, *button);
EventResponse {
repaint: true,
consumed: egui_ctx.wants_pointer_input(),
}
}
WindowEvent::MouseWheel { delta, .. } => {
self.on_mouse_wheel(*delta);
EventResponse {
repaint: true,
consumed: egui_ctx.wants_pointer_input(),
}
}
WindowEvent::CursorMoved { position, .. } => {
self.on_cursor_moved(*position);
EventResponse {
repaint: true,
consumed: egui_ctx.is_using_pointer(),
}
}
WindowEvent::CursorLeft { .. } => {
self.pointer_pos_in_points = None;
self.egui_input.events.push(egui::Event::PointerGone);
EventResponse {
repaint: true,
consumed: false,
}
}
// WindowEvent::TouchpadPressure {device_id, pressure, stage, .. } => {} // TODO
WindowEvent::Touch(touch) => {
self.on_touch(touch);
let consumed = match touch.phase {
winit::event::TouchPhase::Started
| winit::event::TouchPhase::Ended
| winit::event::TouchPhase::Cancelled => egui_ctx.wants_pointer_input(),
winit::event::TouchPhase::Moved => egui_ctx.is_using_pointer(),
};
EventResponse {
repaint: true,
consumed,
}
}
WindowEvent::ReceivedCharacter(ch) => {
// On Mac we get here when the user presses Cmd-C (copy), ctrl-W, etc.
// We need to ignore these characters that are side-effects of commands.
let is_mac_cmd = cfg!(target_os = "macos")
&& (self.egui_input.modifiers.ctrl || self.egui_input.modifiers.mac_cmd);
let consumed = if is_printable_char(*ch) && !is_mac_cmd {
self.egui_input
.events
.push(egui::Event::Text(ch.to_string()));
egui_ctx.wants_keyboard_input()
} else {
false
};
EventResponse {
repaint: true,
consumed,
}
}
WindowEvent::Ime(ime) => {
// on Mac even Cmd-C is preessed during ime, a `c` is pushed to Preedit.
// So no need to check is_mac_cmd.
//
// How winit produce `Ime::Enabled` and `Ime::Disabled` differs in MacOS
// and Windows.
//
// - On Windows, before and after each Commit will produce an Enable/Disabled
// event.
// - On MacOS, only when user explicit enable/disable ime. No Disabled
// after Commit.
//
// We use input_method_editor_started to mannualy insert CompositionStart
// between Commits.
match ime {
winit::event::Ime::Enabled | winit::event::Ime::Disabled => (),
winit::event::Ime::Commit(text) => {
self.input_method_editor_started = false;
self.egui_input
.events
.push(egui::Event::CompositionEnd(text.clone()));
}
winit::event::Ime::Preedit(text, ..) => {
if !self.input_method_editor_started {
self.input_method_editor_started = true;
self.egui_input.events.push(egui::Event::CompositionStart);
}
self.egui_input
.events
.push(egui::Event::CompositionUpdate(text.clone()));
}
};
EventResponse {
repaint: true,
consumed: egui_ctx.wants_keyboard_input(),
}
}
WindowEvent::KeyboardInput { input, .. } => {
self.on_keyboard_input(input);
let consumed = egui_ctx.wants_keyboard_input()
|| input.virtual_keycode == Some(winit::event::VirtualKeyCode::Tab);
EventResponse {
repaint: true,
consumed,
}
}
WindowEvent::Focused(has_focus) => {
self.egui_input.has_focus = *has_focus;
// We will not be given a KeyboardInput event when the modifiers are released while
// the window does not have focus. Unset all modifier state to be safe.
self.egui_input.modifiers = egui::Modifiers::default();
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::HoveredFile(path) => {
self.egui_input.hovered_files.push(egui::HoveredFile {
path: Some(path.clone()),
..Default::default()
});
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::HoveredFileCancelled => {
self.egui_input.hovered_files.clear();
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::DroppedFile(path) => {
self.egui_input.hovered_files.clear();
self.egui_input.dropped_files.push(egui::DroppedFile {
path: Some(path.clone()),
..Default::default()
});
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::ModifiersChanged(state) => {
self.egui_input.modifiers.alt = state.alt();
self.egui_input.modifiers.ctrl = state.ctrl();
self.egui_input.modifiers.shift = state.shift();
self.egui_input.modifiers.mac_cmd = cfg!(target_os = "macos") && state.logo();
self.egui_input.modifiers.command = if cfg!(target_os = "macos") {
state.logo()
} else {
state.ctrl()
};
EventResponse {
repaint: true,
consumed: false,
}
}
// Things that may require repaint:
WindowEvent::CloseRequested
| WindowEvent::CursorEntered { .. }
| WindowEvent::Destroyed
| WindowEvent::Occluded(_)
| WindowEvent::Resized(_)
| WindowEvent::ThemeChanged(_)
| WindowEvent::TouchpadPressure { .. } => EventResponse {
repaint: true,
consumed: false,
},
// Things we completely ignore:
WindowEvent::AxisMotion { .. }
| WindowEvent::Moved(_)
| WindowEvent::SmartMagnify { .. }
| WindowEvent::TouchpadRotate { .. } => EventResponse {
repaint: false,
consumed: false,
},
WindowEvent::TouchpadMagnify { delta, .. } => {
// Positive delta values indicate magnification (zooming in).
// Negative delta values indicate shrinking (zooming out).
let zoom_factor = (*delta as f32).exp();
self.egui_input.events.push(egui::Event::Zoom(zoom_factor));
EventResponse {
repaint: true,
consumed: egui_ctx.wants_pointer_input(),
}
}
}
}
/// Call this when there is a new [`accesskit::ActionRequest`].
///
/// The result can be found in [`Self::egui_input`] and be extracted with [`Self::take_egui_input`].
#[cfg(feature = "accesskit")]
pub fn on_accesskit_action_request(&mut self, request: accesskit::ActionRequest) {
self.egui_input
.events
.push(egui::Event::AccessKitActionRequest(request));
}
fn on_mouse_button_input(
&mut self,
state: winit::event::ElementState,
button: winit::event::MouseButton,
) {
if let Some(pos) = self.pointer_pos_in_points {
if let Some(button) = translate_mouse_button(button) {
let pressed = state == winit::event::ElementState::Pressed;
self.egui_input.events.push(egui::Event::PointerButton {
pos,
button,
pressed,
modifiers: self.egui_input.modifiers,
});
if self.simulate_touch_screen {
if pressed {
self.any_pointer_button_down = true;
self.egui_input.events.push(egui::Event::Touch {
device_id: egui::TouchDeviceId(0),
id: egui::TouchId(0),
phase: egui::TouchPhase::Start,
pos,
force: 0.0,
});
} else {
self.any_pointer_button_down = false;
self.egui_input.events.push(egui::Event::PointerGone);
self.egui_input.events.push(egui::Event::Touch {
device_id: egui::TouchDeviceId(0),
id: egui::TouchId(0),
phase: egui::TouchPhase::End,
pos,
force: 0.0,
});
};
}
}
}
}
fn on_cursor_moved(&mut self, pos_in_pixels: winit::dpi::PhysicalPosition<f64>) {
let pos_in_points = egui::pos2(
pos_in_pixels.x as f32 / self.pixels_per_point(),
pos_in_pixels.y as f32 / self.pixels_per_point(),
);
self.pointer_pos_in_points = Some(pos_in_points);
if self.simulate_touch_screen {
if self.any_pointer_button_down {
self.egui_input
.events
.push(egui::Event::PointerMoved(pos_in_points));
self.egui_input.events.push(egui::Event::Touch {
device_id: egui::TouchDeviceId(0),
id: egui::TouchId(0),
phase: egui::TouchPhase::Move,
pos: pos_in_points,
force: 0.0,
});
}
} else {
self.egui_input
.events
.push(egui::Event::PointerMoved(pos_in_points));
}
}
fn on_touch(&mut self, touch: &winit::event::Touch) {
// Emit touch event
self.egui_input.events.push(egui::Event::Touch {
device_id: egui::TouchDeviceId(egui::epaint::util::hash(touch.device_id)),
id: egui::TouchId::from(touch.id),
phase: match touch.phase {
winit::event::TouchPhase::Started => egui::TouchPhase::Start,
winit::event::TouchPhase::Moved => egui::TouchPhase::Move,
winit::event::TouchPhase::Ended => egui::TouchPhase::End,
winit::event::TouchPhase::Cancelled => egui::TouchPhase::Cancel,
},
pos: egui::pos2(
touch.location.x as f32 / self.pixels_per_point(),
touch.location.y as f32 / self.pixels_per_point(),
),
force: match touch.force {
Some(winit::event::Force::Normalized(force)) => force as f32,
Some(winit::event::Force::Calibrated {
force,
max_possible_force,
..
}) => (force / max_possible_force) as f32,
None => 0_f32,
},
});
// If we're not yet tanslating a touch or we're translating this very
// touch …
if self.pointer_touch_id.is_none() || self.pointer_touch_id.unwrap() == touch.id {
// … emit PointerButton resp. PointerMoved events to emulate mouse
match touch.phase {
winit::event::TouchPhase::Started => {
self.pointer_touch_id = Some(touch.id);
// First move the pointer to the right location
self.on_cursor_moved(touch.location);
self.on_mouse_button_input(
winit::event::ElementState::Pressed,
winit::event::MouseButton::Left,
);
}
winit::event::TouchPhase::Moved => {
self.on_cursor_moved(touch.location);
}
winit::event::TouchPhase::Ended => {
self.pointer_touch_id = None;
self.on_mouse_button_input(
winit::event::ElementState::Released,
winit::event::MouseButton::Left,
);
// The pointer should vanish completely to not get any
// hover effects
self.pointer_pos_in_points = None;
self.egui_input.events.push(egui::Event::PointerGone);
}
winit::event::TouchPhase::Cancelled => {
self.pointer_touch_id = None;
self.pointer_pos_in_points = None;
self.egui_input.events.push(egui::Event::PointerGone);
}
}
}
}
fn on_mouse_wheel(&mut self, delta: winit::event::MouseScrollDelta) {
let delta = match delta {
winit::event::MouseScrollDelta::LineDelta(x, y) => {
let points_per_scroll_line = 50.0; // Scroll speed decided by consensus: https://github.com/emilk/egui/issues/461
egui::vec2(x, y) * points_per_scroll_line
}
winit::event::MouseScrollDelta::PixelDelta(delta) => {
egui::vec2(delta.x as f32, delta.y as f32) / self.pixels_per_point()
}
};
if self.egui_input.modifiers.ctrl || self.egui_input.modifiers.command {
// Treat as zoom instead:
let factor = (delta.y / 200.0).exp();
self.egui_input.events.push(egui::Event::Zoom(factor));
} else if self.egui_input.modifiers.shift {
// Treat as horizontal scrolling.
// Note: one Mac we already get horizontal scroll events when shift is down.
self.egui_input
.events
.push(egui::Event::Scroll(egui::vec2(delta.x + delta.y, 0.0)));
} else {
self.egui_input.events.push(egui::Event::Scroll(delta));
}
}
fn on_keyboard_input(&mut self, input: &winit::event::KeyboardInput) {
if let Some(keycode) = input.virtual_keycode {
let pressed = input.state == winit::event::ElementState::Pressed;
if pressed {
// VirtualKeyCode::Paste etc in winit are broken/untrustworthy,
// so we detect these things manually:
if is_cut_command(self.egui_input.modifiers, keycode) {
self.egui_input.events.push(egui::Event::Cut);
} else if is_copy_command(self.egui_input.modifiers, keycode) {
self.egui_input.events.push(egui::Event::Copy);
} else if is_paste_command(self.egui_input.modifiers, keycode) {
if let Some(contents) = self.clipboard.get() {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
self.egui_input.events.push(egui::Event::Paste(contents));
}
}
}
}
if let Some(key) = translate_virtual_key_code(keycode) {
self.egui_input.events.push(egui::Event::Key {
key,
pressed,
repeat: false, // egui will fill this in for us!
modifiers: self.egui_input.modifiers,
});
}
}
}
/// Call with the output given by `egui`.
///
/// This will, if needed:
/// * update the cursor
/// * copy text to the clipboard
/// * open any clicked urls
/// * update the IME
/// *
pub fn handle_platform_output(
&mut self,
window: &winit::window::Window,
egui_ctx: &egui::Context,
platform_output: egui::PlatformOutput,
) {
let egui::PlatformOutput {
cursor_icon,
open_url,
copied_text,
events: _, // handled above
mutable_text_under_cursor: _, // only used in eframe web
text_cursor_pos,
#[cfg(feature = "accesskit")]
accesskit_update,
} = platform_output;
self.current_pixels_per_point = egui_ctx.pixels_per_point(); // someone can have changed it to scale the UI
self.set_cursor_icon(window, cursor_icon);
if let Some(open_url) = open_url {
open_url_in_browser(&open_url.url);
}
if !copied_text.is_empty() {
self.clipboard.set(copied_text);
}
if let Some(egui::Pos2 { x, y }) = text_cursor_pos {
window.set_ime_position(winit::dpi::LogicalPosition { x, y });
}
#[cfg(feature = "accesskit")]
if let Some(accesskit) = self.accesskit.as_ref() {
if let Some(update) = accesskit_update {
accesskit.update_if_active(|| update);
}
}
}
fn set_cursor_icon(&mut self, window: &winit::window::Window, cursor_icon: egui::CursorIcon) {
if self.current_cursor_icon == Some(cursor_icon) {
// Prevent flickering near frame boundary when Windows OS tries to control cursor icon for window resizing.
// On other platforms: just early-out to save CPU.
return;
}
let is_pointer_in_window = self.pointer_pos_in_points.is_some();
if is_pointer_in_window {
self.current_cursor_icon = Some(cursor_icon);
if let Some(winit_cursor_icon) = translate_cursor(cursor_icon) {
window.set_cursor_visible(true);
window.set_cursor_icon(winit_cursor_icon);
} else {
window.set_cursor_visible(false);
}
} else {
// Remember to set the cursor again once the cursor returns to the screen:
self.current_cursor_icon = None;
}
}
}
fn open_url_in_browser(_url: &str) {
#[cfg(feature = "webbrowser")]
if let Err(err) = webbrowser::open(_url) {
tracing::warn!("Failed to open url: {}", err);
}
#[cfg(not(feature = "webbrowser"))]
{
tracing::warn!("Cannot open url - feature \"links\" not enabled.");
}
}
/// Winit sends special keys (backspace, delete, F1, …) as characters.
/// Ignore those.
/// We also ignore '\r', '\n', '\t'.
/// Newlines are handled by the `Key::Enter` event.
fn is_printable_char(chr: char) -> bool {
let is_in_private_use_area = '\u{e000}' <= chr && chr <= '\u{f8ff}'
|| '\u{f0000}' <= chr && chr <= '\u{ffffd}'
|| '\u{100000}' <= chr && chr <= '\u{10fffd}';
!is_in_private_use_area && !chr.is_ascii_control()
}
fn is_cut_command(modifiers: egui::Modifiers, keycode: winit::event::VirtualKeyCode) -> bool {
(modifiers.command && keycode == winit::event::VirtualKeyCode::X)
|| (cfg!(target_os = "windows")
&& modifiers.shift
&& keycode == winit::event::VirtualKeyCode::Delete)
}
fn is_copy_command(modifiers: egui::Modifiers, keycode: winit::event::VirtualKeyCode) -> bool {
(modifiers.command && keycode == winit::event::VirtualKeyCode::C)
|| (cfg!(target_os = "windows")
&& modifiers.ctrl
&& keycode == winit::event::VirtualKeyCode::Insert)
}
fn is_paste_command(modifiers: egui::Modifiers, keycode: winit::event::VirtualKeyCode) -> bool {
(modifiers.command && keycode == winit::event::VirtualKeyCode::V)
|| (cfg!(target_os = "windows")
&& modifiers.shift
&& keycode == winit::event::VirtualKeyCode::Insert)
}
fn translate_mouse_button(button: winit::event::MouseButton) -> Option<egui::PointerButton> {
match button {
winit::event::MouseButton::Left => Some(egui::PointerButton::Primary),
winit::event::MouseButton::Right => Some(egui::PointerButton::Secondary),
winit::event::MouseButton::Middle => Some(egui::PointerButton::Middle),
winit::event::MouseButton::Other(1) => Some(egui::PointerButton::Extra1),
winit::event::MouseButton::Other(2) => Some(egui::PointerButton::Extra2),
winit::event::MouseButton::Other(_) => None,
}
}
fn translate_virtual_key_code(key: winit::event::VirtualKeyCode) -> Option<egui::Key> {
use egui::Key;
use winit::event::VirtualKeyCode;
Some(match key {
VirtualKeyCode::Down => Key::ArrowDown,
VirtualKeyCode::Left => Key::ArrowLeft,
VirtualKeyCode::Right => Key::ArrowRight,
VirtualKeyCode::Up => Key::ArrowUp,
VirtualKeyCode::Escape => Key::Escape,
VirtualKeyCode::Tab => Key::Tab,
VirtualKeyCode::Back => Key::Backspace,
VirtualKeyCode::Return => Key::Enter,
VirtualKeyCode::Space => Key::Space,
VirtualKeyCode::Insert => Key::Insert,
VirtualKeyCode::Delete => Key::Delete,
VirtualKeyCode::Home => Key::Home,
VirtualKeyCode::End => Key::End,
VirtualKeyCode::PageUp => Key::PageUp,
VirtualKeyCode::PageDown => Key::PageDown,
VirtualKeyCode::Minus => Key::Minus,
// Using Mac the key with the Plus sign on it is reported as the Equals key
// (with both English and Swedish keyboard).
VirtualKeyCode::Equals => Key::PlusEquals,
VirtualKeyCode::Key0 | VirtualKeyCode::Numpad0 => Key::Num0,
VirtualKeyCode::Key1 | VirtualKeyCode::Numpad1 => Key::Num1,
VirtualKeyCode::Key2 | VirtualKeyCode::Numpad2 => Key::Num2,
VirtualKeyCode::Key3 | VirtualKeyCode::Numpad3 => Key::Num3,
VirtualKeyCode::Key4 | VirtualKeyCode::Numpad4 => Key::Num4,
VirtualKeyCode::Key5 | VirtualKeyCode::Numpad5 => Key::Num5,
VirtualKeyCode::Key6 | VirtualKeyCode::Numpad6 => Key::Num6,
VirtualKeyCode::Key7 | VirtualKeyCode::Numpad7 => Key::Num7,
VirtualKeyCode::Key8 | VirtualKeyCode::Numpad8 => Key::Num8,
VirtualKeyCode::Key9 | VirtualKeyCode::Numpad9 => Key::Num9,
VirtualKeyCode::A => Key::A,
VirtualKeyCode::B => Key::B,
VirtualKeyCode::C => Key::C,
VirtualKeyCode::D => Key::D,
VirtualKeyCode::E => Key::E,
VirtualKeyCode::F => Key::F,
VirtualKeyCode::G => Key::G,
VirtualKeyCode::H => Key::H,
VirtualKeyCode::I => Key::I,
VirtualKeyCode::J => Key::J,
VirtualKeyCode::K => Key::K,
VirtualKeyCode::L => Key::L,
VirtualKeyCode::M => Key::M,
VirtualKeyCode::N => Key::N,
VirtualKeyCode::O => Key::O,
VirtualKeyCode::P => Key::P,
VirtualKeyCode::Q => Key::Q,
VirtualKeyCode::R => Key::R,
VirtualKeyCode::S => Key::S,
VirtualKeyCode::T => Key::T,
VirtualKeyCode::U => Key::U,
VirtualKeyCode::V => Key::V,
VirtualKeyCode::W => Key::W,
VirtualKeyCode::X => Key::X,
VirtualKeyCode::Y => Key::Y,
VirtualKeyCode::Z => Key::Z,
VirtualKeyCode::F1 => Key::F1,
VirtualKeyCode::F2 => Key::F2,
VirtualKeyCode::F3 => Key::F3,
VirtualKeyCode::F4 => Key::F4,
VirtualKeyCode::F5 => Key::F5,
VirtualKeyCode::F6 => Key::F6,
VirtualKeyCode::F7 => Key::F7,
VirtualKeyCode::F8 => Key::F8,
VirtualKeyCode::F9 => Key::F9,
VirtualKeyCode::F10 => Key::F10,
VirtualKeyCode::F11 => Key::F11,
VirtualKeyCode::F12 => Key::F12,
VirtualKeyCode::F13 => Key::F13,
VirtualKeyCode::F14 => Key::F14,
VirtualKeyCode::F15 => Key::F15,
VirtualKeyCode::F16 => Key::F16,
VirtualKeyCode::F17 => Key::F17,
VirtualKeyCode::F18 => Key::F18,
VirtualKeyCode::F19 => Key::F19,
VirtualKeyCode::F20 => Key::F20,
_ => {
return None;
}
})
}
fn translate_cursor(cursor_icon: egui::CursorIcon) -> Option<winit::window::CursorIcon> {
match cursor_icon {
egui::CursorIcon::None => None,
egui::CursorIcon::Alias => Some(winit::window::CursorIcon::Alias),
egui::CursorIcon::AllScroll => Some(winit::window::CursorIcon::AllScroll),
egui::CursorIcon::Cell => Some(winit::window::CursorIcon::Cell),
egui::CursorIcon::ContextMenu => Some(winit::window::CursorIcon::ContextMenu),
egui::CursorIcon::Copy => Some(winit::window::CursorIcon::Copy),
egui::CursorIcon::Crosshair => Some(winit::window::CursorIcon::Crosshair),
egui::CursorIcon::Default => Some(winit::window::CursorIcon::Default),
egui::CursorIcon::Grab => Some(winit::window::CursorIcon::Grab),
egui::CursorIcon::Grabbing => Some(winit::window::CursorIcon::Grabbing),
egui::CursorIcon::Help => Some(winit::window::CursorIcon::Help),
egui::CursorIcon::Move => Some(winit::window::CursorIcon::Move),
egui::CursorIcon::NoDrop => Some(winit::window::CursorIcon::NoDrop),
egui::CursorIcon::NotAllowed => Some(winit::window::CursorIcon::NotAllowed),
egui::CursorIcon::PointingHand => Some(winit::window::CursorIcon::Hand),
egui::CursorIcon::Progress => Some(winit::window::CursorIcon::Progress),
egui::CursorIcon::ResizeHorizontal => Some(winit::window::CursorIcon::EwResize),
egui::CursorIcon::ResizeNeSw => Some(winit::window::CursorIcon::NeswResize),
egui::CursorIcon::ResizeNwSe => Some(winit::window::CursorIcon::NwseResize),
egui::CursorIcon::ResizeVertical => Some(winit::window::CursorIcon::NsResize),
egui::CursorIcon::ResizeEast => Some(winit::window::CursorIcon::EResize),
egui::CursorIcon::ResizeSouthEast => Some(winit::window::CursorIcon::SeResize),
egui::CursorIcon::ResizeSouth => Some(winit::window::CursorIcon::SResize),
egui::CursorIcon::ResizeSouthWest => Some(winit::window::CursorIcon::SwResize),
egui::CursorIcon::ResizeWest => Some(winit::window::CursorIcon::WResize),
egui::CursorIcon::ResizeNorthWest => Some(winit::window::CursorIcon::NwResize),
egui::CursorIcon::ResizeNorth => Some(winit::window::CursorIcon::NResize),
egui::CursorIcon::ResizeNorthEast => Some(winit::window::CursorIcon::NeResize),
egui::CursorIcon::ResizeColumn => Some(winit::window::CursorIcon::ColResize),
egui::CursorIcon::ResizeRow => Some(winit::window::CursorIcon::RowResize),
egui::CursorIcon::Text => Some(winit::window::CursorIcon::Text),
egui::CursorIcon::VerticalText => Some(winit::window::CursorIcon::VerticalText),
egui::CursorIcon::Wait => Some(winit::window::CursorIcon::Wait),
egui::CursorIcon::ZoomIn => Some(winit::window::CursorIcon::ZoomIn),
egui::CursorIcon::ZoomOut => Some(winit::window::CursorIcon::ZoomOut),
}
}
/// Returns a Wayland display handle if the target is running Wayland
fn wayland_display<T>(_event_loop: &EventLoopWindowTarget<T>) -> Option<*mut c_void> {
#[cfg(feature = "wayland")]
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
{
use winit::platform::wayland::EventLoopWindowTargetExtWayland as _;
return _event_loop.wayland_display();
}
#[allow(unreachable_code)]
{
let _ = _event_loop;
None
}
}
// ---------------------------------------------------------------------------
/// Profiling macro for feature "puffin"
#[allow(unused_macros)]
macro_rules! profile_function {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
puffin::profile_function!($($arg)*);
};
}
#[allow(unused_imports)]
pub(crate) use profile_function;
/// Profiling macro for feature "puffin"
#[allow(unused_macros)]
macro_rules! profile_scope {
($($arg: tt)*) => {
#[cfg(feature = "puffin")]
puffin::profile_scope!($($arg)*);
};
}
#[allow(unused_imports)]
pub(crate) use profile_scope;

View file

@ -0,0 +1,144 @@
/// Can be used to store native window settings (position and size).
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct WindowSettings {
/// Position of window in physical pixels. This is either
/// the inner or outer position depending on the platform.
/// See [`winit::window::WindowBuilder::with_position`] for details.
position: Option<egui::Pos2>,
fullscreen: bool,
/// Inner size of window in logical pixels
inner_size_points: Option<egui::Vec2>,
}
impl WindowSettings {
pub fn from_display(window: &winit::window::Window) -> Self {
let inner_size_points = window.inner_size().to_logical::<f32>(window.scale_factor());
let position = if cfg!(macos) {
// MacOS uses inner position when positioning windows.
window
.inner_position()
.ok()
.map(|p| egui::pos2(p.x as f32, p.y as f32))
} else {
// Other platforms use the outer position.
window
.outer_position()
.ok()
.map(|p| egui::pos2(p.x as f32, p.y as f32))
};
Self {
position,
fullscreen: window.fullscreen().is_some(),
inner_size_points: Some(egui::vec2(
inner_size_points.width,
inner_size_points.height,
)),
}
}
pub fn inner_size_points(&self) -> Option<egui::Vec2> {
self.inner_size_points
}
pub fn initialize_window(
&self,
mut window: winit::window::WindowBuilder,
) -> winit::window::WindowBuilder {
// If the app last ran on two monitors and only one is now connected, then
// the given position is invalid.
// If this happens on Mac, the window is clamped into valid area.
// If this happens on Windows, the clamping behavior is managed by the function
// clamp_window_to_sane_position.
if let Some(pos) = self.position {
window = window.with_position(winit::dpi::PhysicalPosition {
x: pos.x as f64,
y: pos.y as f64,
});
}
if let Some(inner_size_points) = self.inner_size_points {
window
.with_inner_size(winit::dpi::LogicalSize {
width: inner_size_points.x as f64,
height: inner_size_points.y as f64,
})
.with_fullscreen(
self.fullscreen
.then_some(winit::window::Fullscreen::Borderless(None)),
)
} else {
window
}
}
pub fn clamp_to_sane_values(&mut self, max_size: egui::Vec2) {
use egui::NumExt as _;
if let Some(size) = &mut self.inner_size_points {
// Prevent ridiculously small windows
let min_size = egui::Vec2::splat(64.0);
*size = size.at_least(min_size);
*size = size.at_most(max_size);
}
}
pub fn clamp_window_to_sane_position<E>(
&mut self,
event_loop: &winit::event_loop::EventLoopWindowTarget<E>,
) {
if let (Some(position), Some(inner_size_points)) =
(&mut self.position, &self.inner_size_points)
{
let monitors = event_loop.available_monitors();
// default to primary monitor, in case the correct monitor was disconnected.
let mut active_monitor = if let Some(active_monitor) = event_loop
.primary_monitor()
.or_else(|| event_loop.available_monitors().next())
{
active_monitor
} else {
return; // no monitors 🤷
};
for monitor in monitors {
let monitor_x_range = (monitor.position().x - inner_size_points.x as i32)
..(monitor.position().x + monitor.size().width as i32);
let monitor_y_range = (monitor.position().y - inner_size_points.y as i32)
..(monitor.position().y + monitor.size().height as i32);
if monitor_x_range.contains(&(position.x as i32))
&& monitor_y_range.contains(&(position.y as i32))
{
active_monitor = monitor;
}
}
let mut inner_size_pixels = *inner_size_points * (active_monitor.scale_factor() as f32);
// Add size of title bar. This is 32 px by default in Win 10/11.
if cfg!(target_os = "windows") {
inner_size_pixels +=
egui::Vec2::new(0.0, 32.0 * active_monitor.scale_factor() as f32);
}
let monitor_position = egui::Pos2::new(
active_monitor.position().x as f32,
active_monitor.position().y as f32,
);
let monitor_size = egui::Vec2::new(
active_monitor.size().width as f32,
active_monitor.size().height as f32,
);
// Window size cannot be negative or the subsequent `clamp` will panic.
let window_size = (monitor_size - inner_size_pixels).max(egui::Vec2::ZERO);
// To get the maximum position, we get the rightmost corner of the display, then
// subtract the size of the window to get the bottom right most value window.position
// can have.
*position = position.clamp(monitor_position, monitor_position + window_size);
}
}
}

View file

@ -0,0 +1,84 @@
[package]
name = "egui"
version = "0.21.0"
authors = ["Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
description = "An easy-to-use immediate mode GUI that runs on both web and native"
edition = "2021"
rust-version = "1.65"
homepage = "https://github.com/emilk/egui"
license = "MIT OR Apache-2.0"
readme = "../../README.md"
repository = "https://github.com/emilk/egui"
categories = ["gui", "game-development"]
keywords = ["gui", "imgui", "immediate", "portable", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[package.metadata.docs.rs]
all-features = true
[lib]
[features]
default = ["default_fonts"]
## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`epaint::Vertex`], [`emath::Vec2`] etc to `&[u8]`.
bytemuck = ["epaint/bytemuck"]
## [`cint`](https://docs.rs/cint) enables interopability with other color libraries.
cint = ["epaint/cint"]
## Enable the [`hex_color`] macro.
color-hex = ["epaint/color-hex"]
## This will automatically detect deadlocks due to double-locking on the same thread.
## If your app freezes, you may want to enable this!
## Only affects [`epaint::mutex::RwLock`] (which egui uses a lot).
deadlock_detection = ["epaint/deadlock_detection"]
## If set, egui will use `include_bytes!` to bundle some fonts.
## If you plan on specifying your own fonts you may disable this feature.
default_fonts = ["epaint/default_fonts"]
## Enable additional checks if debug assertions are enabled (debug builds).
extra_debug_asserts = ["epaint/extra_debug_asserts"]
## Always enable additional checks.
extra_asserts = ["epaint/extra_asserts"]
## [`mint`](https://docs.rs/mint) enables interopability with other math libraries such as [`glam`](https://docs.rs/glam) and [`nalgebra`](https://docs.rs/nalgebra).
mint = ["epaint/mint"]
## Enable persistence of memory (window positions etc).
persistence = ["serde", "epaint/serde", "ron"]
## Allow serialization using [`serde`](https://docs.rs/serde).
serde = ["dep:serde", "epaint/serde", "accesskit?/serde"]
## Change Vertex layout to be compatible with unity
unity = ["epaint/unity"]
[dependencies]
epaint = { version = "0.21.0", path = "../epaint", default-features = false }
ahash = { version = "0.8.1", default-features = false, features = [
"no-rng", # we don't need DOS-protection, so we let users opt-in to it instead
"std",
] }
nohash-hasher = "0.2"
#! ### Optional dependencies
## Exposes detailed accessibility implementation required by platform
## accessibility APIs. Also requires support in the egui integration.
accesskit = { version = "0.9.0", optional = true }
## Enable this when generating docs.
document-features = { version = "0.2", optional = true }
ron = { version = "0.8", optional = true }
serde = { version = "1", optional = true, features = ["derive", "rc"] }
# egui doesn't log much, but when it does, it uses [`tracing`](https://docs.rs/tracing).
tracing = { version = "0.1", optional = true, default-features = false, features = [
"std",
] }

View file

@ -0,0 +1,2 @@
# GUI implementation
This is the core library crate egui. It is fully platform independent without any backend. You give the egui library input each frame (mouse pos etc), and it outputs a triangle mesh for you to paint.

View file

@ -0,0 +1,7 @@
There are no stand-alone egui examples, because egui is not stand-alone!
See the top-level [examples](https://github.com/emilk/egui/tree/master/examples/) folder instead.
There are also plenty of examples in [the online demo](https://www.egui.rs/#demo). You can find the source code for it at <https://github.com/emilk/egui/tree/master/crates/nevmes_gui_lib>.
To learn how to set up `eframe` for web and native, go to <https://github.com/emilk/eframe_template/> and follow the instructions there!

View file

@ -0,0 +1,114 @@
use crate::{emath::remap_clamp, Id, IdMap, InputState};
#[derive(Clone, Default)]
pub(crate) struct AnimationManager {
bools: IdMap<BoolAnim>,
values: IdMap<ValueAnim>,
}
#[derive(Clone, Debug)]
struct BoolAnim {
value: bool,
/// when did `value` last toggle?
toggle_time: f64,
}
#[derive(Clone, Debug)]
struct ValueAnim {
from_value: f32,
to_value: f32,
/// when did `value` last toggle?
toggle_time: f64,
}
impl AnimationManager {
/// See `Context::animate_bool` for documentation
pub fn animate_bool(
&mut self,
input: &InputState,
animation_time: f32,
id: Id,
value: bool,
) -> f32 {
match self.bools.get_mut(&id) {
None => {
self.bools.insert(
id,
BoolAnim {
value,
toggle_time: -f64::INFINITY, // long time ago
},
);
if value {
1.0
} else {
0.0
}
}
Some(anim) => {
if anim.value != value {
anim.value = value;
anim.toggle_time = input.time;
}
let time_since_toggle = (input.time - anim.toggle_time) as f32;
// On the frame we toggle we don't want to return the old value,
// so we extrapolate forwards:
let time_since_toggle = time_since_toggle + input.predicted_dt;
if value {
remap_clamp(time_since_toggle, 0.0..=animation_time, 0.0..=1.0)
} else {
remap_clamp(time_since_toggle, 0.0..=animation_time, 1.0..=0.0)
}
}
}
}
pub fn animate_value(
&mut self,
input: &InputState,
animation_time: f32,
id: Id,
value: f32,
) -> f32 {
match self.values.get_mut(&id) {
None => {
self.values.insert(
id,
ValueAnim {
from_value: value,
to_value: value,
toggle_time: -f64::INFINITY, // long time ago
},
);
value
}
Some(anim) => {
let time_since_toggle = (input.time - anim.toggle_time) as f32;
// On the frame we toggle we don't want to return the old value,
// so we extrapolate forwards:
let time_since_toggle = time_since_toggle + input.predicted_dt;
let current_value = remap_clamp(
time_since_toggle,
0.0..=animation_time,
anim.from_value..=anim.to_value,
);
if anim.to_value != value {
anim.from_value = current_value; //start new animation from current position of playing animation
anim.to_value = value;
anim.toggle_time = input.time;
}
if animation_time == 0.0 {
anim.from_value = value;
anim.to_value = value;
}
current_value
}
}
}
}

View file

@ -0,0 +1,514 @@
//! Area is a [`Ui`] that has no parent, it floats on the background.
//! It has no frame or own size. It is potentially movable.
//! It is the foundation for windows and popups.
use crate::*;
/// State that is persisted between frames.
// TODO(emilk): this is not currently stored in `Memory::data`, but maybe it should be?
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub(crate) struct State {
/// Last known pos of the pivot
pub pivot_pos: Pos2,
pub pivot: Align2,
/// Last know size. Used for catching clicks.
pub size: Vec2,
/// If false, clicks goes straight through to what is behind us.
/// Good for tooltips etc.
pub interactable: bool,
}
impl State {
pub fn left_top_pos(&self) -> Pos2 {
pos2(
self.pivot_pos.x - self.pivot.x().to_factor() * self.size.x,
self.pivot_pos.y - self.pivot.y().to_factor() * self.size.y,
)
}
pub fn set_left_top_pos(&mut self, pos: Pos2) {
self.pivot_pos = pos2(
pos.x + self.pivot.x().to_factor() * self.size.x,
pos.y + self.pivot.y().to_factor() * self.size.y,
);
}
pub fn rect(&self) -> Rect {
Rect::from_min_size(self.left_top_pos(), self.size)
}
}
/// An area on the screen that can be moved by dragging.
///
/// This forms the base of the [`Window`] container.
///
/// ```
/// # egui::__run_test_ctx(|ctx| {
/// egui::Area::new("my_area")
/// .fixed_pos(egui::pos2(32.0, 32.0))
/// .show(ctx, |ui| {
/// ui.label("Floating text!");
/// });
/// # });
/// ```
#[must_use = "You should call .show()"]
#[derive(Clone, Copy, Debug)]
pub struct Area {
pub(crate) id: Id,
movable: bool,
interactable: bool,
enabled: bool,
constrain: bool,
order: Order,
default_pos: Option<Pos2>,
pivot: Align2,
anchor: Option<(Align2, Vec2)>,
new_pos: Option<Pos2>,
drag_bounds: Option<Rect>,
}
impl Area {
pub fn new(id: impl Into<Id>) -> Self {
Self {
id: id.into(),
movable: true,
interactable: true,
constrain: false,
enabled: true,
order: Order::Middle,
default_pos: None,
new_pos: None,
pivot: Align2::LEFT_TOP,
anchor: None,
drag_bounds: None,
}
}
pub fn id(mut self, id: Id) -> Self {
self.id = id;
self
}
pub fn layer(&self) -> LayerId {
LayerId::new(self.order, self.id)
}
/// If false, no content responds to click
/// and widgets will be shown grayed out.
/// You won't be able to move the window.
/// Default: `true`.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// moveable by dragging the area?
pub fn movable(mut self, movable: bool) -> Self {
self.movable = movable;
self.interactable |= movable;
self
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn is_movable(&self) -> bool {
self.movable && self.enabled
}
/// If false, clicks goes straight through to what is behind us.
/// Good for tooltips etc.
pub fn interactable(mut self, interactable: bool) -> Self {
self.interactable = interactable;
self.movable &= interactable;
self
}
/// `order(Order::Foreground)` for an Area that should always be on top
pub fn order(mut self, order: Order) -> Self {
self.order = order;
self
}
pub fn default_pos(mut self, default_pos: impl Into<Pos2>) -> Self {
self.default_pos = Some(default_pos.into());
self
}
/// Positions the window and prevents it from being moved
pub fn fixed_pos(mut self, fixed_pos: impl Into<Pos2>) -> Self {
self.new_pos = Some(fixed_pos.into());
self.movable = false;
self
}
/// Constrains this area to the screen bounds.
pub fn constrain(mut self, constrain: bool) -> Self {
self.constrain = constrain;
self
}
/// Where the "root" of the area is.
///
/// For instance, if you set this to [`Align2::RIGHT_TOP`]
/// then [`Self::fixed_pos`] will set the position of the right-top
/// corner of the area.
///
/// Default: [`Align2::LEFT_TOP`].
pub fn pivot(mut self, pivot: Align2) -> Self {
self.pivot = pivot;
self
}
/// Positions the window but you can still move it.
pub fn current_pos(mut self, current_pos: impl Into<Pos2>) -> Self {
self.new_pos = Some(current_pos.into());
self
}
/// Set anchor and distance.
///
/// An anchor of `Align2::RIGHT_TOP` means "put the right-top corner of the window
/// in the right-top corner of the screen".
///
/// The offset is added to the position, so e.g. an offset of `[-5.0, 5.0]`
/// would move the window left and down from the given anchor.
///
/// Anchoring also makes the window immovable.
///
/// It is an error to set both an anchor and a position.
pub fn anchor(mut self, align: Align2, offset: impl Into<Vec2>) -> Self {
self.anchor = Some((align, offset.into()));
self.movable(false)
}
/// Constrain the area up to which the window can be dragged.
pub fn drag_bounds(mut self, bounds: Rect) -> Self {
self.drag_bounds = Some(bounds);
self
}
pub(crate) fn get_pivot(&self) -> Align2 {
if let Some((pivot, _)) = self.anchor {
pivot
} else {
Align2::LEFT_TOP
}
}
}
pub(crate) struct Prepared {
layer_id: LayerId,
state: State,
move_response: Response,
enabled: bool,
drag_bounds: Option<Rect>,
/// We always make windows invisible the first frame to hide "first-frame-jitters".
///
/// This is so that we use the first frame to calculate the window size,
/// and then can correctly position the window and its contents the next frame,
/// without having one frame where the window is wrongly positioned or sized.
temporarily_invisible: bool,
}
impl Area {
pub fn show<R>(
self,
ctx: &Context,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
let prepared = self.begin(ctx);
let mut content_ui = prepared.content_ui(ctx);
let inner = add_contents(&mut content_ui);
let response = prepared.end(ctx, content_ui);
InnerResponse { inner, response }
}
pub(crate) fn begin(self, ctx: &Context) -> Prepared {
let Area {
id,
movable,
order,
interactable,
enabled,
default_pos,
new_pos,
pivot,
anchor,
drag_bounds,
constrain,
} = self;
let layer_id = LayerId::new(order, id);
let state = ctx.memory(|mem| mem.areas.get(id).copied());
let is_new = state.is_none();
if is_new {
ctx.request_repaint(); // if we don't know the previous size we are likely drawing the area in the wrong place
}
let mut state = state.unwrap_or_else(|| State {
pivot_pos: default_pos.unwrap_or_else(|| automatic_area_position(ctx)),
pivot,
size: Vec2::ZERO,
interactable,
});
state.pivot_pos = new_pos.unwrap_or(state.pivot_pos);
state.interactable = interactable;
if let Some((anchor, offset)) = anchor {
let screen = ctx.available_rect();
state.set_left_top_pos(
anchor.align_size_within_rect(state.size, screen).left_top() + offset,
);
}
// interact right away to prevent frame-delay
let move_response = {
let interact_id = layer_id.id.with("move");
let sense = if movable {
Sense::click_and_drag()
} else if interactable {
Sense::click() // allow clicks to bring to front
} else {
Sense::hover()
};
let move_response = ctx.interact(
Rect::EVERYTHING,
ctx.style().spacing.item_spacing,
layer_id,
interact_id,
state.rect(),
sense,
enabled,
);
// Important check - don't try to move e.g. a combobox popup!
if movable {
if move_response.dragged() {
state.pivot_pos += ctx.input(|i| i.pointer.delta());
}
state.set_left_top_pos(
ctx.constrain_window_rect_to_area(state.rect(), drag_bounds)
.min,
);
}
if (move_response.dragged() || move_response.clicked())
|| pointer_pressed_on_area(ctx, layer_id)
|| !ctx.memory(|m| m.areas.visible_last_frame(&layer_id))
{
ctx.memory_mut(|m| m.areas.move_to_top(layer_id));
ctx.request_repaint();
}
move_response
};
state.set_left_top_pos(ctx.round_pos_to_pixels(state.left_top_pos()));
if constrain {
state.set_left_top_pos(
ctx.constrain_window_rect_to_area(state.rect(), drag_bounds)
.left_top(),
);
}
Prepared {
layer_id,
state,
move_response,
enabled,
drag_bounds,
temporarily_invisible: is_new,
}
}
pub fn show_open_close_animation(&self, ctx: &Context, frame: &Frame, is_open: bool) {
// must be called first so animation managers know the latest state
let visibility_factor = ctx.animate_bool(self.id.with("close_animation"), is_open);
if is_open {
// we actually only show close animations.
// when opening a window we show it right away.
return;
}
if visibility_factor <= 0.0 {
return;
}
let layer_id = LayerId::new(self.order, self.id);
let area_rect = ctx.memory(|mem| mem.areas.get(self.id).map(|area| area.rect()));
if let Some(area_rect) = area_rect {
let clip_rect = ctx.available_rect();
let painter = Painter::new(ctx.clone(), layer_id, clip_rect);
// shrinkage: looks kinda a bad on its own
// let area_rect =
// Rect::from_center_size(area_rect.center(), visibility_factor * area_rect.size());
let frame = frame.multiply_with_opacity(visibility_factor);
painter.add(frame.paint(area_rect));
}
}
}
impl Prepared {
pub(crate) fn state(&self) -> &State {
&self.state
}
pub(crate) fn state_mut(&mut self) -> &mut State {
&mut self.state
}
pub(crate) fn drag_bounds(&self) -> Option<Rect> {
self.drag_bounds
}
pub(crate) fn content_ui(&self, ctx: &Context) -> Ui {
let screen_rect = ctx.screen_rect();
let bounds = if let Some(bounds) = self.drag_bounds {
bounds.intersect(screen_rect) // protect against infinite bounds
} else {
let central_area = ctx.available_rect();
let is_within_central_area = central_area.contains_rect(self.state.rect().shrink(1.0));
if is_within_central_area {
central_area // let's try to not cover side panels
} else {
screen_rect
}
};
let max_rect = Rect::from_min_max(
self.state.left_top_pos(),
bounds
.max
.at_least(self.state.left_top_pos() + Vec2::splat(32.0)),
);
let shadow_radius = ctx.style().visuals.window_shadow.extrusion; // hacky
let clip_rect_margin = ctx.style().visuals.clip_rect_margin.max(shadow_radius);
let clip_rect = Rect::from_min_max(self.state.left_top_pos(), bounds.max)
.expand(clip_rect_margin)
.intersect(bounds);
let mut ui = Ui::new(
ctx.clone(),
self.layer_id,
self.layer_id.id,
max_rect,
clip_rect,
);
ui.set_enabled(self.enabled);
ui.set_visible(!self.temporarily_invisible);
ui
}
#[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`.
pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response {
let Prepared {
layer_id,
mut state,
move_response,
enabled: _,
drag_bounds: _,
temporarily_invisible: _,
} = self;
state.size = content_ui.min_rect().size();
ctx.memory_mut(|m| m.areas.set_state(layer_id, state));
move_response
}
}
fn pointer_pressed_on_area(ctx: &Context, layer_id: LayerId) -> bool {
if let Some(pointer_pos) = ctx.pointer_interact_pos() {
let any_pressed = ctx.input(|i| i.pointer.any_pressed());
any_pressed && ctx.layer_id_at(pointer_pos) == Some(layer_id)
} else {
false
}
}
fn automatic_area_position(ctx: &Context) -> Pos2 {
let mut existing: Vec<Rect> = ctx.memory(|mem| {
mem.areas
.visible_windows()
.into_iter()
.map(State::rect)
.collect()
});
existing.sort_by_key(|r| r.left().round() as i32);
let available_rect = ctx.available_rect();
let spacing = 16.0;
let left = available_rect.left() + spacing;
let top = available_rect.top() + spacing;
if existing.is_empty() {
return pos2(left, top);
}
// Separate existing rectangles into columns:
let mut column_bbs = vec![existing[0]];
for &rect in &existing {
let current_column_bb = column_bbs.last_mut().unwrap();
if rect.left() < current_column_bb.right() {
// same column
*current_column_bb = current_column_bb.union(rect);
} else {
// new column
column_bbs.push(rect);
}
}
{
// Look for large spaces between columns (empty columns):
let mut x = left;
for col_bb in &column_bbs {
let available = col_bb.left() - x;
if available >= 300.0 {
return pos2(x, top);
}
x = col_bb.right() + spacing;
}
}
// Find first column with some available space at the bottom of it:
for col_bb in &column_bbs {
if col_bb.bottom() < available_rect.center().y {
return pos2(col_bb.left(), col_bb.bottom() + spacing);
}
}
// Maybe we can fit a new column?
let rightmost = column_bbs.last().unwrap().right();
if rightmost + 200.0 < available_rect.right() {
return pos2(rightmost + spacing, top);
}
// Ok, just put us in the column with the most space at the bottom:
let mut best_pos = pos2(left, column_bbs[0].bottom() + spacing);
for col_bb in &column_bbs {
let col_pos = pos2(col_bb.left(), col_bb.bottom() + spacing);
if col_pos.y < best_pos.y {
best_pos = col_pos;
}
}
best_pos
}

View file

@ -0,0 +1,681 @@
use std::hash::Hash;
use crate::*;
use epaint::Shape;
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub(crate) struct InnerState {
open: bool,
/// Height of the region when open. Used for animations
#[cfg_attr(feature = "serde", serde(default))]
open_height: Option<f32>,
}
/// This is a a building block for building collapsing regions.
///
/// It is used by [`CollapsingHeader`] and [`Window`], but can also be used on its own.
///
/// See [`CollapsingState::show_header`] for how to show a collapsing header with a custom header.
#[derive(Clone, Debug)]
pub struct CollapsingState {
id: Id,
state: InnerState,
}
impl CollapsingState {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| {
d.get_persisted::<InnerState>(id)
.map(|state| Self { id, state })
})
}
pub fn store(&self, ctx: &Context) {
ctx.data_mut(|d| d.insert_persisted(self.id, self.state));
}
pub fn id(&self) -> Id {
self.id
}
pub fn load_with_default_open(ctx: &Context, id: Id, default_open: bool) -> Self {
Self::load(ctx, id).unwrap_or(CollapsingState {
id,
state: InnerState {
open: default_open,
open_height: None,
},
})
}
pub fn is_open(&self) -> bool {
self.state.open
}
pub fn set_open(&mut self, open: bool) {
self.state.open = open;
}
pub fn toggle(&mut self, ui: &Ui) {
self.state.open = !self.state.open;
ui.ctx().request_repaint();
}
/// 0 for closed, 1 for open, with tweening
pub fn openness(&self, ctx: &Context) -> f32 {
if ctx.memory(|mem| mem.everything_is_visible()) {
1.0
} else {
ctx.animate_bool(self.id, self.state.open)
}
}
/// Will toggle when clicked, etc.
pub(crate) fn show_default_button_with_size(
&mut self,
ui: &mut Ui,
button_size: Vec2,
) -> Response {
let (_id, rect) = ui.allocate_space(button_size);
let response = ui.interact(rect, self.id, Sense::click());
if response.clicked() {
self.toggle(ui);
}
let openness = self.openness(ui.ctx());
paint_default_icon(ui, openness, &response);
response
}
/// Will toggle when clicked, etc.
fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response {
self.show_button_indented(ui, paint_default_icon)
}
/// Will toggle when clicked, etc.
fn show_button_indented(
&mut self,
ui: &mut Ui,
icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
) -> Response {
let size = vec2(ui.spacing().indent, ui.spacing().icon_width);
let (_id, rect) = ui.allocate_space(size);
let response = ui.interact(rect, self.id, Sense::click());
if response.clicked() {
self.toggle(ui);
}
let (mut icon_rect, _) = ui.spacing().icon_rectangles(response.rect);
icon_rect.set_center(pos2(
response.rect.left() + ui.spacing().indent / 2.0,
response.rect.center().y,
));
let openness = self.openness(ui.ctx());
let small_icon_response = response.clone().with_new_rect(icon_rect);
icon_fn(ui, openness, &small_icon_response);
response
}
/// Shows header and body (if expanded).
///
/// The header will start with the default button in a horizontal layout, followed by whatever you add.
///
/// Will also store the state.
///
/// Returns the response of the collapsing button, the custom header, and the custom body.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let id = ui.make_persistent_id("my_collapsing_header");
/// egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false)
/// .show_header(ui, |ui| {
/// ui.label("Header"); // you can put checkboxes or whatever here
/// })
/// .body(|ui| ui.label("Body"));
/// # });
/// ```
pub fn show_header<HeaderRet>(
mut self,
ui: &mut Ui,
add_header: impl FnOnce(&mut Ui) -> HeaderRet,
) -> HeaderResponse<'_, HeaderRet> {
let header_response = ui.horizontal(|ui| {
let prev_item_spacing = ui.spacing_mut().item_spacing;
ui.spacing_mut().item_spacing.x = 0.0; // the toggler button uses the full indent width
let collapser = self.show_default_button_indented(ui);
ui.spacing_mut().item_spacing = prev_item_spacing;
(collapser, add_header(ui))
});
HeaderResponse {
state: self,
ui,
toggle_button_response: header_response.inner.0,
header_response: InnerResponse {
response: header_response.response,
inner: header_response.inner.1,
},
}
}
/// Show body if we are open, with a nice animation between closed and open.
/// Indent the body to show it belongs to the header.
///
/// Will also store the state.
pub fn show_body_indented<R>(
&mut self,
header_response: &Response,
ui: &mut Ui,
add_body: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let id = self.id;
self.show_body_unindented(ui, |ui| {
ui.indent(id, |ui| {
// make as wide as the header:
ui.expand_to_include_x(header_response.rect.right());
add_body(ui)
})
.inner
})
}
/// Show body if we are open, with a nice animation between closed and open.
/// Will also store the state.
pub fn show_body_unindented<R>(
&mut self,
ui: &mut Ui,
add_body: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let openness = self.openness(ui.ctx());
if openness <= 0.0 {
self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring
None
} else if openness < 1.0 {
Some(ui.scope(|child_ui| {
let max_height = if self.state.open && self.state.open_height.is_none() {
// First frame of expansion.
// We don't know full height yet, but we will next frame.
// Just use a placeholder value that shows some movement:
10.0
} else {
let full_height = self.state.open_height.unwrap_or_default();
remap_clamp(openness, 0.0..=1.0, 0.0..=full_height)
};
let mut clip_rect = child_ui.clip_rect();
clip_rect.max.y = clip_rect.max.y.min(child_ui.max_rect().top() + max_height);
child_ui.set_clip_rect(clip_rect);
let ret = add_body(child_ui);
let mut min_rect = child_ui.min_rect();
self.state.open_height = Some(min_rect.height());
self.store(child_ui.ctx()); // remember the height
// Pretend children took up at most `max_height` space:
min_rect.max.y = min_rect.max.y.at_most(min_rect.top() + max_height);
child_ui.force_set_min_rect(min_rect);
ret
}))
} else {
let ret_response = ui.scope(add_body);
let full_size = ret_response.response.rect.size();
self.state.open_height = Some(full_size.y);
self.store(ui.ctx()); // remember the height
Some(ret_response)
}
}
/// Paint this [CollapsingState](CollapsingState)'s toggle button. Takes an [IconPainter](IconPainter) as the icon.
/// ```
/// # egui::__run_test_ui(|ui| {
/// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
/// let stroke = ui.style().interact(&response).fg_stroke;
/// let radius = egui::lerp(2.0..=3.0, openness);
/// ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
/// }
///
/// let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
/// ui.ctx(),
/// ui.make_persistent_id("my_collapsing_state"),
/// false,
/// );
///
/// let header_res = ui.horizontal(|ui| {
/// ui.label("Header");
/// state.show_toggle_button(ui, circle_icon);
/// });
///
/// state.show_body_indented(&header_res.response, ui, |ui| ui.label("Body"));
/// # });
/// ```
pub fn show_toggle_button(
&mut self,
ui: &mut Ui,
icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
) -> Response {
self.show_button_indented(ui, icon_fn)
}
}
/// From [`CollapsingState::show_header`].
#[must_use = "Remember to show the body"]
pub struct HeaderResponse<'ui, HeaderRet> {
state: CollapsingState,
ui: &'ui mut Ui,
toggle_button_response: Response,
header_response: InnerResponse<HeaderRet>,
}
impl<'ui, HeaderRet> HeaderResponse<'ui, HeaderRet> {
/// Returns the response of the collapsing button, the custom header, and the custom body.
pub fn body<BodyRet>(
mut self,
add_body: impl FnOnce(&mut Ui) -> BodyRet,
) -> (
Response,
InnerResponse<HeaderRet>,
Option<InnerResponse<BodyRet>>,
) {
let body_response =
self.state
.show_body_indented(&self.header_response.response, self.ui, add_body);
(
self.toggle_button_response,
self.header_response,
body_response,
)
}
/// Returns the response of the collapsing button, the custom header, and the custom body, without indentation.
pub fn body_unindented<BodyRet>(
mut self,
add_body: impl FnOnce(&mut Ui) -> BodyRet,
) -> (
Response,
InnerResponse<HeaderRet>,
Option<InnerResponse<BodyRet>>,
) {
let body_response = self.state.show_body_unindented(self.ui, add_body);
(
self.toggle_button_response,
self.header_response,
body_response,
)
}
}
// ----------------------------------------------------------------------------
/// Paint the arrow icon that indicated if the region is open or not
pub fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) {
let visuals = ui.style().interact(response);
let rect = response.rect;
// Draw a pointy triangle arrow:
let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75);
let rect = rect.expand(visuals.expansion);
let mut points = vec![rect.left_top(), rect.right_top(), rect.center_bottom()];
use std::f32::consts::TAU;
let rotation = emath::Rot2::from_angle(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0));
for p in &mut points {
*p = rect.center() + rotation * (*p - rect.center());
}
ui.painter().add(Shape::convex_polygon(
points,
visuals.fg_stroke.color,
Stroke::NONE,
));
}
/// A function that paints an icon indicating if the region is open or not
pub type IconPainter = Box<dyn FnOnce(&mut Ui, f32, &Response)>;
/// A header which can be collapsed/expanded, revealing a contained [`Ui`] region.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// egui::CollapsingHeader::new("Heading")
/// .show(ui, |ui| {
/// ui.label("Body");
/// });
///
/// // Short version:
/// ui.collapsing("Heading", |ui| { ui.label("Body"); });
/// # });
/// ```
///
/// If you want to customize the header contents, see [`CollapsingState::show_header`].
#[must_use = "You should call .show()"]
pub struct CollapsingHeader {
text: WidgetText,
default_open: bool,
open: Option<bool>,
id_source: Id,
enabled: bool,
selectable: bool,
selected: bool,
show_background: bool,
icon: Option<IconPainter>,
}
impl CollapsingHeader {
/// The [`CollapsingHeader`] starts out collapsed unless you call `default_open`.
///
/// The label is used as an [`Id`] source.
/// If the label is unique and static this is fine,
/// but if it changes or there are several [`CollapsingHeader`] with the same title
/// you need to provide a unique id source with [`Self::id_source`].
pub fn new(text: impl Into<WidgetText>) -> Self {
let text = text.into();
let id_source = Id::new(text.text());
Self {
text,
default_open: false,
open: None,
id_source,
enabled: true,
selectable: false,
selected: false,
show_background: false,
icon: None,
}
}
/// By default, the [`CollapsingHeader`] is collapsed.
/// Call `.default_open(true)` to change this.
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
/// Calling `.open(Some(true))` will make the collapsing header open this frame (or stay open).
///
/// Calling `.open(Some(false))` will make the collapsing header close this frame (or stay closed).
///
/// Calling `.open(None)` has no effect (default).
pub fn open(mut self, open: Option<bool>) -> Self {
self.open = open;
self
}
/// Explicitly set the source of the [`Id`] of this widget, instead of using title label.
/// This is useful if the title label is dynamic or not unique.
pub fn id_source(mut self, id_source: impl Hash) -> Self {
self.id_source = Id::new(id_source);
self
}
/// If you set this to `false`, the [`CollapsingHeader`] will be grayed out and un-clickable.
///
/// This is a convenience for [`Ui::set_enabled`].
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Can the [`CollapsingHeader`] be selected by clicking it? Default: `false`.
#[deprecated = "Use the more powerful egui::collapsing_header::CollapsingState::show_header"] // Deprecated in 2022-04-28, before egui 0.18
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
/// If you set this to 'true', the [`CollapsingHeader`] will be shown as selected.
///
/// Example:
/// ```
/// # egui::__run_test_ui(|ui| {
/// let mut selected = false;
/// let response = egui::CollapsingHeader::new("Select and open me")
/// .selectable(true)
/// .selected(selected)
/// .show(ui, |ui| ui.label("Body"));
/// if response.header_response.clicked() {
/// selected = true;
/// }
/// # });
/// ```
#[deprecated = "Use the more powerful egui::collapsing_header::CollapsingState::show_header"] // Deprecated in 2022-04-28, before egui 0.18
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Should the [`CollapsingHeader`] show a background behind it? Default: `false`.
///
/// To show it behind all [`CollapsingHeader`] you can just use:
/// ```
/// # egui::__run_test_ui(|ui| {
/// ui.visuals_mut().collapsing_header_frame = true;
/// # });
/// ```
pub fn show_background(mut self, show_background: bool) -> Self {
self.show_background = show_background;
self
}
/// Use the provided function to render a different [`CollapsingHeader`] icon.
/// Defaults to a triangle that animates as the [`CollapsingHeader`] opens and closes.
///
/// For example:
/// ```
/// # egui::__run_test_ui(|ui| {
/// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
/// let stroke = ui.style().interact(&response).fg_stroke;
/// let radius = egui::lerp(2.0..=3.0, openness);
/// ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
/// }
///
/// egui::CollapsingHeader::new("Circles")
/// .icon(circle_icon)
/// .show(ui, |ui| { ui.label("Hi!"); });
/// # });
/// ```
pub fn icon(mut self, icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static) -> Self {
self.icon = Some(Box::new(icon_fn));
self
}
}
struct Prepared {
header_response: Response,
state: CollapsingState,
openness: f32,
}
impl CollapsingHeader {
fn begin(self, ui: &mut Ui) -> Prepared {
assert!(
ui.layout().main_dir().is_vertical(),
"Horizontal collapsing is unimplemented"
);
let Self {
icon,
text,
default_open,
open,
id_source,
enabled: _,
selectable,
selected,
show_background,
} = self;
// TODO(emilk): horizontal layout, with icon and text as labels. Insert background behind using Frame.
let id = ui.make_persistent_id(id_source);
let button_padding = ui.spacing().button_padding;
let available = ui.available_rect_before_wrap();
let text_pos = available.min + vec2(ui.spacing().indent, 0.0);
let wrap_width = available.right() - text_pos.x;
let wrap = Some(false);
let text = text.into_galley(ui, wrap, wrap_width, TextStyle::Button);
let text_max_x = text_pos.x + text.size().x;
let mut desired_width = text_max_x + button_padding.x - available.left();
if ui.visuals().collapsing_header_frame {
desired_width = desired_width.max(available.width()); // fill full width
}
let mut desired_size = vec2(desired_width, text.size().y + 2.0 * button_padding.y);
desired_size = desired_size.at_least(ui.spacing().interact_size);
let (_, rect) = ui.allocate_space(desired_size);
let mut header_response = ui.interact(rect, id, Sense::click());
let text_pos = pos2(
text_pos.x,
header_response.rect.center().y - text.size().y / 2.0,
);
let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
if let Some(open) = open {
if open != state.is_open() {
state.toggle(ui);
header_response.mark_changed();
}
} else if header_response.clicked() {
state.toggle(ui);
header_response.mark_changed();
}
header_response
.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, text.text()));
let openness = state.openness(ui.ctx());
if ui.is_rect_visible(rect) {
let visuals = ui.style().interact_selectable(&header_response, selected);
if ui.visuals().collapsing_header_frame || show_background {
ui.painter().add(epaint::RectShape {
rect: header_response.rect.expand(visuals.expansion),
rounding: visuals.rounding,
fill: visuals.weak_bg_fill,
stroke: visuals.bg_stroke,
// stroke: Default::default(),
});
}
if selected || selectable && (header_response.hovered() || header_response.has_focus())
{
let rect = rect.expand(visuals.expansion);
ui.painter()
.rect(rect, visuals.rounding, visuals.bg_fill, visuals.bg_stroke);
}
{
let (mut icon_rect, _) = ui.spacing().icon_rectangles(header_response.rect);
icon_rect.set_center(pos2(
header_response.rect.left() + ui.spacing().indent / 2.0,
header_response.rect.center().y,
));
let icon_response = header_response.clone().with_new_rect(icon_rect);
if let Some(icon) = icon {
icon(ui, openness, &icon_response);
} else {
paint_default_icon(ui, openness, &icon_response);
}
}
text.paint_with_visuals(ui.painter(), text_pos, &visuals);
}
Prepared {
header_response,
state,
openness,
}
}
#[inline]
pub fn show<R>(
self,
ui: &mut Ui,
add_body: impl FnOnce(&mut Ui) -> R,
) -> CollapsingResponse<R> {
self.show_dyn(ui, Box::new(add_body), true)
}
#[inline]
pub fn show_unindented<R>(
self,
ui: &mut Ui,
add_body: impl FnOnce(&mut Ui) -> R,
) -> CollapsingResponse<R> {
self.show_dyn(ui, Box::new(add_body), false)
}
fn show_dyn<'c, R>(
self,
ui: &mut Ui,
add_body: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
indented: bool,
) -> CollapsingResponse<R> {
// Make sure body is bellow header,
// and make sure it is one unit (necessary for putting a [`CollapsingHeader`] in a grid).
ui.vertical(|ui| {
ui.set_enabled(self.enabled);
let Prepared {
header_response,
mut state,
openness,
} = self.begin(ui); // show the header
let ret_response = if indented {
state.show_body_indented(&header_response, ui, add_body)
} else {
state.show_body_unindented(ui, add_body)
};
if let Some(ret_response) = ret_response {
CollapsingResponse {
header_response,
body_response: Some(ret_response.response),
body_returned: Some(ret_response.inner),
openness,
}
} else {
CollapsingResponse {
header_response,
body_response: None,
body_returned: None,
openness,
}
}
})
.inner
}
}
/// The response from showing a [`CollapsingHeader`].
pub struct CollapsingResponse<R> {
/// Response of the actual clickable header.
pub header_response: Response,
/// None iff collapsed.
pub body_response: Option<Response>,
/// None iff collapsed.
pub body_returned: Option<R>,
/// 0.0 if fully closed, 1.0 if fully open, and something in-between while animating.
pub openness: f32,
}
impl<R> CollapsingResponse<R> {
/// Was the [`CollapsingHeader`] fully closed (and not being animated)?
pub fn fully_closed(&self) -> bool {
self.openness <= 0.0
}
/// Was the [`CollapsingHeader`] fully open (and not being animated)?
pub fn fully_open(&self) -> bool {
self.openness >= 1.0
}
}

View file

@ -0,0 +1,429 @@
use epaint::Shape;
use crate::{style::WidgetVisuals, *};
/// Indicate wether or not a popup will be shown above or below the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AboveOrBelow {
Above,
Below,
}
/// A function that paints the [`ComboBox`] icon
pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
/// A drop-down selection menu with a descriptive label.
///
/// ```
/// # #[derive(Debug, PartialEq)]
/// # enum Enum { First, Second, Third }
/// # let mut selected = Enum::First;
/// # egui::__run_test_ui(|ui| {
/// egui::ComboBox::from_label("Select one!")
/// .selected_text(format!("{:?}", selected))
/// .show_ui(ui, |ui| {
/// ui.selectable_value(&mut selected, Enum::First, "First");
/// ui.selectable_value(&mut selected, Enum::Second, "Second");
/// ui.selectable_value(&mut selected, Enum::Third, "Third");
/// }
/// );
/// # });
/// ```
#[must_use = "You should call .show*"]
pub struct ComboBox {
id_source: Id,
label: Option<WidgetText>,
selected_text: WidgetText,
width: Option<f32>,
icon: Option<IconPainter>,
wrap_enabled: bool,
}
impl ComboBox {
/// Create new [`ComboBox`] with id and label
pub fn new(id_source: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
Self {
id_source: Id::new(id_source),
label: Some(label.into()),
selected_text: Default::default(),
width: None,
icon: None,
wrap_enabled: false,
}
}
/// Label shown next to the combo box
pub fn from_label(label: impl Into<WidgetText>) -> Self {
let label = label.into();
Self {
id_source: Id::new(label.text()),
label: Some(label),
selected_text: Default::default(),
width: None,
icon: None,
wrap_enabled: false,
}
}
/// Without label.
pub fn from_id_source(id_source: impl std::hash::Hash) -> Self {
Self {
id_source: Id::new(id_source),
label: Default::default(),
selected_text: Default::default(),
width: None,
icon: None,
wrap_enabled: false,
}
}
/// Set the outer width of the button and menu.
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
/// What we show as the currently selected value
pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
self.selected_text = selected_text.into();
self
}
/// Use the provided function to render a different [`ComboBox`] icon.
/// Defaults to a triangle that expands when the cursor is hovering over the [`ComboBox`].
///
/// For example:
/// ```
/// # egui::__run_test_ui(|ui| {
/// # let text = "Selected text";
/// pub fn filled_triangle(
/// ui: &egui::Ui,
/// rect: egui::Rect,
/// visuals: &egui::style::WidgetVisuals,
/// _is_open: bool,
/// _above_or_below: egui::AboveOrBelow,
/// ) {
/// let rect = egui::Rect::from_center_size(
/// rect.center(),
/// egui::vec2(rect.width() * 0.6, rect.height() * 0.4),
/// );
/// ui.painter().add(egui::Shape::convex_polygon(
/// vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
/// visuals.fg_stroke.color,
/// visuals.fg_stroke,
/// ));
/// }
///
/// egui::ComboBox::from_id_source("my-combobox")
/// .selected_text(text)
/// .icon(filled_triangle)
/// .show_ui(ui, |_ui| {});
/// # });
/// ```
pub fn icon(
mut self,
icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
) -> Self {
self.icon = Some(Box::new(icon_fn));
self
}
/// Controls whether text wrap is used for the selected text
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap_enabled = wrap;
self
}
/// Show the combo box, with the given ui code for the menu contents.
///
/// Returns `InnerResponse { inner: None }` if the combo box is closed.
pub fn show_ui<R>(
self,
ui: &mut Ui,
menu_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
self.show_ui_dyn(ui, Box::new(menu_contents))
}
fn show_ui_dyn<'c, R>(
self,
ui: &mut Ui,
menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> InnerResponse<Option<R>> {
let Self {
id_source,
label,
selected_text,
width,
icon,
wrap_enabled,
} = self;
let button_id = ui.make_persistent_id(id_source);
ui.horizontal(|ui| {
let mut ir = combo_box_dyn(
ui,
button_id,
selected_text,
menu_contents,
icon,
wrap_enabled,
width,
);
if let Some(label) = label {
ir.response
.widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, label.text()));
ir.response |= ui.label(label);
} else {
ir.response
.widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ""));
}
ir
})
.inner
}
/// Show a list of items with the given selected index.
///
///
/// ```
/// # #[derive(Debug, PartialEq)]
/// # enum Enum { First, Second, Third }
/// # let mut selected = Enum::First;
/// # egui::__run_test_ui(|ui| {
/// let alternatives = ["a", "b", "c", "d"];
/// let mut selected = 2;
/// egui::ComboBox::from_label("Select one!").show_index(
/// ui,
/// &mut selected,
/// alternatives.len(),
/// |i| alternatives[i].to_owned()
/// );
/// # });
/// ```
pub fn show_index(
self,
ui: &mut Ui,
selected: &mut usize,
len: usize,
get: impl Fn(usize) -> String,
) -> Response {
let slf = self.selected_text(get(*selected));
let mut changed = false;
let mut response = slf
.show_ui(ui, |ui| {
for i in 0..len {
if ui.selectable_label(i == *selected, get(i)).clicked() {
*selected = i;
changed = true;
}
}
})
.response;
if changed {
response.mark_changed();
}
response
}
}
fn combo_box_dyn<'c, R>(
ui: &mut Ui,
button_id: Id,
selected_text: WidgetText,
menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
icon: Option<IconPainter>,
wrap_enabled: bool,
width: Option<f32>,
) -> InnerResponse<Option<R>> {
let popup_id = button_id.with("popup");
let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
let popup_height = ui.memory(|m| m.areas.get(popup_id).map_or(100.0, |state| state.size.y));
let above_or_below =
if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
< ui.ctx().screen_rect().bottom()
{
AboveOrBelow::Below
} else {
AboveOrBelow::Above
};
let margin = ui.spacing().button_padding;
let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
let icon_spacing = ui.spacing().icon_spacing;
// We don't want to change width when user selects something new
let full_minimum_width = if wrap_enabled {
// Currently selected value's text will be wrapped if needed, so occupy the available width.
ui.available_width()
} else {
// Occupy at least the minimum width assigned to ComboBox.
let width = width.unwrap_or_else(|| ui.spacing().combo_width);
width - 2.0 * margin.x
};
let icon_size = Vec2::splat(ui.spacing().icon_width);
let wrap_width = if wrap_enabled {
// Use the available width, currently selected value's text will be wrapped if exceeds this value.
ui.available_width() - icon_spacing - icon_size.x
} else {
// Use all the width necessary to display the currently selected value's text.
f32::INFINITY
};
let galley =
selected_text.into_galley(ui, Some(wrap_enabled), wrap_width, TextStyle::Button);
// The width necessary to contain the whole widget with the currently selected value's text.
let width = if wrap_enabled {
full_minimum_width
} else {
// Occupy at least the minimum width needed to contain the widget with the currently selected value's text.
galley.size().x + icon_spacing + icon_size.x
};
// Case : wrap_enabled : occupy all the available width.
// Case : !wrap_enabled : occupy at least the minimum width assigned to Slider and ComboBox,
// increase if the currently selected value needs additional horizontal space to fully display its text (up to wrap_width (f32::INFINITY)).
let width = width.at_least(full_minimum_width);
let height = galley.size().y.max(icon_size.y);
let (_, rect) = ui.allocate_space(Vec2::new(width, height));
let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
let response = ui.interact(button_rect, button_id, Sense::click());
// response.active |= is_popup_open;
if ui.is_rect_visible(rect) {
let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
let visuals = if is_popup_open {
&ui.visuals().widgets.open
} else {
ui.style().interact(&response)
};
if let Some(icon) = icon {
icon(
ui,
icon_rect.expand(visuals.expansion),
visuals,
is_popup_open,
above_or_below,
);
} else {
paint_default_icon(
ui.painter(),
icon_rect.expand(visuals.expansion),
visuals,
above_or_below,
);
}
let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
galley.paint_with_visuals(ui.painter(), text_rect.min, visuals);
}
});
if button_response.clicked() {
ui.memory_mut(|mem| mem.toggle_popup(popup_id));
}
let inner = crate::popup::popup_above_or_below_widget(
ui,
popup_id,
&button_response,
above_or_below,
|ui| {
ScrollArea::vertical()
.max_height(ui.spacing().combo_height)
.show(ui, menu_contents)
.inner
},
);
InnerResponse {
inner,
response: button_response,
}
}
fn button_frame(
ui: &mut Ui,
id: Id,
is_popup_open: bool,
sense: Sense,
add_contents: impl FnOnce(&mut Ui),
) -> Response {
let where_to_put_background = ui.painter().add(Shape::Noop);
let margin = ui.spacing().button_padding;
let interact_size = ui.spacing().interact_size;
let mut outer_rect = ui.available_rect_before_wrap();
outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
let inner_rect = outer_rect.shrink2(margin);
let mut content_ui = ui.child_ui(inner_rect, *ui.layout());
add_contents(&mut content_ui);
let mut outer_rect = content_ui.min_rect().expand2(margin);
outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
let response = ui.interact(outer_rect, id, sense);
if ui.is_rect_visible(outer_rect) {
let visuals = if is_popup_open {
&ui.visuals().widgets.open
} else {
ui.style().interact(&response)
};
ui.painter().set(
where_to_put_background,
epaint::RectShape {
rect: outer_rect.expand(visuals.expansion),
rounding: visuals.rounding,
fill: visuals.weak_bg_fill,
stroke: visuals.bg_stroke,
},
);
}
ui.advance_cursor_after_rect(outer_rect);
response
}
fn paint_default_icon(
painter: &Painter,
rect: Rect,
visuals: &WidgetVisuals,
above_or_below: AboveOrBelow,
) {
let rect = Rect::from_center_size(
rect.center(),
vec2(rect.width() * 0.7, rect.height() * 0.45),
);
match above_or_below {
AboveOrBelow::Above => {
// Upward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
AboveOrBelow::Below => {
// Downward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
}
}

View file

@ -0,0 +1,288 @@
//! Frame container
use crate::{layers::ShapeIdx, style::Margin, *};
use epaint::*;
/// Add a background, frame and/or margin to a rectangular background of a [`Ui`].
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// egui::Frame::none()
/// .fill(egui::Color32::RED)
/// .show(ui, |ui| {
/// ui.label("Label with red background");
/// });
/// # });
/// ```
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[must_use = "You should call .show()"]
pub struct Frame {
/// Margin within the painted frame.
pub inner_margin: Margin,
/// Margin outside the painted frame.
pub outer_margin: Margin,
pub rounding: Rounding,
pub shadow: Shadow,
pub fill: Color32,
pub stroke: Stroke,
}
impl Frame {
pub fn none() -> Self {
Self::default()
}
/// For when you want to group a few widgets together within a frame.
pub fn group(style: &Style) -> Self {
Self {
inner_margin: Margin::same(6.0), // same and symmetric looks best in corners when nesting groups
rounding: style.visuals.widgets.noninteractive.rounding,
stroke: style.visuals.widgets.noninteractive.bg_stroke,
..Default::default()
}
}
pub fn side_top_panel(style: &Style) -> Self {
Self {
inner_margin: Margin::symmetric(8.0, 2.0),
fill: style.visuals.panel_fill,
..Default::default()
}
}
pub fn central_panel(style: &Style) -> Self {
Self {
inner_margin: Margin::same(8.0),
fill: style.visuals.panel_fill,
..Default::default()
}
}
pub fn window(style: &Style) -> Self {
Self {
inner_margin: style.spacing.window_margin,
rounding: style.visuals.window_rounding,
shadow: style.visuals.window_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
}
pub fn menu(style: &Style) -> Self {
Self {
inner_margin: style.spacing.menu_margin,
rounding: style.visuals.menu_rounding,
shadow: style.visuals.popup_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
}
pub fn popup(style: &Style) -> Self {
Self {
inner_margin: style.spacing.menu_margin,
rounding: style.visuals.menu_rounding,
shadow: style.visuals.popup_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
}
/// A canvas to draw on.
///
/// In bright mode this will be very bright,
/// and in dark mode this will be very dark.
pub fn canvas(style: &Style) -> Self {
Self {
inner_margin: Margin::same(2.0),
rounding: style.visuals.widgets.noninteractive.rounding,
fill: style.visuals.extreme_bg_color,
stroke: style.visuals.window_stroke(),
..Default::default()
}
}
/// A dark canvas to draw on.
pub fn dark_canvas(style: &Style) -> Self {
Self {
fill: Color32::from_black_alpha(250),
..Self::canvas(style)
}
}
}
impl Frame {
#[inline]
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = fill;
self
}
#[inline]
pub fn stroke(mut self, stroke: Stroke) -> Self {
self.stroke = stroke;
self
}
#[inline]
pub fn rounding(mut self, rounding: impl Into<Rounding>) -> Self {
self.rounding = rounding.into();
self
}
/// Margin within the painted frame.
#[inline]
pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self {
self.inner_margin = inner_margin.into();
self
}
/// Margin outside the painted frame.
#[inline]
pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self {
self.outer_margin = outer_margin.into();
self
}
#[deprecated = "Renamed inner_margin in egui 0.18"]
#[inline]
pub fn margin(self, margin: impl Into<Margin>) -> Self {
self.inner_margin(margin)
}
#[inline]
pub fn shadow(mut self, shadow: Shadow) -> Self {
self.shadow = shadow;
self
}
pub fn multiply_with_opacity(mut self, opacity: f32) -> Self {
self.fill = self.fill.linear_multiply(opacity);
self.stroke.color = self.stroke.color.linear_multiply(opacity);
self.shadow.color = self.shadow.color.linear_multiply(opacity);
self
}
}
impl Frame {
/// inner margin plus outer margin.
#[inline]
pub fn total_margin(&self) -> Margin {
self.inner_margin + self.outer_margin
}
}
// ----------------------------------------------------------------------------
pub struct Prepared {
pub frame: Frame,
where_to_put_background: ShapeIdx,
pub content_ui: Ui,
}
impl Frame {
pub fn begin(self, ui: &mut Ui) -> Prepared {
let where_to_put_background = ui.painter().add(Shape::Noop);
let outer_rect_bounds = ui.available_rect_before_wrap();
let mut inner_rect = outer_rect_bounds;
inner_rect.min += self.outer_margin.left_top() + self.inner_margin.left_top();
inner_rect.max -= self.outer_margin.right_bottom() + self.inner_margin.right_bottom();
// Make sure we don't shrink to the negative:
inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x);
inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y);
let content_ui = ui.child_ui(inner_rect, *ui.layout());
// content_ui.set_clip_rect(outer_rect_bounds.shrink(self.stroke.width * 0.5)); // Can't do this since we don't know final size yet
Prepared {
frame: self,
where_to_put_background,
content_ui,
}
}
pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
self.show_dyn(ui, Box::new(add_contents))
}
fn show_dyn<'c, R>(
self,
ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> InnerResponse<R> {
let mut prepared = self.begin(ui);
let ret = add_contents(&mut prepared.content_ui);
let response = prepared.end(ui);
InnerResponse::new(ret, response)
}
pub fn paint(&self, outer_rect: Rect) -> Shape {
let Self {
inner_margin: _,
outer_margin: _,
rounding,
shadow,
fill,
stroke,
} = *self;
let frame_shape = Shape::Rect(epaint::RectShape {
rect: outer_rect,
rounding,
fill,
stroke,
});
if shadow == Default::default() {
frame_shape
} else {
let shadow = shadow.tessellate(outer_rect, rounding);
let shadow = Shape::Mesh(shadow);
Shape::Vec(vec![shadow, frame_shape])
}
}
}
impl Prepared {
fn paint_rect(&self) -> Rect {
let mut rect = self.content_ui.min_rect();
rect.min -= self.frame.inner_margin.left_top();
rect.max += self.frame.inner_margin.right_bottom();
rect
}
fn content_with_margin(&self) -> Rect {
let mut rect = self.content_ui.min_rect();
rect.min -= self.frame.inner_margin.left_top() + self.frame.outer_margin.left_top();
rect.max += self.frame.inner_margin.right_bottom() + self.frame.outer_margin.right_bottom();
rect
}
pub fn end(self, ui: &mut Ui) -> Response {
let paint_rect = self.paint_rect();
let Prepared {
frame,
where_to_put_background,
..
} = self;
if ui.is_rect_visible(paint_rect) {
let shape = frame.paint(paint_rect);
ui.painter().set(where_to_put_background, shape);
}
ui.allocate_rect(self.content_with_margin(), Sense::hover())
}
}

View file

@ -0,0 +1,25 @@
//! Containers are pieces of the UI which wraps other pieces of UI. Examples: [`Window`], [`ScrollArea`], [`Resize`], [`SidePanel`], etc.
//!
//! For instance, a [`Frame`] adds a frame and background to some contained UI.
pub(crate) mod area;
pub mod collapsing_header;
mod combo_box;
pub(crate) mod frame;
pub mod panel;
pub mod popup;
pub(crate) mod resize;
pub mod scroll_area;
pub(crate) mod window;
pub use {
area::Area,
collapsing_header::{CollapsingHeader, CollapsingResponse},
combo_box::*,
frame::Frame,
panel::{CentralPanel, SidePanel, TopBottomPanel},
popup::*,
resize::Resize,
scroll_area::ScrollArea,
window::Window,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,378 @@
//! Show popup windows, tooltips, context menus etc.
use crate::*;
// ----------------------------------------------------------------------------
/// Same state for all tooltips.
#[derive(Clone, Debug, Default)]
pub(crate) struct TooltipState {
last_common_id: Option<Id>,
individual_ids_and_sizes: ahash::HashMap<usize, (Id, Vec2)>,
}
impl TooltipState {
pub fn load(ctx: &Context) -> Option<Self> {
ctx.data_mut(|d| d.get_temp(Id::null()))
}
fn store(self, ctx: &Context) {
ctx.data_mut(|d| d.insert_temp(Id::null(), self));
}
fn individual_tooltip_size(&self, common_id: Id, index: usize) -> Option<Vec2> {
if self.last_common_id == Some(common_id) {
Some(self.individual_ids_and_sizes.get(&index)?.1)
} else {
None
}
}
fn set_individual_tooltip(
&mut self,
common_id: Id,
index: usize,
individual_id: Id,
size: Vec2,
) {
if self.last_common_id != Some(common_id) {
self.last_common_id = Some(common_id);
self.individual_ids_and_sizes.clear();
}
self.individual_ids_and_sizes
.insert(index, (individual_id, size));
}
}
// ----------------------------------------------------------------------------
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip(ui.ctx(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
pub fn show_tooltip<R>(
ctx: &Context,
id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
show_tooltip_at_pointer(ctx, id, add_contents)
}
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_at_pointer(ui.ctx(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
pub fn show_tooltip_at_pointer<R>(
ctx: &Context,
id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let suggested_pos = ctx
.input(|i| i.pointer.hover_pos())
.map(|pointer_pos| pointer_pos + vec2(16.0, 16.0));
show_tooltip_at(ctx, id, suggested_pos, add_contents)
}
/// Show a tooltip under the given area.
///
/// If the tooltip does not fit under the area, it tries to place it above it instead.
pub fn show_tooltip_for<R>(
ctx: &Context,
id: Id,
rect: &Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let expanded_rect = rect.expand2(vec2(2.0, 4.0));
let (above, position) = if ctx.input(|i| i.any_touches()) {
(true, expanded_rect.left_top())
} else {
(false, expanded_rect.left_bottom())
};
show_tooltip_at_avoid_dyn(
ctx,
id,
Some(position),
above,
expanded_rect,
Box::new(add_contents),
)
}
/// Show a tooltip at the given position.
///
/// Returns `None` if the tooltip could not be placed.
pub fn show_tooltip_at<R>(
ctx: &Context,
id: Id,
suggested_position: Option<Pos2>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let above = false;
show_tooltip_at_avoid_dyn(
ctx,
id,
suggested_position,
above,
Rect::NOTHING,
Box::new(add_contents),
)
}
fn show_tooltip_at_avoid_dyn<'c, R>(
ctx: &Context,
individual_id: Id,
suggested_position: Option<Pos2>,
above: bool,
mut avoid_rect: Rect,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> Option<R> {
let spacing = 4.0;
// if there are multiple tooltips open they should use the same common_id for the `tooltip_size` caching to work.
let mut frame_state =
ctx.frame_state(|fs| fs.tooltip_state)
.unwrap_or(crate::frame_state::TooltipFrameState {
common_id: individual_id,
rect: Rect::NOTHING,
count: 0,
});
let mut position = if frame_state.rect.is_positive() {
avoid_rect = avoid_rect.union(frame_state.rect);
if above {
frame_state.rect.left_top() - spacing * Vec2::Y
} else {
frame_state.rect.left_bottom() + spacing * Vec2::Y
}
} else if let Some(position) = suggested_position {
position
} else if ctx.memory(|mem| mem.everything_is_visible()) {
Pos2::ZERO
} else {
return None; // No good place for a tooltip :(
};
let mut long_state = TooltipState::load(ctx).unwrap_or_default();
let expected_size =
long_state.individual_tooltip_size(frame_state.common_id, frame_state.count);
let expected_size = expected_size.unwrap_or_else(|| vec2(64.0, 32.0));
if above {
position.y -= expected_size.y;
}
position = position.at_most(ctx.screen_rect().max - expected_size);
// check if we intersect the avoid_rect
{
let new_rect = Rect::from_min_size(position, expected_size);
// Note: We use shrink so that we don't get false positives when the rects just touch
if new_rect.shrink(1.0).intersects(avoid_rect) {
if above {
// place below instead:
position = avoid_rect.left_bottom() + spacing * Vec2::Y;
} else {
// place above instead:
position = Pos2::new(position.x, avoid_rect.min.y - expected_size.y - spacing);
}
}
}
let position = position.at_least(ctx.screen_rect().min);
let area_id = frame_state.common_id.with(frame_state.count);
let InnerResponse { inner, response } =
show_tooltip_area_dyn(ctx, area_id, position, add_contents);
long_state.set_individual_tooltip(
frame_state.common_id,
frame_state.count,
individual_id,
response.rect.size(),
);
long_state.store(ctx);
frame_state.count += 1;
frame_state.rect = frame_state.rect.union(response.rect);
ctx.frame_state_mut(|fs| fs.tooltip_state = Some(frame_state));
Some(inner)
}
/// Show some text at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_text`].
///
/// See also [`show_tooltip`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_text(ui.ctx(), egui::Id::new("my_tooltip"), "Helpful text");
/// }
/// # });
/// ```
pub fn show_tooltip_text(ctx: &Context, id: Id, text: impl Into<WidgetText>) -> Option<()> {
show_tooltip(ctx, id, |ui| {
crate::widgets::Label::new(text).ui(ui);
})
}
/// Show a pop-over window.
fn show_tooltip_area_dyn<'c, R>(
ctx: &Context,
area_id: Id,
window_pos: Pos2,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> InnerResponse<R> {
use containers::*;
Area::new(area_id)
.order(Order::Tooltip)
.fixed_pos(window_pos)
.constrain(true)
.interactable(false)
.drag_bounds(ctx.screen_rect())
.show(ctx, |ui| {
Frame::popup(&ctx.style())
.show(ui, |ui| {
ui.set_max_width(ui.spacing().tooltip_width);
add_contents(ui)
})
.inner
})
}
/// Was this popup visible last frame?
pub fn was_tooltip_open_last_frame(ctx: &Context, tooltip_id: Id) -> bool {
if let Some(state) = TooltipState::load(ctx) {
if let Some(common_id) = state.last_common_id {
for (count, (individual_id, _size)) in &state.individual_ids_and_sizes {
if *individual_id == tooltip_id {
let area_id = common_id.with(count);
let layer_id = LayerId::new(Order::Tooltip, area_id);
if ctx.memory(|mem| mem.areas.visible_last_frame(&layer_id)) {
return true;
}
}
}
}
}
false
}
/// Helper for [`popup_above_or_below_widget`].
pub fn popup_below_widget<R>(
ui: &Ui,
popup_id: Id,
widget_response: &Response,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
popup_above_or_below_widget(
ui,
popup_id,
widget_response,
AboveOrBelow::Below,
add_contents,
)
}
/// Shows a popup above or below another widget.
///
/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields.
///
/// The opened popup will have the same width as the parent.
///
/// You must open the popup with [`Memory::open_popup`] or [`Memory::toggle_popup`].
///
/// Returns `None` if the popup is not open.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let response = ui.button("Open popup");
/// let popup_id = ui.make_persistent_id("my_unique_id");
/// if response.clicked() {
/// ui.memory_mut(|mem| mem.toggle_popup(popup_id));
/// }
/// let below = egui::AboveOrBelow::Below;
/// egui::popup::popup_above_or_below_widget(ui, popup_id, &response, below, |ui| {
/// ui.set_min_width(200.0); // if you want to control the size
/// ui.label("Some more info, or things you can select:");
/// ui.label("…");
/// });
/// # });
/// ```
pub fn popup_above_or_below_widget<R>(
ui: &Ui,
popup_id: Id,
widget_response: &Response,
above_or_below: AboveOrBelow,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
if ui.memory(|mem| mem.is_popup_open(popup_id)) {
let (pos, pivot) = match above_or_below {
AboveOrBelow::Above => (widget_response.rect.left_top(), Align2::LEFT_BOTTOM),
AboveOrBelow::Below => (widget_response.rect.left_bottom(), Align2::LEFT_TOP),
};
let inner = Area::new(popup_id)
.order(Order::Foreground)
.constrain(true)
.fixed_pos(pos)
.pivot(pivot)
.show(ui.ctx(), |ui| {
// Note: we use a separate clip-rect for this area, so the popup can be outside the parent.
// See https://github.com/emilk/egui/issues/825
let frame = Frame::popup(ui.style());
let frame_margin = frame.total_margin();
frame
.show(ui, |ui| {
ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| {
ui.set_width(widget_response.rect.width() - frame_margin.sum().x);
add_contents(ui)
})
.inner
})
.inner
})
.inner;
if ui.input(|i| i.key_pressed(Key::Escape)) || widget_response.clicked_elsewhere() {
ui.memory_mut(|mem| mem.close_popup());
}
Some(inner)
} else {
None
}
}

View file

@ -0,0 +1,351 @@
use crate::*;
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub(crate) struct State {
/// This is the size that the user has picked by dragging the resize handles.
/// This may be smaller and/or larger than the actual size.
/// For instance, the user may have tried to shrink too much (not fitting the contents).
/// Or the user requested a large area, but the content don't need that much space.
pub(crate) desired_size: Vec2,
/// Actual size of content last frame
last_content_size: Vec2,
/// Externally requested size (e.g. by Window) for the next frame
pub(crate) requested_size: Option<Vec2>,
}
impl State {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| d.get_persisted(id))
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.data_mut(|d| d.insert_persisted(id, self));
}
}
/// A region that can be resized by dragging the bottom right corner.
#[derive(Clone, Copy, Debug)]
#[must_use = "You should call .show()"]
pub struct Resize {
id: Option<Id>,
id_source: Option<Id>,
/// If false, we are no enabled
resizable: bool,
pub(crate) min_size: Vec2,
pub(crate) max_size: Vec2,
default_size: Vec2,
with_stroke: bool,
}
impl Default for Resize {
fn default() -> Self {
Self {
id: None,
id_source: None,
resizable: true,
min_size: Vec2::splat(16.0),
max_size: Vec2::splat(f32::INFINITY),
default_size: vec2(320.0, 128.0), // TODO(emilk): preferred size of [`Resize`] area.
with_stroke: true,
}
}
}
impl Resize {
/// Assign an explicit and globally unique id.
pub fn id(mut self, id: Id) -> Self {
self.id = Some(id);
self
}
/// A source for the unique [`Id`], e.g. `.id_source("second_resize_area")` or `.id_source(loop_index)`.
pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self {
self.id_source = Some(Id::new(id_source));
self
}
/// Preferred / suggested width. Actual width will depend on contents.
///
/// Examples:
/// * if the contents is text, this will decide where we break long lines.
/// * if the contents is a canvas, this decides the width of it,
/// * if the contents is some buttons, this is ignored and we will auto-size.
pub fn default_width(mut self, width: f32) -> Self {
self.default_size.x = width;
self
}
/// Preferred / suggested height. Actual height will depend on contents.
///
/// Examples:
/// * if the contents is a [`ScrollArea`] then this decides the maximum size.
/// * if the contents is a canvas, this decides the height of it,
/// * if the contents is text and buttons, then the `default_height` is ignored
/// and the height is picked automatically..
pub fn default_height(mut self, height: f32) -> Self {
self.default_size.y = height;
self
}
pub fn default_size(mut self, default_size: impl Into<Vec2>) -> Self {
self.default_size = default_size.into();
self
}
/// Won't shrink to smaller than this
pub fn min_size(mut self, min_size: impl Into<Vec2>) -> Self {
self.min_size = min_size.into();
self
}
/// Won't shrink to smaller than this
pub fn min_width(mut self, min_width: f32) -> Self {
self.min_size.x = min_width;
self
}
/// Won't shrink to smaller than this
pub fn min_height(mut self, min_height: f32) -> Self {
self.min_size.y = min_height;
self
}
/// Won't expand to larger than this
pub fn max_size(mut self, max_size: impl Into<Vec2>) -> Self {
self.max_size = max_size.into();
self
}
/// Can you resize it with the mouse?
/// Note that a window can still auto-resize
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
pub fn is_resizable(&self) -> bool {
self.resizable
}
/// Not manually resizable, just takes the size of its contents.
/// Text will not wrap, but will instead make your window width expand.
pub fn auto_sized(self) -> Self {
self.min_size(Vec2::ZERO)
.default_size(Vec2::splat(f32::INFINITY))
.resizable(false)
}
pub fn fixed_size(mut self, size: impl Into<Vec2>) -> Self {
let size = size.into();
self.default_size = size;
self.min_size = size;
self.max_size = size;
self.resizable = false;
self
}
pub fn with_stroke(mut self, with_stroke: bool) -> Self {
self.with_stroke = with_stroke;
self
}
}
struct Prepared {
id: Id,
state: State,
corner_response: Option<Response>,
content_ui: Ui,
}
impl Resize {
fn begin(&mut self, ui: &mut Ui) -> Prepared {
let position = ui.available_rect_before_wrap().min;
let id = self.id.unwrap_or_else(|| {
let id_source = self.id_source.unwrap_or_else(|| Id::new("resize"));
ui.make_persistent_id(id_source)
});
let mut state = State::load(ui.ctx(), id).unwrap_or_else(|| {
ui.ctx().request_repaint(); // counter frame delay
let default_size = self
.default_size
.at_least(self.min_size)
.at_most(self.max_size)
.at_most(
ui.ctx().screen_rect().size() - ui.spacing().window_margin.sum(), // hack for windows
);
State {
desired_size: default_size,
last_content_size: vec2(0.0, 0.0),
requested_size: None,
}
});
state.desired_size = state
.desired_size
.at_least(self.min_size)
.at_most(self.max_size);
let mut user_requested_size = state.requested_size.take();
let corner_response = if self.resizable {
// Resize-corner:
let corner_size = Vec2::splat(ui.visuals().resize_corner_size);
let corner_rect =
Rect::from_min_size(position + state.desired_size - corner_size, corner_size);
let corner_response = ui.interact(corner_rect, id.with("corner"), Sense::drag());
if let Some(pointer_pos) = corner_response.interact_pointer_pos() {
user_requested_size =
Some(pointer_pos - position + 0.5 * corner_response.rect.size());
}
Some(corner_response)
} else {
None
};
if let Some(user_requested_size) = user_requested_size {
state.desired_size = user_requested_size;
} else {
// We are not being actively resized, so auto-expand to include size of last frame.
// This prevents auto-shrinking if the contents contain width-filling widgets (separators etc)
// but it makes a lot of interactions with [`Window`]s nicer.
state.desired_size = state.desired_size.max(state.last_content_size);
}
state.desired_size = state
.desired_size
.at_least(self.min_size)
.at_most(self.max_size);
// ------------------------------
let inner_rect = Rect::from_min_size(position, state.desired_size);
let mut content_clip_rect = inner_rect.expand(ui.visuals().clip_rect_margin);
// If we pull the resize handle to shrink, we want to TRY to shrink it.
// After laying out the contents, we might be much bigger.
// In those cases we don't want the clip_rect to be smaller, because
// then we will clip the contents of the region even thought the result gets larger. This is simply ugly!
// So we use the memory of last_content_size to make the clip rect large enough.
content_clip_rect.max = content_clip_rect.max.max(
inner_rect.min + state.last_content_size + Vec2::splat(ui.visuals().clip_rect_margin),
);
content_clip_rect = content_clip_rect.intersect(ui.clip_rect()); // Respect parent region
let mut content_ui = ui.child_ui(inner_rect, *ui.layout());
content_ui.set_clip_rect(content_clip_rect);
Prepared {
id,
state,
corner_response,
content_ui,
}
}
pub fn show<R>(mut self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
let mut prepared = self.begin(ui);
let ret = add_contents(&mut prepared.content_ui);
self.end(ui, prepared);
ret
}
fn end(self, ui: &mut Ui, prepared: Prepared) {
let Prepared {
id,
mut state,
corner_response,
content_ui,
} = prepared;
state.last_content_size = content_ui.min_size();
// ------------------------------
let size = if self.with_stroke || self.resizable {
// We show how large we are,
// so we must follow the contents:
state.desired_size = state.desired_size.max(state.last_content_size);
// We are as large as we look
state.desired_size
} else {
// Probably a window.
state.last_content_size
};
ui.advance_cursor_after_rect(Rect::from_min_size(content_ui.min_rect().min, size));
// ------------------------------
if self.with_stroke && corner_response.is_some() {
let rect = Rect::from_min_size(content_ui.min_rect().left_top(), state.desired_size);
let rect = rect.expand(2.0); // breathing room for content
ui.painter().add(Shape::rect_stroke(
rect,
3.0,
ui.visuals().widgets.noninteractive.bg_stroke,
));
}
if let Some(corner_response) = corner_response {
paint_resize_corner(ui, &corner_response);
if corner_response.hovered() || corner_response.dragged() {
ui.ctx().set_cursor_icon(CursorIcon::ResizeNwSe);
}
}
state.store(ui.ctx(), id);
if ui.ctx().style().debug.show_resize {
ui.ctx().debug_painter().debug_rect(
Rect::from_min_size(content_ui.min_rect().left_top(), state.desired_size),
Color32::GREEN,
"desired_size",
);
ui.ctx().debug_painter().debug_rect(
Rect::from_min_size(content_ui.min_rect().left_top(), state.last_content_size),
Color32::LIGHT_BLUE,
"last_content_size",
);
}
}
}
use epaint::Stroke;
pub fn paint_resize_corner(ui: &mut Ui, response: &Response) {
let stroke = ui.style().interact(response).fg_stroke;
paint_resize_corner_with_style(ui, &response.rect, stroke, Align2::RIGHT_BOTTOM);
}
pub fn paint_resize_corner_with_style(ui: &mut Ui, rect: &Rect, stroke: Stroke, corner: Align2) {
let painter = ui.painter();
let cp = painter.round_pos_to_pixels(corner.pos_in_rect(rect));
let mut w = 2.0;
while w <= rect.width() && w <= rect.height() {
painter.line_segment(
[
pos2(cp.x - w * corner.x().to_sign(), cp.y),
pos2(cp.x, cp.y - w * corner.y().to_sign()),
],
stroke,
);
w += 4.0;
}
}

View file

@ -0,0 +1,924 @@
//! Coordinate system names:
//! * content: size of contents (generally large; that's why we want scroll bars)
//! * outer: size of scroll area including scroll bar(s)
//! * inner: excluding scroll bar(s). The area we clip the contents to.
#![allow(clippy::needless_range_loop)]
use crate::*;
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct State {
/// Positive offset means scrolling down/right
pub offset: Vec2,
/// Were the scroll bars visible last frame?
show_scroll: [bool; 2],
/// The content were to large to fit large frame.
content_is_too_large: [bool; 2],
/// Momentum, used for kinetic scrolling
#[cfg_attr(feature = "serde", serde(skip))]
vel: Vec2,
/// Mouse offset relative to the top of the handle when started moving the handle.
scroll_start_offset_from_top_left: [Option<f32>; 2],
/// Is the scroll sticky. This is true while scroll handle is in the end position
/// and remains that way until the user moves the scroll_handle. Once unstuck (false)
/// it remains false until the scroll touches the end position, which reenables stickiness.
scroll_stuck_to_end: [bool; 2],
}
impl Default for State {
fn default() -> Self {
Self {
offset: Vec2::ZERO,
show_scroll: [false; 2],
content_is_too_large: [false; 2],
vel: Vec2::ZERO,
scroll_start_offset_from_top_left: [None; 2],
scroll_stuck_to_end: [true; 2],
}
}
}
impl State {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| d.get_persisted(id))
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.data_mut(|d| d.insert_persisted(id, self));
}
}
pub struct ScrollAreaOutput<R> {
/// What the user closure returned.
pub inner: R,
/// [`Id`] of the [`ScrollArea`].
pub id: Id,
/// The current state of the scroll area.
pub state: State,
/// The size of the content. If this is larger than [`Self::inner_rect`],
/// then there was need for scrolling.
pub content_size: Vec2,
/// Where on the screen the content is (excludes scroll bars).
pub inner_rect: Rect,
}
/// Indicate whether the horizontal and vertical scroll bars must be always visible, hidden or visible when needed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScrollBarVisibility {
AlwaysVisible,
VisibleWhenNeeded,
AlwaysHidden,
}
/// Add vertical and/or horizontal scrolling to a contained [`Ui`].
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// egui::ScrollArea::vertical().show(ui, |ui| {
/// // Add a lot of widgets here.
/// });
/// # });
/// ```
///
/// You can scroll to an element using [`Response::scroll_to_me`], [`Ui::scroll_to_cursor`] and [`Ui::scroll_to_rect`].
#[derive(Clone, Debug)]
#[must_use = "You should call .show()"]
pub struct ScrollArea {
/// Do we have horizontal/vertical scrolling?
has_bar: [bool; 2],
auto_shrink: [bool; 2],
max_size: Vec2,
min_scrolled_size: Vec2,
scroll_bar_visibility: ScrollBarVisibility,
id_source: Option<Id>,
offset_x: Option<f32>,
offset_y: Option<f32>,
/// If false, we ignore scroll events.
scrolling_enabled: bool,
drag_to_scroll: bool,
/// If true for vertical or horizontal the scroll wheel will stick to the
/// end position until user manually changes position. It will become true
/// again once scroll handle makes contact with end.
stick_to_end: [bool; 2],
}
impl ScrollArea {
/// Create a horizontal scroll area.
pub fn horizontal() -> Self {
Self::new([true, false])
}
/// Create a vertical scroll area.
pub fn vertical() -> Self {
Self::new([false, true])
}
/// Create a bi-directional (horizontal and vertical) scroll area.
pub fn both() -> Self {
Self::new([true, true])
}
/// Create a scroll area where both direction of scrolling is disabled.
/// It's unclear why you would want to do this.
pub fn neither() -> Self {
Self::new([false, false])
}
/// Create a scroll area where you decide which axis has scrolling enabled.
/// For instance, `ScrollArea::new([true, false])` enables horizontal scrolling.
pub fn new(has_bar: [bool; 2]) -> Self {
Self {
has_bar,
auto_shrink: [true; 2],
max_size: Vec2::INFINITY,
min_scrolled_size: Vec2::splat(64.0),
scroll_bar_visibility: ScrollBarVisibility::AlwaysHidden,
id_source: None,
offset_x: None,
offset_y: None,
scrolling_enabled: true,
drag_to_scroll: true,
stick_to_end: [false; 2],
}
}
/// The maximum width of the outer frame of the scroll area.
///
/// Use `f32::INFINITY` if you want the scroll area to expand to fit the surrounding [`Ui`] (default).
///
/// See also [`Self::auto_shrink`].
pub fn max_width(mut self, max_width: f32) -> Self {
self.max_size.x = max_width;
self
}
/// The maximum height of the outer frame of the scroll area.
///
/// Use `f32::INFINITY` if you want the scroll area to expand to fit the surrounding [`Ui`] (default).
///
/// See also [`Self::auto_shrink`].
pub fn max_height(mut self, max_height: f32) -> Self {
self.max_size.y = max_height;
self
}
/// The minimum width of a horizontal scroll area which requires scroll bars.
///
/// The [`ScrollArea`] will only become smaller than this if the content is smaller than this
/// (and so we don't require scroll bars).
///
/// Default: `64.0`.
pub fn min_scrolled_width(mut self, min_scrolled_width: f32) -> Self {
self.min_scrolled_size.x = min_scrolled_width;
self
}
/// The minimum height of a vertical scroll area which requires scroll bars.
///
/// The [`ScrollArea`] will only become smaller than this if the content is smaller than this
/// (and so we don't require scroll bars).
///
/// Default: `64.0`.
pub fn min_scrolled_height(mut self, min_scrolled_height: f32) -> Self {
self.min_scrolled_size.y = min_scrolled_height;
self
}
/// Set the visibility of both horizontal and vertical scroll bars.
///
/// With `ScrollBarVisibility::VisibleWhenNeeded` (default), the scroll bar will be visible only when needed.
pub fn scroll_bar_visibility(mut self, scroll_bar_visibility: ScrollBarVisibility) -> Self {
self.scroll_bar_visibility = scroll_bar_visibility;
self
}
/// A source for the unique [`Id`], e.g. `.id_source("second_scroll_area")` or `.id_source(loop_index)`.
pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self {
self.id_source = Some(Id::new(id_source));
self
}
/// Set the horizontal and vertical scroll offset position.
///
/// Positive offset means scrolling down/right.
///
/// See also: [`Self::vertical_scroll_offset`], [`Self::horizontal_scroll_offset`],
/// [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and
/// [`Response::scroll_to_me`](crate::Response::scroll_to_me)
pub fn scroll_offset(mut self, offset: Vec2) -> Self {
self.offset_x = Some(offset.x);
self.offset_y = Some(offset.y);
self
}
/// Set the vertical scroll offset position.
///
/// Positive offset means scrolling down.
///
/// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and
/// [`Response::scroll_to_me`](crate::Response::scroll_to_me)
pub fn vertical_scroll_offset(mut self, offset: f32) -> Self {
self.offset_y = Some(offset);
self
}
/// Set the horizontal scroll offset position.
///
/// Positive offset means scrolling right.
///
/// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and
/// [`Response::scroll_to_me`](crate::Response::scroll_to_me)
pub fn horizontal_scroll_offset(mut self, offset: f32) -> Self {
self.offset_x = Some(offset);
self
}
/// Turn on/off scrolling on the horizontal axis.
pub fn hscroll(mut self, hscroll: bool) -> Self {
self.has_bar[0] = hscroll;
self
}
/// Turn on/off scrolling on the vertical axis.
pub fn vscroll(mut self, vscroll: bool) -> Self {
self.has_bar[1] = vscroll;
self
}
/// Turn on/off scrolling on the horizontal/vertical axes.
pub fn scroll2(mut self, has_bar: [bool; 2]) -> Self {
self.has_bar = has_bar;
self
}
/// Control the scrolling behavior.
///
/// * If `true` (default), the scroll area will respond to user scrolling.
/// * If `false`, the scroll area will not respond to user scrolling.
///
/// This can be used, for example, to optionally freeze scrolling while the user
/// is typing text in a [`TextEdit`] widget contained within the scroll area.
///
/// This controls both scrolling directions.
pub fn enable_scrolling(mut self, enable: bool) -> Self {
self.scrolling_enabled = enable;
self
}
/// Can the user drag the scroll area to scroll?
///
/// This is useful for touch screens.
///
/// If `true`, the [`ScrollArea`] will sense drags.
///
/// Default: `true`.
pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self {
self.drag_to_scroll = drag_to_scroll;
self
}
/// For each axis, should the containing area shrink if the content is small?
///
/// * If `true`, egui will add blank space outside the scroll area.
/// * If `false`, egui will add blank space inside the scroll area.
///
/// Default: `[true; 2]`.
pub fn auto_shrink(mut self, auto_shrink: [bool; 2]) -> Self {
self.auto_shrink = auto_shrink;
self
}
pub(crate) fn has_any_bar(&self) -> bool {
self.has_bar[0] || self.has_bar[1]
}
/// The scroll handle will stick to the rightmost position even while the content size
/// changes dynamically. This can be useful to simulate text scrollers coming in from right
/// hand side. The scroll handle remains stuck until user manually changes position. Once "unstuck"
/// it will remain focused on whatever content viewport the user left it on. If the scroll
/// handle is dragged all the way to the right it will again become stuck and remain there
/// until manually pulled from the end position.
pub fn stick_to_right(mut self, stick: bool) -> Self {
self.stick_to_end[0] = stick;
self
}
/// The scroll handle will stick to the bottom position even while the content size
/// changes dynamically. This can be useful to simulate terminal UIs or log/info scrollers.
/// The scroll handle remains stuck until user manually changes position. Once "unstuck"
/// it will remain focused on whatever content viewport the user left it on. If the scroll
/// handle is dragged to the bottom it will again become stuck and remain there until manually
/// pulled from the end position.
pub fn stick_to_bottom(mut self, stick: bool) -> Self {
self.stick_to_end[1] = stick;
self
}
}
struct Prepared {
id: Id,
state: State,
has_bar: [bool; 2],
auto_shrink: [bool; 2],
/// How much horizontal and vertical space are used up by the
/// width of the vertical bar, and the height of the horizontal bar?
current_bar_use: Vec2,
scroll_bar_visibility: ScrollBarVisibility,
/// Where on the screen the content is (excludes scroll bars).
inner_rect: Rect,
content_ui: Ui,
/// Relative coordinates: the offset and size of the view of the inner UI.
/// `viewport.min == ZERO` means we scrolled to the top.
viewport: Rect,
scrolling_enabled: bool,
stick_to_end: [bool; 2],
}
impl ScrollArea {
fn begin(self, ui: &mut Ui) -> Prepared {
let Self {
has_bar,
auto_shrink,
max_size,
min_scrolled_size,
scroll_bar_visibility,
id_source,
offset_x,
offset_y,
scrolling_enabled,
drag_to_scroll,
stick_to_end,
} = self;
let ctx = ui.ctx().clone();
let id_source = id_source.unwrap_or_else(|| Id::new("scroll_area"));
let id = ui.make_persistent_id(id_source);
ui.ctx().check_for_id_clash(
id,
Rect::from_min_size(ui.available_rect_before_wrap().min, Vec2::ZERO),
"ScrollArea",
);
let mut state = State::load(&ctx, id).unwrap_or_default();
state.offset.x = offset_x.unwrap_or(state.offset.x);
state.offset.y = offset_y.unwrap_or(state.offset.y);
let max_scroll_bar_width = max_scroll_bar_width_with_margin(ui);
let current_hscroll_bar_height = if !has_bar[0] {
0.0
} else if scroll_bar_visibility == ScrollBarVisibility::AlwaysVisible {
max_scroll_bar_width
} else {
max_scroll_bar_width * ui.ctx().animate_bool(id.with("h"), state.show_scroll[0])
};
let current_vscroll_bar_width = if !has_bar[1] {
0.0
} else if scroll_bar_visibility == ScrollBarVisibility::AlwaysVisible {
max_scroll_bar_width
} else {
max_scroll_bar_width * ui.ctx().animate_bool(id.with("v"), state.show_scroll[1])
};
let current_bar_use = vec2(current_vscroll_bar_width, current_hscroll_bar_height);
let available_outer = ui.available_rect_before_wrap();
let outer_size = available_outer.size().at_most(max_size);
let inner_size = {
let mut inner_size = outer_size - current_bar_use;
// Don't go so far that we shrink to zero.
// In particular, if we put a [`ScrollArea`] inside of a [`ScrollArea`], the inner
// one shouldn't collapse into nothingness.
// See https://github.com/emilk/egui/issues/1097
for d in 0..2 {
if has_bar[d] {
inner_size[d] = inner_size[d].max(min_scrolled_size[d]);
}
}
inner_size
};
let inner_rect = Rect::from_min_size(available_outer.min, inner_size);
let mut content_max_size = inner_size;
if true {
// Tell the inner Ui to *try* to fit the content without needing to scroll,
// i.e. better to wrap text and shrink images than showing a horizontal scrollbar!
} else {
// Tell the inner Ui to use as much space as possible, we can scroll to see it!
for d in 0..2 {
if has_bar[d] {
content_max_size[d] = f32::INFINITY;
}
}
}
let content_max_rect = Rect::from_min_size(inner_rect.min - state.offset, content_max_size);
let mut content_ui = ui.child_ui(content_max_rect, *ui.layout());
{
// Clip the content, but only when we really need to:
let clip_rect_margin = ui.visuals().clip_rect_margin;
let scroll_bar_inner_margin = ui.spacing().scroll_bar_inner_margin;
let mut content_clip_rect = ui.clip_rect();
for d in 0..2 {
if has_bar[d] {
if state.content_is_too_large[d] {
content_clip_rect.min[d] = inner_rect.min[d] - clip_rect_margin;
content_clip_rect.max[d] = inner_rect.max[d] + clip_rect_margin;
}
if state.show_scroll[d] {
// Make sure content doesn't cover scroll bars
let tiny_gap = 1.0;
content_clip_rect.max[1 - d] =
inner_rect.max[1 - d] + scroll_bar_inner_margin - tiny_gap;
}
} else {
// Nice handling of forced resizing beyond the possible:
content_clip_rect.max[d] = ui.clip_rect().max[d] - current_bar_use[d];
}
}
// Make sure we din't accidentally expand the clip rect
content_clip_rect = content_clip_rect.intersect(ui.clip_rect());
content_ui.set_clip_rect(content_clip_rect);
}
let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size);
if (scrolling_enabled && drag_to_scroll)
&& (state.content_is_too_large[0] || state.content_is_too_large[1])
{
// Drag contents to scroll (for touch screens mostly).
// We must do this BEFORE adding content to the `ScrollArea`,
// or we will steal input from the widgets we contain.
let content_response = ui.interact(inner_rect, id.with("area"), Sense::drag());
if content_response.dragged() {
for d in 0..2 {
if has_bar[d] {
ui.input(|input| {
state.offset[d] -= input.pointer.delta()[d];
state.vel[d] = input.pointer.velocity()[d];
});
state.scroll_stuck_to_end[d] = false;
} else {
state.vel[d] = 0.0;
}
}
} else {
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
let dt = ui.input(|i| i.unstable_dt);
let friction = friction_coeff * dt;
if friction > state.vel.length() || state.vel.length() < stop_speed {
state.vel = Vec2::ZERO;
} else {
state.vel -= friction * state.vel.normalized();
// Offset has an inverted coordinate system compared to
// the velocity, so we subtract it instead of adding it
state.offset -= state.vel * dt;
ui.ctx().request_repaint();
}
}
}
Prepared {
id,
state,
has_bar,
auto_shrink,
current_bar_use,
scroll_bar_visibility,
inner_rect,
content_ui,
viewport,
scrolling_enabled,
stick_to_end,
}
}
/// Show the [`ScrollArea`], and add the contents to the viewport.
///
/// If the inner area can be very long, consider using [`Self::show_rows`] instead.
pub fn show<R>(
self,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> ScrollAreaOutput<R> {
self.show_viewport_dyn(ui, Box::new(|ui, _viewport| add_contents(ui)))
}
/// Efficiently show only the visible part of a large number of rows.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let text_style = egui::TextStyle::Body;
/// let row_height = ui.text_style_height(&text_style);
/// // let row_height = ui.spacing().interact_size.y; // if you are adding buttons instead of labels.
/// let total_rows = 10_000;
/// egui::ScrollArea::vertical().show_rows(ui, row_height, total_rows, |ui, row_range| {
/// for row in row_range {
/// let text = format!("Row {}/{}", row + 1, total_rows);
/// ui.label(text);
/// }
/// });
/// # });
/// ```
pub fn show_rows<R>(
self,
ui: &mut Ui,
row_height_sans_spacing: f32,
total_rows: usize,
add_contents: impl FnOnce(&mut Ui, std::ops::Range<usize>) -> R,
) -> ScrollAreaOutput<R> {
let spacing = ui.spacing().item_spacing;
let row_height_with_spacing = row_height_sans_spacing + spacing.y;
self.show_viewport(ui, |ui, viewport| {
ui.set_height((row_height_with_spacing * total_rows as f32 - spacing.y).at_least(0.0));
let mut min_row = (viewport.min.y / row_height_with_spacing).floor() as usize;
let mut max_row = (viewport.max.y / row_height_with_spacing).ceil() as usize + 1;
if max_row > total_rows {
let diff = max_row.saturating_sub(min_row);
max_row = total_rows;
min_row = total_rows.saturating_sub(diff);
}
let y_min = ui.max_rect().top() + min_row as f32 * row_height_with_spacing;
let y_max = ui.max_rect().top() + max_row as f32 * row_height_with_spacing;
let rect = Rect::from_x_y_ranges(ui.max_rect().x_range(), y_min..=y_max);
ui.allocate_ui_at_rect(rect, |viewport_ui| {
viewport_ui.skip_ahead_auto_ids(min_row); // Make sure we get consistent IDs.
add_contents(viewport_ui, min_row..max_row)
})
.inner
})
}
/// This can be used to only paint the visible part of the contents.
///
/// `add_contents` is given the viewport rectangle, which is the relative view of the content.
/// So if the passed rect has min = zero, then show the top left content (the user has not scrolled).
pub fn show_viewport<R>(
self,
ui: &mut Ui,
add_contents: impl FnOnce(&mut Ui, Rect) -> R,
) -> ScrollAreaOutput<R> {
self.show_viewport_dyn(ui, Box::new(add_contents))
}
fn show_viewport_dyn<'c, R>(
self,
ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui, Rect) -> R + 'c>,
) -> ScrollAreaOutput<R> {
let mut prepared = self.begin(ui);
let id = prepared.id;
let inner_rect = prepared.inner_rect;
let inner = add_contents(&mut prepared.content_ui, prepared.viewport);
let (content_size, state) = prepared.end(ui);
ScrollAreaOutput {
inner,
id,
state,
content_size,
inner_rect,
}
}
}
impl Prepared {
/// Returns content size and state
fn end(self, ui: &mut Ui) -> (Vec2, State) {
let Prepared {
id,
mut state,
inner_rect,
has_bar,
auto_shrink,
mut current_bar_use,
scroll_bar_visibility,
content_ui,
viewport: _,
scrolling_enabled,
stick_to_end,
} = self;
let content_size = content_ui.min_size();
for d in 0..2 {
if has_bar[d] {
// We take the scroll target so only this ScrollArea will use it:
let scroll_target = content_ui
.ctx()
.frame_state_mut(|state| state.scroll_target[d].take());
if let Some((scroll, align)) = scroll_target {
let min = content_ui.min_rect().min[d];
let clip_rect = content_ui.clip_rect();
let visible_range = min..=min + clip_rect.size()[d];
let start = *scroll.start();
let end = *scroll.end();
let clip_start = clip_rect.min[d];
let clip_end = clip_rect.max[d];
let mut spacing = ui.spacing().item_spacing[d];
let delta = if let Some(align) = align {
let center_factor = align.to_factor();
let offset =
lerp(scroll, center_factor) - lerp(visible_range, center_factor);
// Depending on the alignment we need to add or subtract the spacing
spacing *= remap(center_factor, 0.0..=1.0, -1.0..=1.0);
offset + spacing - state.offset[d]
} else if start < clip_start && end < clip_end {
-(clip_start - start + spacing).min(clip_end - end - spacing)
} else if end > clip_end && start > clip_start {
(end - clip_end + spacing).min(start - clip_start - spacing)
} else {
// Ui is already in view, no need to adjust scroll.
0.0
};
if delta != 0.0 {
state.offset[d] += delta;
ui.ctx().request_repaint();
}
}
}
}
let inner_rect = {
// At this point this is the available size for the inner rect.
let mut inner_size = inner_rect.size();
for d in 0..2 {
inner_size[d] = match (has_bar[d], auto_shrink[d]) {
(true, true) => inner_size[d].min(content_size[d]), // shrink scroll area if content is small
(true, false) => inner_size[d], // let scroll area be larger than content; fill with blank space
(false, true) => content_size[d], // Follow the content (expand/contract to fit it).
(false, false) => inner_size[d].max(content_size[d]), // Expand to fit content
};
}
Rect::from_min_size(inner_rect.min, inner_size)
};
let outer_rect = Rect::from_min_size(inner_rect.min, inner_rect.size() + current_bar_use);
let content_is_too_large = [
content_size.x > inner_rect.width(),
content_size.y > inner_rect.height(),
];
let max_offset = content_size - inner_rect.size();
if scrolling_enabled && ui.rect_contains_pointer(outer_rect) {
for d in 0..2 {
if has_bar[d] {
let scroll_delta = ui.ctx().frame_state(|fs| fs.scroll_delta);
let scrolling_up = state.offset[d] > 0.0 && scroll_delta[d] > 0.0;
let scrolling_down = state.offset[d] < max_offset[d] && scroll_delta[d] < 0.0;
if scrolling_up || scrolling_down {
state.offset[d] -= scroll_delta[d];
// Clear scroll delta so no parent scroll will use it.
ui.ctx().frame_state_mut(|fs| fs.scroll_delta[d] = 0.0);
state.scroll_stuck_to_end[d] = false;
}
}
}
}
let show_scroll_this_frame = match scroll_bar_visibility {
ScrollBarVisibility::AlwaysVisible => [true, true],
ScrollBarVisibility::VisibleWhenNeeded => {
[content_is_too_large[0], content_is_too_large[1]]
}
ScrollBarVisibility::AlwaysHidden => [false, false],
};
let max_scroll_bar_width = max_scroll_bar_width_with_margin(ui);
// Avoid frame delay; start showing scroll bar right away:
if show_scroll_this_frame[0] && current_bar_use.y <= 0.0 {
current_bar_use.y = max_scroll_bar_width * ui.ctx().animate_bool(id.with("h"), true);
}
if show_scroll_this_frame[1] && current_bar_use.x <= 0.0 {
current_bar_use.x = max_scroll_bar_width * ui.ctx().animate_bool(id.with("v"), true);
}
for d in 0..2 {
let animation_t = current_bar_use[1 - d] / max_scroll_bar_width;
if animation_t == 0.0 {
continue;
}
// margin on either side of the scroll bar
let inner_margin = animation_t * ui.spacing().scroll_bar_inner_margin;
let outer_margin = animation_t * ui.spacing().scroll_bar_outer_margin;
let mut min_cross = inner_rect.max[1 - d] + inner_margin; // left of vertical scroll (d == 1)
let mut max_cross = outer_rect.max[1 - d] - outer_margin; // right of vertical scroll (d == 1)
let min_main = inner_rect.min[d]; // top of vertical scroll (d == 1)
let max_main = inner_rect.max[d]; // bottom of vertical scroll (d == 1)
if ui.clip_rect().max[1 - d] < max_cross + outer_margin {
// Move the scrollbar so it is visible. This is needed in some cases.
// For instance:
// * When we have a vertical-only scroll area in a top level panel,
// and that panel is not wide enough for the contents.
// * When one ScrollArea is nested inside another, and the outer
// is scrolled so that the scroll-bars of the inner ScrollArea (us)
// is outside the clip rectangle.
// Really this should use the tighter clip_rect that ignores clip_rect_margin, but we don't store that.
// clip_rect_margin is quite a hack. It would be nice to get rid of it.
let width = max_cross - min_cross;
max_cross = ui.clip_rect().max[1 - d] - outer_margin;
min_cross = max_cross - width;
}
let outer_scroll_rect = if d == 0 {
Rect::from_min_max(
pos2(inner_rect.left(), min_cross),
pos2(inner_rect.right(), max_cross),
)
} else {
Rect::from_min_max(
pos2(min_cross, inner_rect.top()),
pos2(max_cross, inner_rect.bottom()),
)
};
// maybe force increase in offset to keep scroll stuck to end position
if stick_to_end[d] && state.scroll_stuck_to_end[d] {
state.offset[d] = content_size[d] - inner_rect.size()[d];
}
let from_content =
|content| remap_clamp(content, 0.0..=content_size[d], min_main..=max_main);
let handle_rect = if d == 0 {
Rect::from_min_max(
pos2(from_content(state.offset.x), min_cross),
pos2(from_content(state.offset.x + inner_rect.width()), max_cross),
)
} else {
Rect::from_min_max(
pos2(min_cross, from_content(state.offset.y)),
pos2(
max_cross,
from_content(state.offset.y + inner_rect.height()),
),
)
};
let interact_id = id.with(d);
let sense = if self.scrolling_enabled {
Sense::click_and_drag()
} else {
Sense::hover()
};
let response = ui.interact(outer_scroll_rect, interact_id, sense);
if let Some(pointer_pos) = response.interact_pointer_pos() {
let scroll_start_offset_from_top_left = state.scroll_start_offset_from_top_left[d]
.get_or_insert_with(|| {
if handle_rect.contains(pointer_pos) {
pointer_pos[d] - handle_rect.min[d]
} else {
let handle_top_pos_at_bottom = max_main - handle_rect.size()[d];
// Calculate the new handle top position, centering the handle on the mouse.
let new_handle_top_pos = (pointer_pos[d] - handle_rect.size()[d] / 2.0)
.clamp(min_main, handle_top_pos_at_bottom);
pointer_pos[d] - new_handle_top_pos
}
});
let new_handle_top = pointer_pos[d] - *scroll_start_offset_from_top_left;
state.offset[d] = remap(new_handle_top, min_main..=max_main, 0.0..=content_size[d]);
// some manual action taken, scroll not stuck
state.scroll_stuck_to_end[d] = false;
} else {
state.scroll_start_offset_from_top_left[d] = None;
}
let unbounded_offset = state.offset[d];
state.offset[d] = state.offset[d].max(0.0);
state.offset[d] = state.offset[d].min(max_offset[d]);
if state.offset[d] != unbounded_offset {
state.vel[d] = 0.0;
}
if ui.is_rect_visible(outer_scroll_rect) {
// Avoid frame-delay by calculating a new handle rect:
let mut handle_rect = if d == 0 {
Rect::from_min_max(
pos2(from_content(state.offset.x), min_cross),
pos2(from_content(state.offset.x + inner_rect.width()), max_cross),
)
} else {
Rect::from_min_max(
pos2(min_cross, from_content(state.offset.y)),
pos2(
max_cross,
from_content(state.offset.y + inner_rect.height()),
),
)
};
let min_handle_size = ui.spacing().scroll_handle_min_length;
if handle_rect.size()[d] < min_handle_size {
handle_rect = Rect::from_center_size(
handle_rect.center(),
if d == 0 {
vec2(min_handle_size, handle_rect.size().y)
} else {
vec2(handle_rect.size().x, min_handle_size)
},
);
}
let visuals = if scrolling_enabled {
ui.style().interact(&response)
} else {
&ui.style().visuals.widgets.inactive
};
ui.painter().add(epaint::Shape::rect_filled(
outer_scroll_rect,
visuals.rounding,
ui.visuals().extreme_bg_color,
));
ui.painter().add(epaint::Shape::rect_filled(
handle_rect,
visuals.rounding,
visuals.bg_fill,
));
}
}
ui.advance_cursor_after_rect(outer_rect);
if show_scroll_this_frame != state.show_scroll {
ui.ctx().request_repaint();
}
let available_offset = content_size - inner_rect.size();
state.offset = state.offset.min(available_offset);
state.offset = state.offset.max(Vec2::ZERO);
// Is scroll handle at end of content, or is there no scrollbar
// yet (not enough content), but sticking is requested? If so, enter sticky mode.
// Only has an effect if stick_to_end is enabled but we save in
// state anyway so that entering sticky mode at an arbitrary time
// has appropriate effect.
state.scroll_stuck_to_end = [
(state.offset[0] == available_offset[0])
|| (self.stick_to_end[0] && available_offset[0] < 0.),
(state.offset[1] == available_offset[1])
|| (self.stick_to_end[1] && available_offset[1] < 0.),
];
state.show_scroll = show_scroll_this_frame;
state.content_is_too_large = content_is_too_large;
state.store(ui.ctx(), id);
(content_size, state)
}
}
/// Width of a vertical scrollbar, or height of a horizontal scroll bar
fn max_scroll_bar_width_with_margin(ui: &Ui) -> f32 {
ui.spacing().scroll_bar_inner_margin
+ ui.spacing().scroll_bar_width
+ ui.spacing().scroll_bar_outer_margin
}

View file

@ -0,0 +1,982 @@
// WARNING: the code in here is horrible. It is a behemoth that needs breaking up into simpler parts.
use crate::collapsing_header::CollapsingState;
use crate::{widget_text::WidgetTextGalley, *};
use epaint::*;
use super::*;
/// Builder for a floating window which can be dragged, closed, collapsed, resized and scrolled (off by default).
///
/// You can customize:
/// * title
/// * default, minimum, maximum and/or fixed size, collapsed/expanded
/// * if the window has a scroll area (off by default)
/// * if the window can be collapsed (minimized) to just the title bar (yes, by default)
/// * if there should be a close button (none by default)
///
/// ```
/// # egui::__run_test_ctx(|ctx| {
/// egui::Window::new("My Window").show(ctx, |ui| {
/// ui.label("Hello World!");
/// });
/// # });
#[must_use = "You should call .show()"]
pub struct Window<'open> {
title: WidgetText,
open: Option<&'open mut bool>,
area: Area,
frame: Option<Frame>,
resize: Resize,
scroll: ScrollArea,
collapsible: bool,
default_open: bool,
with_title_bar: bool,
}
impl<'open> Window<'open> {
/// The window title is used as a unique [`Id`] and must be unique, and should not change.
/// This is true even if you disable the title bar with `.title_bar(false)`.
/// If you need a changing title, you must call `window.id(…)` with a fixed id.
pub fn new(title: impl Into<WidgetText>) -> Self {
let title = title.into().fallback_text_style(TextStyle::Heading);
let area = Area::new(Id::new(title.text()));
Self {
title,
open: None,
area,
frame: None,
resize: Resize::default()
.with_stroke(false)
.min_size([96.0, 32.0])
.default_size([340.0, 420.0]), // Default inner size of a window
scroll: ScrollArea::neither(),
collapsible: true,
default_open: true,
with_title_bar: true,
}
}
/// Assign a unique id to the Window. Required if the title changes, or is shared with another window.
pub fn id(mut self, id: Id) -> Self {
self.area = self.area.id(id);
self
}
/// Call this to add a close-button to the window title bar.
///
/// * If `*open == false`, the window will not be visible.
/// * If `*open == true`, the window will have a close button.
/// * If the close button is pressed, `*open` will be set to `false`.
pub fn open(mut self, open: &'open mut bool) -> Self {
self.open = Some(open);
self
}
/// If `false` the window will be grayed out and non-interactive.
pub fn enabled(mut self, enabled: bool) -> Self {
self.area = self.area.enabled(enabled);
self
}
/// If `false` the window will be non-interactive.
pub fn interactable(mut self, interactable: bool) -> Self {
self.area = self.area.interactable(interactable);
self
}
/// If `false` the window will be immovable.
pub fn movable(mut self, movable: bool) -> Self {
self.area = self.area.movable(movable);
self
}
/// Usage: `Window::new(…).mutate(|w| w.resize = w.resize.auto_expand_width(true))`
// TODO(emilk): I'm not sure this is a good interface for this.
pub fn mutate(mut self, mutate: impl Fn(&mut Self)) -> Self {
mutate(&mut self);
self
}
/// Usage: `Window::new(…).resize(|r| r.auto_expand_width(true))`
// TODO(emilk): I'm not sure this is a good interface for this.
pub fn resize(mut self, mutate: impl Fn(Resize) -> Resize) -> Self {
self.resize = mutate(self.resize);
self
}
/// Change the background color, margins, etc.
pub fn frame(mut self, frame: Frame) -> Self {
self.frame = Some(frame);
self
}
/// Set minimum width of the window.
pub fn min_width(mut self, min_width: f32) -> Self {
self.resize = self.resize.min_width(min_width);
self
}
/// Set minimum height of the window.
pub fn min_height(mut self, min_height: f32) -> Self {
self.resize = self.resize.min_height(min_height);
self
}
/// Set current position of the window.
/// If the window is movable it is up to you to keep track of where it moved to!
pub fn current_pos(mut self, current_pos: impl Into<Pos2>) -> Self {
self.area = self.area.current_pos(current_pos);
self
}
/// Set initial position of the window.
pub fn default_pos(mut self, default_pos: impl Into<Pos2>) -> Self {
self.area = self.area.default_pos(default_pos);
self
}
/// Sets the window position and prevents it from being dragged around.
pub fn fixed_pos(mut self, pos: impl Into<Pos2>) -> Self {
self.area = self.area.fixed_pos(pos);
self
}
/// Constrains this window to the screen bounds.
pub fn constrain(mut self, constrain: bool) -> Self {
self.area = self.area.constrain(constrain);
self
}
/// Where the "root" of the window is.
///
/// For instance, if you set this to [`Align2::RIGHT_TOP`]
/// then [`Self::fixed_pos`] will set the position of the right-top
/// corner of the window.
///
/// Default: [`Align2::LEFT_TOP`].
pub fn pivot(mut self, pivot: Align2) -> Self {
self.area = self.area.pivot(pivot);
self
}
/// Set anchor and distance.
///
/// An anchor of `Align2::RIGHT_TOP` means "put the right-top corner of the window
/// in the right-top corner of the screen".
///
/// The offset is added to the position, so e.g. an offset of `[-5.0, 5.0]`
/// would move the window left and down from the given anchor.
///
/// Anchoring also makes the window immovable.
///
/// It is an error to set both an anchor and a position.
pub fn anchor(mut self, align: Align2, offset: impl Into<Vec2>) -> Self {
self.area = self.area.anchor(align, offset);
self
}
/// Set initial collapsed state of the window
pub fn default_open(mut self, default_open: bool) -> Self {
self.default_open = default_open;
self
}
/// Set initial size of the window.
pub fn default_size(mut self, default_size: impl Into<Vec2>) -> Self {
self.resize = self.resize.default_size(default_size);
self
}
/// Set initial width of the window.
pub fn default_width(mut self, default_width: f32) -> Self {
self.resize = self.resize.default_width(default_width);
self
}
/// Set initial height of the window.
pub fn default_height(mut self, default_height: f32) -> Self {
self.resize = self.resize.default_height(default_height);
self
}
/// Sets the window size and prevents it from being resized by dragging its edges.
pub fn fixed_size(mut self, size: impl Into<Vec2>) -> Self {
self.resize = self.resize.fixed_size(size);
self
}
/// Set initial position and size of the window.
pub fn default_rect(self, rect: Rect) -> Self {
self.default_pos(rect.min).default_size(rect.size())
}
/// Sets the window pos and size and prevents it from being moved and resized by dragging its edges.
pub fn fixed_rect(self, rect: Rect) -> Self {
self.fixed_pos(rect.min).fixed_size(rect.size())
}
/// Can the user resize the window by dragging its edges?
/// Note that even if you set this to `false` the window may still auto-resize.
pub fn resizable(mut self, resizable: bool) -> Self {
self.resize = self.resize.resizable(resizable);
self
}
/// Can the window be collapsed by clicking on its title?
pub fn collapsible(mut self, collapsible: bool) -> Self {
self.collapsible = collapsible;
self
}
/// Show title bar on top of the window?
/// If `false`, the window will not be collapsible nor have a close-button.
pub fn title_bar(mut self, title_bar: bool) -> Self {
self.with_title_bar = title_bar;
self
}
/// Not resizable, just takes the size of its contents.
/// Also disabled scrolling.
/// Text will not wrap, but will instead make your window width expand.
pub fn auto_sized(mut self) -> Self {
self.resize = self.resize.auto_sized();
self.scroll = ScrollArea::neither();
self
}
/// Enable/disable horizontal/vertical scrolling. `false` by default.
pub fn scroll2(mut self, scroll: [bool; 2]) -> Self {
self.scroll = self.scroll.scroll2(scroll);
self
}
/// Enable/disable horizontal scrolling. `false` by default.
pub fn hscroll(mut self, hscroll: bool) -> Self {
self.scroll = self.scroll.hscroll(hscroll);
self
}
/// Enable/disable vertical scrolling. `false` by default.
pub fn vscroll(mut self, vscroll: bool) -> Self {
self.scroll = self.scroll.vscroll(vscroll);
self
}
/// Constrain the area up to which the window can be dragged.
pub fn drag_bounds(mut self, bounds: Rect) -> Self {
self.area = self.area.drag_bounds(bounds);
self
}
}
impl<'open> Window<'open> {
/// Returns `None` if the window is not open (if [`Window::open`] was called with `&mut false`).
/// Returns `Some(InnerResponse { inner: None })` if the window is collapsed.
#[inline]
pub fn show<R>(
self,
ctx: &Context,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<Option<R>>> {
self.show_dyn(ctx, Box::new(add_contents))
}
fn show_dyn<'c, R>(
self,
ctx: &Context,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> Option<InnerResponse<Option<R>>> {
let Window {
title,
open,
area,
frame,
resize,
scroll,
collapsible,
default_open,
with_title_bar,
} = self;
let frame = frame.unwrap_or_else(|| Frame::window(&ctx.style()));
let is_explicitly_closed = matches!(open, Some(false));
let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible());
area.show_open_close_animation(ctx, &frame, is_open);
if !is_open {
return None;
}
let area_id = area.id;
let area_layer_id = area.layer();
let resize_id = area_id.with("resize");
let mut collapsing =
CollapsingState::load_with_default_open(ctx, area_id.with("collapsing"), default_open);
let is_collapsed = with_title_bar && !collapsing.is_open();
let possible = PossibleInteractions::new(&area, &resize, is_collapsed);
let area = area.movable(false); // We move it manually, or the area will move the window when we want to resize it
let resize = resize.resizable(false); // We move it manually
let mut resize = resize.id(resize_id);
let mut area = area.begin(ctx);
let title_content_spacing = 2.0 * ctx.style().spacing.item_spacing.y;
// First interact (move etc) to avoid frame delay:
let last_frame_outer_rect = area.state().rect();
let interaction = if possible.movable || possible.resizable() {
window_interaction(
ctx,
possible,
area_layer_id,
area_id.with("frame_resize"),
last_frame_outer_rect,
)
.and_then(|window_interaction| {
// Calculate roughly how much larger the window size is compared to the inner rect
let title_bar_height = if with_title_bar {
let style = ctx.style();
ctx.fonts(|f| title.font_height(f, &style)) + title_content_spacing
} else {
0.0
};
let margins = frame.outer_margin.sum()
+ frame.inner_margin.sum()
+ vec2(0.0, title_bar_height);
interact(
window_interaction,
ctx,
margins,
area_layer_id,
&mut area,
resize_id,
)
})
} else {
None
};
let hover_interaction = resize_hover(ctx, possible, area_layer_id, last_frame_outer_rect);
let mut area_content_ui = area.content_ui(ctx);
let content_inner = {
// BEGIN FRAME --------------------------------
let frame_stroke = frame.stroke;
let mut frame = frame.begin(&mut area_content_ui);
let show_close_button = open.is_some();
let title_bar = if with_title_bar {
let title_bar = show_title_bar(
&mut frame.content_ui,
title,
show_close_button,
&mut collapsing,
collapsible,
);
resize.min_size.x = resize.min_size.x.at_least(title_bar.rect.width()); // Prevent making window smaller than title bar width
Some(title_bar)
} else {
None
};
let (content_inner, content_response) = collapsing
.show_body_unindented(&mut frame.content_ui, |ui| {
resize.show(ui, |ui| {
if title_bar.is_some() {
ui.add_space(title_content_spacing);
}
if scroll.has_any_bar() {
scroll.show(ui, add_contents).inner
} else {
add_contents(ui)
}
})
})
.map_or((None, None), |ir| (Some(ir.inner), Some(ir.response)));
let outer_rect = frame.end(&mut area_content_ui).rect;
paint_resize_corner(&mut area_content_ui, &possible, outer_rect, frame_stroke);
// END FRAME --------------------------------
if let Some(title_bar) = title_bar {
title_bar.ui(
&mut area_content_ui,
outer_rect,
&content_response,
open,
&mut collapsing,
collapsible,
);
}
collapsing.store(ctx);
if let Some(interaction) = interaction {
paint_frame_interaction(
&mut area_content_ui,
outer_rect,
interaction,
ctx.style().visuals.widgets.active,
);
} else if let Some(hover_interaction) = hover_interaction {
if ctx.input(|i| i.pointer.has_pointer()) {
paint_frame_interaction(
&mut area_content_ui,
outer_rect,
hover_interaction,
ctx.style().visuals.widgets.hovered,
);
}
}
content_inner
};
{
let pos = ctx
.constrain_window_rect_to_area(area.state().rect(), area.drag_bounds())
.left_top();
area.state_mut().set_left_top_pos(pos);
}
let full_response = area.end(ctx, area_content_ui);
let inner_response = InnerResponse {
inner: content_inner,
response: full_response,
};
Some(inner_response)
}
}
fn paint_resize_corner(
ui: &mut Ui,
possible: &PossibleInteractions,
outer_rect: Rect,
stroke: Stroke,
) {
let corner = if possible.resize_right && possible.resize_bottom {
Align2::RIGHT_BOTTOM
} else if possible.resize_left && possible.resize_bottom {
Align2::LEFT_BOTTOM
} else if possible.resize_left && possible.resize_top {
Align2::LEFT_TOP
} else if possible.resize_right && possible.resize_top {
Align2::RIGHT_TOP
} else {
return;
};
let corner_size = Vec2::splat(ui.visuals().resize_corner_size);
let corner_rect = corner.align_size_within_rect(corner_size, outer_rect);
let corner_rect = corner_rect.translate(-2.0 * corner.to_sign()); // move away from corner
crate::resize::paint_resize_corner_with_style(ui, &corner_rect, stroke, corner);
}
// ----------------------------------------------------------------------------
#[derive(Clone, Copy, Debug)]
struct PossibleInteractions {
movable: bool,
// Which sides can we drag to resize?
resize_left: bool,
resize_right: bool,
resize_top: bool,
resize_bottom: bool,
}
impl PossibleInteractions {
fn new(area: &Area, resize: &Resize, is_collapsed: bool) -> Self {
let movable = area.is_enabled() && area.is_movable();
let resizable = area.is_enabled() && resize.is_resizable() && !is_collapsed;
let pivot = area.get_pivot();
Self {
movable,
resize_left: resizable && (movable || pivot.x() != Align::LEFT),
resize_right: resizable && (movable || pivot.x() != Align::RIGHT),
resize_top: resizable && (movable || pivot.y() != Align::TOP),
resize_bottom: resizable && (movable || pivot.y() != Align::BOTTOM),
}
}
pub fn resizable(&self) -> bool {
self.resize_left || self.resize_right || self.resize_top || self.resize_bottom
}
}
/// Either a move or resize
#[derive(Clone, Copy, Debug)]
pub(crate) struct WindowInteraction {
pub(crate) area_layer_id: LayerId,
pub(crate) start_rect: Rect,
pub(crate) left: bool,
pub(crate) right: bool,
pub(crate) top: bool,
pub(crate) bottom: bool,
}
impl WindowInteraction {
pub fn set_cursor(&self, ctx: &Context) {
if (self.left && self.top) || (self.right && self.bottom) {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
} else if (self.right && self.top) || (self.left && self.bottom) {
ctx.set_cursor_icon(CursorIcon::ResizeNeSw);
} else if self.left || self.right {
ctx.set_cursor_icon(CursorIcon::ResizeHorizontal);
} else if self.bottom || self.top {
ctx.set_cursor_icon(CursorIcon::ResizeVertical);
}
}
pub fn is_resize(&self) -> bool {
self.left || self.right || self.top || self.bottom
}
}
fn interact(
window_interaction: WindowInteraction,
ctx: &Context,
margins: Vec2,
area_layer_id: LayerId,
area: &mut area::Prepared,
resize_id: Id,
) -> Option<WindowInteraction> {
let new_rect = move_and_resize_window(ctx, &window_interaction)?;
let new_rect = ctx.round_rect_to_pixels(new_rect);
let new_rect = ctx.constrain_window_rect_to_area(new_rect, area.drag_bounds());
// TODO(emilk): add this to a Window state instead as a command "move here next frame"
area.state_mut().set_left_top_pos(new_rect.left_top());
if window_interaction.is_resize() {
if let Some(mut state) = resize::State::load(ctx, resize_id) {
state.requested_size = Some(new_rect.size() - margins);
state.store(ctx, resize_id);
}
}
ctx.memory_mut(|mem| mem.areas.move_to_top(area_layer_id));
Some(window_interaction)
}
fn move_and_resize_window(ctx: &Context, window_interaction: &WindowInteraction) -> Option<Rect> {
window_interaction.set_cursor(ctx);
// Only move/resize windows with primary mouse button:
if !ctx.input(|i| i.pointer.primary_down()) {
return None;
}
let pointer_pos = ctx.input(|i| i.pointer.interact_pos())?;
let mut rect = window_interaction.start_rect; // prevent drift
if window_interaction.is_resize() {
if window_interaction.left {
rect.min.x = ctx.round_to_pixel(pointer_pos.x);
} else if window_interaction.right {
rect.max.x = ctx.round_to_pixel(pointer_pos.x);
}
if window_interaction.top {
rect.min.y = ctx.round_to_pixel(pointer_pos.y);
} else if window_interaction.bottom {
rect.max.y = ctx.round_to_pixel(pointer_pos.y);
}
} else {
// Movement.
// We do window interaction first (to avoid frame delay),
// but we want anything interactive in the window (e.g. slider) to steal
// the drag from us. It is therefor important not to move the window the first frame,
// but instead let other widgets to the steal. HACK.
if !ctx.input(|i| i.pointer.any_pressed()) {
let press_origin = ctx.input(|i| i.pointer.press_origin())?;
let delta = pointer_pos - press_origin;
rect = rect.translate(delta);
}
}
Some(rect)
}
/// Returns `Some` if there is a move or resize
fn window_interaction(
ctx: &Context,
possible: PossibleInteractions,
area_layer_id: LayerId,
id: Id,
rect: Rect,
) -> Option<WindowInteraction> {
{
let drag_id = ctx.memory(|mem| mem.interaction.drag_id);
if drag_id.is_some() && drag_id != Some(id) {
return None;
}
}
let mut window_interaction = ctx.memory(|mem| mem.window_interaction);
if window_interaction.is_none() {
if let Some(hover_window_interaction) = resize_hover(ctx, possible, area_layer_id, rect) {
hover_window_interaction.set_cursor(ctx);
if ctx.input(|i| i.pointer.any_pressed() && i.pointer.primary_down()) {
ctx.memory_mut(|mem| {
mem.interaction.drag_id = Some(id);
mem.interaction.drag_is_window = true;
window_interaction = Some(hover_window_interaction);
mem.window_interaction = window_interaction;
});
}
}
}
if let Some(window_interaction) = window_interaction {
let is_active = ctx.memory_mut(|mem| mem.interaction.drag_id == Some(id));
if is_active && window_interaction.area_layer_id == area_layer_id {
return Some(window_interaction);
}
}
None
}
fn resize_hover(
ctx: &Context,
possible: PossibleInteractions,
area_layer_id: LayerId,
rect: Rect,
) -> Option<WindowInteraction> {
let pointer = ctx.input(|i| i.pointer.interact_pos())?;
if ctx.input(|i| i.pointer.any_down() && !i.pointer.any_pressed()) {
return None; // already dragging (something)
}
if let Some(top_layer_id) = ctx.layer_id_at(pointer) {
if top_layer_id != area_layer_id && top_layer_id.order != Order::Background {
return None; // Another window is on top here
}
}
if ctx.memory(|mem| mem.interaction.drag_interest) {
// Another widget will become active if we drag here
return None;
}
let side_grab_radius = ctx.style().interaction.resize_grab_radius_side;
let corner_grab_radius = ctx.style().interaction.resize_grab_radius_corner;
if !rect.expand(side_grab_radius).contains(pointer) {
return None;
}
let mut left = possible.resize_left && (rect.left() - pointer.x).abs() <= side_grab_radius;
let mut right = possible.resize_right && (rect.right() - pointer.x).abs() <= side_grab_radius;
let mut top = possible.resize_top && (rect.top() - pointer.y).abs() <= side_grab_radius;
let mut bottom =
possible.resize_bottom && (rect.bottom() - pointer.y).abs() <= side_grab_radius;
if possible.resize_right
&& possible.resize_bottom
&& rect.right_bottom().distance(pointer) < corner_grab_radius
{
right = true;
bottom = true;
}
if possible.resize_right
&& possible.resize_top
&& rect.right_top().distance(pointer) < corner_grab_radius
{
right = true;
top = true;
}
if possible.resize_left
&& possible.resize_top
&& rect.left_top().distance(pointer) < corner_grab_radius
{
left = true;
top = true;
}
if possible.resize_left
&& possible.resize_bottom
&& rect.left_bottom().distance(pointer) < corner_grab_radius
{
left = true;
bottom = true;
}
let any_resize = left || right || top || bottom;
if !any_resize && !possible.movable {
return None;
}
if any_resize || possible.movable {
Some(WindowInteraction {
area_layer_id,
start_rect: rect,
left,
right,
top,
bottom,
})
} else {
None
}
}
/// Fill in parts of the window frame when we resize by dragging that part
fn paint_frame_interaction(
ui: &mut Ui,
rect: Rect,
interaction: WindowInteraction,
visuals: style::WidgetVisuals,
) {
use epaint::tessellator::path::add_circle_quadrant;
let rounding = ui.visuals().window_rounding;
let Rect { min, max } = rect;
let mut points = Vec::new();
if interaction.right && !interaction.bottom && !interaction.top {
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
}
if interaction.right && interaction.bottom {
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
add_circle_quadrant(
&mut points,
pos2(max.x - rounding.se, max.y - rounding.se),
rounding.se,
0.0,
);
}
if interaction.bottom {
points.push(pos2(max.x - rounding.se, max.y));
points.push(pos2(min.x + rounding.sw, max.y));
}
if interaction.left && interaction.bottom {
add_circle_quadrant(
&mut points,
pos2(min.x + rounding.sw, max.y - rounding.sw),
rounding.sw,
1.0,
);
}
if interaction.left {
points.push(pos2(min.x, max.y - rounding.sw));
points.push(pos2(min.x, min.y + rounding.nw));
}
if interaction.left && interaction.top {
add_circle_quadrant(
&mut points,
pos2(min.x + rounding.nw, min.y + rounding.nw),
rounding.nw,
2.0,
);
}
if interaction.top {
points.push(pos2(min.x + rounding.nw, min.y));
points.push(pos2(max.x - rounding.ne, min.y));
}
if interaction.right && interaction.top {
add_circle_quadrant(
&mut points,
pos2(max.x - rounding.ne, min.y + rounding.ne),
rounding.ne,
3.0,
);
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
}
ui.painter().add(Shape::line(points, visuals.bg_stroke));
}
// ----------------------------------------------------------------------------
struct TitleBar {
/// A title Id used for dragging windows
id: Id,
/// Prepared text in the title
title_galley: WidgetTextGalley,
/// Size of the title bar in a collapsed state (if window is collapsible),
/// which includes all necessary space for showing the expand button, the
/// title and the close button.
min_rect: Rect,
/// Size of the title bar in an expanded state. This size become known only
/// after expanding window and painting its content
rect: Rect,
}
fn show_title_bar(
ui: &mut Ui,
title: WidgetText,
show_close_button: bool,
collapsing: &mut CollapsingState,
collapsible: bool,
) -> TitleBar {
let inner_response = ui.horizontal(|ui| {
let height = ui
.fonts(|fonts| title.font_height(fonts, ui.style()))
.max(ui.spacing().interact_size.y);
ui.set_min_height(height);
let item_spacing = ui.spacing().item_spacing;
let button_size = Vec2::splat(ui.spacing().icon_width);
let pad = (height - button_size.y) / 2.0; // calculated so that the icon is on the diagonal (if window padding is symmetrical)
if collapsible {
ui.add_space(pad);
collapsing.show_default_button_with_size(ui, button_size);
}
let title_galley = title.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Heading);
let minimum_width = if collapsible || show_close_button {
// If at least one button is shown we make room for both buttons (since title is centered):
2.0 * (pad + button_size.x + item_spacing.x) + title_galley.size().x
} else {
pad + title_galley.size().x + pad
};
let min_rect = Rect::from_min_size(ui.min_rect().min, vec2(minimum_width, height));
let id = ui.advance_cursor_after_rect(min_rect);
TitleBar {
id,
title_galley,
min_rect,
rect: Rect::NAN, // Will be filled in later
}
});
let title_bar = inner_response.inner;
let rect = inner_response.response.rect;
TitleBar { rect, ..title_bar }
}
impl TitleBar {
/// Finishes painting of the title bar when the window content size already known.
///
/// # Parameters
///
/// - `ui`:
/// - `outer_rect`:
/// - `content_response`: if `None`, window is collapsed at this frame, otherwise contains
/// a result of rendering the window content
/// - `open`: if `None`, no "Close" button will be rendered, otherwise renders and processes
/// the "Close" button and writes a `false` if window was closed
/// - `collapsing`: holds the current expanding state. Can be changed by double click on the
/// title if `collapsible` is `true`
/// - `collapsible`: if `true`, double click on the title bar will be handled for a change
/// of `collapsing` state
fn ui(
mut self,
ui: &mut Ui,
outer_rect: Rect,
content_response: &Option<Response>,
open: Option<&mut bool>,
collapsing: &mut CollapsingState,
collapsible: bool,
) {
if let Some(content_response) = &content_response {
// Now we know how large we got to be:
self.rect.max.x = self.rect.max.x.max(content_response.rect.max.x);
}
if let Some(open) = open {
// Add close button now that we know our full width:
if self.close_button_ui(ui).clicked() {
*open = false;
}
}
let full_top_rect = Rect::from_x_y_ranges(self.rect.x_range(), self.min_rect.y_range());
let text_pos =
emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top();
let text_pos = text_pos - self.title_galley.galley().rect.min.to_vec2();
let text_pos = text_pos - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better)
self.title_galley.paint_with_fallback_color(
ui.painter(),
text_pos,
ui.visuals().text_color(),
);
if let Some(content_response) = &content_response {
// paint separator between title and content:
let y = content_response.rect.top() + ui.spacing().item_spacing.y * 0.5;
// let y = lerp(self.rect.bottom()..=content_response.rect.top(), 0.5);
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().hline(outer_rect.x_range(), y, stroke);
}
// Don't cover the close- and collapse buttons:
let double_click_rect = self.rect.shrink2(vec2(32.0, 0.0));
if ui
.interact(double_click_rect, self.id, Sense::click())
.double_clicked()
&& collapsible
{
collapsing.toggle(ui);
}
}
/// Paints the "Close" button at the right side of the title bar
/// and processes clicks on it.
///
/// The button is square and its size is determined by the
/// [`crate::style::Spacing::icon_width`] setting.
fn close_button_ui(&self, ui: &mut Ui) -> Response {
let button_size = Vec2::splat(ui.spacing().icon_width);
let pad = (self.rect.height() - button_size.y) / 2.0; // calculated so that the icon is on the diagonal (if window padding is symmetrical)
let button_rect = Rect::from_min_size(
pos2(
self.rect.right() - pad - button_size.x,
self.rect.center().y - 0.5 * button_size.y,
),
button_size,
);
close_button(ui, button_rect)
}
}
/// Paints the "Close" button of the window and processes clicks on it.
///
/// The close button is just an `X` symbol painted by a current stroke
/// for foreground elements (such as a label text).
///
/// # Parameters
/// - `ui`:
/// - `rect`: The rectangular area to fit the button in
///
/// Returns the result of a click on a button if it was pressed
fn close_button(ui: &mut Ui, rect: Rect) -> Response {
let close_id = ui.auto_id_with("window_close_button");
let response = ui.interact(rect, close_id, Sense::click());
ui.expand_to_include_rect(response.rect);
let visuals = ui.style().interact(&response);
let rect = rect.shrink(2.0).expand(visuals.expansion);
let stroke = visuals.fg_stroke;
ui.painter() // paints \
.line_segment([rect.left_top(), rect.right_bottom()], stroke);
ui.painter() // paints /
.line_segment([rect.right_top(), rect.left_bottom()], stroke);
response
}

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more