summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2024-09-17 04:44:56 +0200
committerLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2024-09-17 04:44:56 +0200
commit547e509683007b9e0c149d847ac685f3aa770de8 (patch)
tree5cb2e9b75c736d116079165d359115bf23eb3bac
parent8fb939b5a920e0cd836dbdd24c948f8f2512fc7e (diff)
downloadiced-547e509683007b9e0c149d847ac685f3aa770de8.tar.gz
iced-547e509683007b9e0c149d847ac685f3aa770de8.tar.bz2
iced-547e509683007b9e0c149d847ac685f3aa770de8.zip
Implement a `changelog-generator` tool and example
-rw-r--r--CHANGELOG.md5
-rw-r--r--examples/changelog/Cargo.toml23
-rw-r--r--examples/changelog/fonts/changelog-icons.ttfbin0 -> 5764 bytes
-rw-r--r--examples/changelog/src/changelog.rs354
-rw-r--r--examples/changelog/src/icon.rs10
-rw-r--r--examples/changelog/src/main.rs368
6 files changed, 759 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16a69a7a..e8ac8d68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `fetch_position` command in `window` module. [#2280](https://github.com/iced-rs/iced/pull/2280)
-Many thanks to...
+### Fixed
+- Fix `block_on` in `iced_wgpu` hanging Wasm builds. [#2313](https://github.com/iced-rs/iced/pull/2313)
+Many thanks to...
+- @hecrj
- @n1ght-hunter
## [0.12.1] - 2024-02-22
diff --git a/examples/changelog/Cargo.toml b/examples/changelog/Cargo.toml
new file mode 100644
index 00000000..6f914bce
--- /dev/null
+++ b/examples/changelog/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "changelog"
+version = "0.1.0"
+authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
+edition = "2021"
+publish = false
+
+[dependencies]
+iced.workspace = true
+iced.features = ["tokio", "markdown", "highlighter", "debug"]
+
+log.workspace = true
+thiserror.workspace = true
+tokio.features = ["fs", "process"]
+tokio.workspace = true
+
+serde = "1"
+webbrowser = "1"
+
+[dependencies.reqwest]
+version = "0.12"
+default-features = false
+features = ["json", "rustls-tls"]
diff --git a/examples/changelog/fonts/changelog-icons.ttf b/examples/changelog/fonts/changelog-icons.ttf
new file mode 100644
index 00000000..a0f32553
--- /dev/null
+++ b/examples/changelog/fonts/changelog-icons.ttf
Binary files differ
diff --git a/examples/changelog/src/changelog.rs b/examples/changelog/src/changelog.rs
new file mode 100644
index 00000000..39cbb42c
--- /dev/null
+++ b/examples/changelog/src/changelog.rs
@@ -0,0 +1,354 @@
+use serde::Deserialize;
+use tokio::fs;
+use tokio::process;
+
+use std::env;
+use std::fmt;
+use std::io;
+use std::sync::Arc;
+
+#[derive(Debug, Clone)]
+pub struct Changelog {
+ ids: Vec<u64>,
+ added: Vec<String>,
+ changed: Vec<String>,
+ fixed: Vec<String>,
+ removed: Vec<String>,
+ authors: Vec<String>,
+}
+
+impl Changelog {
+ pub fn new() -> Self {
+ Self {
+ ids: Vec::new(),
+ added: Vec::new(),
+ changed: Vec::new(),
+ fixed: Vec::new(),
+ removed: Vec::new(),
+ authors: Vec::new(),
+ }
+ }
+
+ pub async fn list() -> Result<(Self, Vec<Candidate>), Error> {
+ let mut changelog = Self::new();
+
+ {
+ let markdown = fs::read_to_string("CHANGELOG.md").await?;
+
+ if let Some(unreleased) = markdown.split("\n## ").nth(1) {
+ let sections = unreleased.split("\n\n");
+
+ for section in sections {
+ if section.starts_with("Many thanks to...") {
+ for author in section.lines().skip(1) {
+ let author = author.trim_start_matches("- @");
+
+ if author.is_empty() {
+ continue;
+ }
+
+ changelog.authors.push(author.to_owned());
+ }
+
+ continue;
+ }
+
+ let Some((_, rest)) = section.split_once("### ") else {
+ continue;
+ };
+
+ let Some((name, rest)) = rest.split_once("\n") else {
+ continue;
+ };
+
+ let category = match name {
+ "Added" => Category::Added,
+ "Fixed" => Category::Fixed,
+ "Changed" => Category::Changed,
+ "Removed" => Category::Removed,
+ _ => continue,
+ };
+
+ for entry in rest.lines() {
+ let Some((_, id)) = entry.split_once('#') else {
+ continue;
+ };
+
+ let Some((id, _)) = id.split_once(']') else {
+ continue;
+ };
+
+ let Ok(id): Result<u64, _> = id.parse() else {
+ continue;
+ };
+
+ changelog.ids.push(id);
+
+ let target = match category {
+ Category::Added => &mut changelog.added,
+ Category::Changed => &mut changelog.added,
+ Category::Fixed => &mut changelog.fixed,
+ Category::Removed => &mut changelog.removed,
+ };
+
+ target.push(entry.to_owned());
+ }
+ }
+ }
+ }
+
+ let mut candidates = Candidate::list().await?;
+
+ for reviewed_entry in changelog.entries() {
+ candidates.retain(|candidate| candidate.id != reviewed_entry);
+ }
+
+ Ok((changelog, candidates))
+ }
+
+ pub fn len(&self) -> usize {
+ self.ids.len()
+ }
+
+ pub fn entries(&self) -> impl Iterator<Item = u64> + '_ {
+ self.ids.iter().copied()
+ }
+
+ pub fn push(&mut self, entry: Entry) {
+ self.ids.push(entry.id);
+
+ let item = format!(
+ "- {title}. [#{id}](https://github.com/iced-rs/iced/pull/{id})",
+ title = entry.title,
+ id = entry.id
+ );
+
+ let target = match entry.category {
+ Category::Added => &mut self.added,
+ Category::Changed => &mut self.added,
+ Category::Fixed => &mut self.fixed,
+ Category::Removed => &mut self.removed,
+ };
+
+ target.push(item);
+
+ if !self.authors.contains(&entry.author) {
+ self.authors.push(entry.author);
+ self.authors.sort_by_key(|author| author.to_lowercase());
+ }
+ }
+}
+
+impl fmt::Display for Changelog {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ fn section(category: Category, entries: &[String]) -> String {
+ if entries.is_empty() {
+ return String::new();
+ }
+
+ format!("### {category}\n{list}\n", list = entries.join("\n"))
+ }
+
+ fn thank_you<'a>(authors: impl IntoIterator<Item = &'a str>) -> String {
+ let mut list = String::new();
+
+ for author in authors {
+ list.push_str(&format!("- @{author}\n"));
+ }
+
+ format!("Many thanks to...\n{list}")
+ }
+
+ let changelog = [
+ section(Category::Added, &self.added),
+ section(Category::Changed, &self.changed),
+ section(Category::Fixed, &self.fixed),
+ section(Category::Removed, &self.removed),
+ thank_you(self.authors.iter().map(String::as_str)),
+ ]
+ .into_iter()
+ .filter(|section| !section.is_empty())
+ .collect::<Vec<String>>()
+ .join("\n");
+
+ f.write_str(&changelog)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Entry {
+ pub id: u64,
+ pub title: String,
+ pub category: Category,
+ pub author: String,
+}
+
+impl Entry {
+ pub fn new(
+ title: &str,
+ category: Category,
+ pull_request: &PullRequest,
+ ) -> Option<Self> {
+ let title = title.strip_suffix(".").unwrap_or(title);
+
+ if title.is_empty() {
+ return None;
+ };
+
+ Some(Self {
+ id: pull_request.id,
+ title: title.to_owned(),
+ category,
+ author: pull_request.author.clone(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Category {
+ Added,
+ Changed,
+ Fixed,
+ Removed,
+}
+
+impl Category {
+ pub const ALL: &'static [Self] =
+ &[Self::Added, Self::Changed, Self::Fixed, Self::Removed];
+
+ pub fn guess(label: &str) -> Option<Self> {
+ Some(match label {
+ "feature" | "addition" => Self::Added,
+ "change" => Self::Changed,
+ "bug" | "fix" => Self::Fixed,
+ _ => None?,
+ })
+ }
+}
+
+impl fmt::Display for Category {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(match self {
+ Category::Added => "Added",
+ Category::Changed => "Changed",
+ Category::Fixed => "Fixed",
+ Category::Removed => "Removed",
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Candidate {
+ pub id: u64,
+}
+
+#[derive(Debug, Clone)]
+pub struct PullRequest {
+ pub id: u64,
+ pub title: String,
+ pub description: String,
+ pub labels: Vec<String>,
+ pub author: String,
+}
+
+impl Candidate {
+ pub async fn list() -> Result<Vec<Candidate>, Error> {
+ let output = process::Command::new("git")
+ .args([
+ "log",
+ "--oneline",
+ "--grep",
+ "#[0-9]*",
+ "origin/latest..HEAD",
+ ])
+ .output()
+ .await?;
+
+ let log = String::from_utf8_lossy(&output.stdout);
+
+ Ok(log
+ .lines()
+ .filter(|title| !title.is_empty())
+ .filter_map(|title| {
+ let (_, pull_request) = title.split_once("#")?;
+ let (pull_request, _) = pull_request.split_once([')', ' '])?;
+
+ Some(Candidate {
+ id: pull_request.parse().ok()?,
+ })
+ })
+ .collect())
+ }
+
+ pub async fn fetch(self) -> Result<PullRequest, Error> {
+ let request = reqwest::Client::new()
+ .request(
+ reqwest::Method::GET,
+ format!(
+ "https://api.github.com/repos/iced-rs/iced/pulls/{}",
+ self.id
+ ),
+ )
+ .header("User-Agent", "iced changelog generator")
+ .header(
+ "Authorization",
+ format!(
+ "Bearer {}",
+ env::var("GITHUB_TOKEN")
+ .map_err(|_| Error::GitHubTokenNotFound)?
+ ),
+ );
+
+ #[derive(Deserialize)]
+ struct Schema {
+ title: String,
+ body: String,
+ user: User,
+ labels: Vec<Label>,
+ }
+
+ #[derive(Deserialize)]
+ struct User {
+ login: String,
+ }
+
+ #[derive(Deserialize)]
+ struct Label {
+ name: String,
+ }
+
+ let schema: Schema = request.send().await?.json().await?;
+
+ Ok(PullRequest {
+ id: self.id,
+ title: schema.title,
+ description: schema.body,
+ labels: schema.labels.into_iter().map(|label| label.name).collect(),
+ author: schema.user.login,
+ })
+ }
+}
+
+#[derive(Debug, Clone, thiserror::Error)]
+pub enum Error {
+ #[error("io operation failed: {0}")]
+ IOFailed(Arc<io::Error>),
+
+ #[error("http request failed: {0}")]
+ RequestFailed(Arc<reqwest::Error>),
+
+ #[error("no GITHUB_TOKEN variable was set")]
+ GitHubTokenNotFound,
+}
+
+impl From<io::Error> for Error {
+ fn from(error: io::Error) -> Self {
+ Error::IOFailed(Arc::new(error))
+ }
+}
+
+impl From<reqwest::Error> for Error {
+ fn from(error: reqwest::Error) -> Self {
+ Error::RequestFailed(Arc::new(error))
+ }
+}
diff --git a/examples/changelog/src/icon.rs b/examples/changelog/src/icon.rs
new file mode 100644
index 00000000..dd82e5b9
--- /dev/null
+++ b/examples/changelog/src/icon.rs
@@ -0,0 +1,10 @@
+use iced::widget::{text, Text};
+use iced::Font;
+
+pub const FONT_BYTES: &[u8] = include_bytes!("../fonts/changelog-icons.ttf");
+
+const FONT: Font = Font::with_name("changelog-icons");
+
+pub fn copy() -> Text<'static> {
+ text('\u{e800}').font(FONT)
+}
diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs
new file mode 100644
index 00000000..73da0f2c
--- /dev/null
+++ b/examples/changelog/src/main.rs
@@ -0,0 +1,368 @@
+mod changelog;
+mod icon;
+
+use crate::changelog::Changelog;
+
+use iced::clipboard;
+use iced::font;
+use iced::widget::{
+ button, center, column, container, markdown, pick_list, progress_bar,
+ rich_text, row, scrollable, span, stack, text, text_input,
+};
+use iced::{Element, Fill, FillPortion, Font, Task, Theme};
+
+pub fn main() -> iced::Result {
+ iced::application("Changelog Generator", Generator::update, Generator::view)
+ .font(icon::FONT_BYTES)
+ .theme(Generator::theme)
+ .run_with(Generator::new)
+}
+
+enum Generator {
+ Loading,
+ Empty,
+ Reviewing {
+ changelog: Changelog,
+ pending: Vec<changelog::Candidate>,
+ state: State,
+ preview: Vec<markdown::Item>,
+ },
+}
+
+enum State {
+ Loading(changelog::Candidate),
+ Loaded {
+ pull_request: changelog::PullRequest,
+ description: Vec<markdown::Item>,
+ title: String,
+ category: changelog::Category,
+ },
+}
+
+#[derive(Debug, Clone)]
+enum Message {
+ ChangelogListed(
+ Result<(Changelog, Vec<changelog::Candidate>), changelog::Error>,
+ ),
+ PullRequestFetched(Result<changelog::PullRequest, changelog::Error>),
+ UrlClicked(markdown::Url),
+ TitleChanged(String),
+ CategorySelected(changelog::Category),
+ Next,
+ OpenPullRequest(u64),
+ CopyPreview,
+}
+
+impl Generator {
+ fn new() -> (Self, Task<Message>) {
+ (
+ Self::Loading,
+ Task::perform(Changelog::list(), Message::ChangelogListed),
+ )
+ }
+
+ fn update(&mut self, message: Message) -> Task<Message> {
+ match message {
+ Message::ChangelogListed(Ok((changelog, mut pending))) => {
+ if let Some(candidate) = pending.pop() {
+ let preview =
+ markdown::parse(&changelog.to_string()).collect();
+
+ *self = Self::Reviewing {
+ changelog,
+ pending,
+ state: State::Loading(candidate.clone()),
+ preview,
+ };
+
+ Task::perform(
+ candidate.fetch(),
+ Message::PullRequestFetched,
+ )
+ } else {
+ *self = Self::Empty;
+
+ Task::none()
+ }
+ }
+ Message::PullRequestFetched(Ok(pull_request)) => {
+ let Self::Reviewing { state, .. } = self else {
+ return Task::none();
+ };
+
+ let description =
+ markdown::parse(&pull_request.description).collect();
+
+ *state = State::Loaded {
+ title: pull_request.title.clone(),
+ category: pull_request
+ .labels
+ .iter()
+ .map(String::as_str)
+ .filter_map(changelog::Category::guess)
+ .next()
+ .unwrap_or(changelog::Category::Added),
+ pull_request,
+ description,
+ };
+
+ Task::none()
+ }
+ Message::ChangelogListed(Err(error))
+ | Message::PullRequestFetched(Err(error)) => {
+ log::error!("{error}");
+
+ Task::none()
+ }
+ Message::UrlClicked(url) => {
+ let _ = webbrowser::open(url.as_str());
+
+ Task::none()
+ }
+ Message::TitleChanged(new_title) => {
+ let Self::Reviewing { state, .. } = self else {
+ return Task::none();
+ };
+
+ let State::Loaded { title, .. } = state else {
+ return Task::none();
+ };
+
+ *title = new_title;
+
+ Task::none()
+ }
+ Message::CategorySelected(new_category) => {
+ let Self::Reviewing { state, .. } = self else {
+ return Task::none();
+ };
+
+ let State::Loaded { category, .. } = state else {
+ return Task::none();
+ };
+
+ *category = new_category;
+
+ Task::none()
+ }
+ Message::Next => {
+ let Self::Reviewing {
+ changelog,
+ pending,
+ state,
+ preview,
+ ..
+ } = self
+ else {
+ return Task::none();
+ };
+
+ let State::Loaded {
+ title,
+ category,
+ pull_request,
+ ..
+ } = state
+ else {
+ return Task::none();
+ };
+
+ if let Some(entry) =
+ changelog::Entry::new(title, *category, pull_request)
+ {
+ changelog.push(entry);
+
+ *preview =
+ markdown::parse(&changelog.to_string()).collect();
+
+ if let Some(candidate) = pending.pop() {
+ *state = State::Loading(candidate.clone());
+
+ Task::perform(
+ candidate.fetch(),
+ Message::PullRequestFetched,
+ )
+ } else {
+ // TODO: We are done!
+ Task::none()
+ }
+ } else {
+ Task::none()
+ }
+ }
+ Message::OpenPullRequest(id) => {
+ let _ = webbrowser::open(&format!(
+ "https://github.com/iced-rs/iced/pull/{id}"
+ ));
+
+ Task::none()
+ }
+ Message::CopyPreview => {
+ let Self::Reviewing { changelog, .. } = self else {
+ return Task::none();
+ };
+
+ clipboard::write(changelog.to_string())
+ }
+ }
+ }
+
+ fn view(&self) -> Element<Message> {
+ match self {
+ Self::Loading => center("Loading...").into(),
+ Self::Empty => center("No changes found!").into(),
+ Self::Reviewing {
+ changelog,
+ pending,
+ state,
+ preview,
+ } => {
+ let progress = {
+ let total = pending.len() + changelog.len();
+
+ let bar = progress_bar(
+ 0.0..=1.0,
+ changelog.len() as f32 / total as f32,
+ )
+ .style(progress_bar::secondary);
+
+ let label = text!(
+ "{amount_reviewed} / {total}",
+ amount_reviewed = changelog.len()
+ )
+ .font(Font::MONOSPACE)
+ .size(12);
+
+ stack![bar, center(label)]
+ };
+
+ let form: Element<_> = match state {
+ State::Loading(candidate) => {
+ text!("Loading #{}...", candidate.id).into()
+ }
+ State::Loaded {
+ pull_request,
+ description,
+ title,
+ category,
+ } => {
+ let details = {
+ let title = rich_text![
+ span(&pull_request.title).size(24).link(
+ Message::OpenPullRequest(pull_request.id)
+ ),
+ span(format!(" by {}", pull_request.author))
+ .font(Font {
+ style: font::Style::Italic,
+ ..Font::default()
+ }),
+ ]
+ .font(Font::MONOSPACE);
+
+ let description = markdown::view(
+ description,
+ markdown::Settings::default(),
+ markdown::Style::from_palette(
+ self.theme().palette(),
+ ),
+ )
+ .map(Message::UrlClicked);
+
+ let labels =
+ row(pull_request.labels.iter().map(|label| {
+ container(
+ text(label)
+ .size(10)
+ .font(Font::MONOSPACE),
+ )
+ .padding(5)
+ .style(container::rounded_box)
+ .into()
+ }))
+ .spacing(10)
+ .wrap();
+
+ column![
+ title,
+ labels,
+ scrollable(description)
+ .spacing(10)
+ .width(Fill)
+ .height(Fill)
+ ]
+ .spacing(10)
+ };
+
+ let title = text_input(
+ "Type a changelog entry title...",
+ title,
+ )
+ .on_input(Message::TitleChanged);
+
+ let category = pick_list(
+ changelog::Category::ALL,
+ Some(category),
+ Message::CategorySelected,
+ );
+
+ let next = button("Next →")
+ .on_press(Message::Next)
+ .style(button::success);
+
+ column![
+ details,
+ row![title, category, next].spacing(10)
+ ]
+ .spacing(10)
+ .into()
+ }
+ };
+
+ let preview: Element<_> = if preview.is_empty() {
+ center(
+ container(
+ text("The changelog is empty... so far!").size(12),
+ )
+ .padding(10)
+ .style(container::rounded_box),
+ )
+ .into()
+ } else {
+ let content = container(
+ scrollable(
+ markdown::view(
+ preview,
+ markdown::Settings::with_text_size(12),
+ markdown::Style::from_palette(
+ self.theme().palette(),
+ ),
+ )
+ .map(Message::UrlClicked),
+ )
+ .spacing(10),
+ )
+ .width(Fill)
+ .padding(10)
+ .style(container::rounded_box);
+
+ let copy = button(icon::copy().size(12))
+ .on_press(Message::CopyPreview)
+ .style(button::text);
+
+ center(stack![content, container(copy).align_right(Fill)])
+ .into()
+ };
+
+ let review = column![container(form).height(Fill), progress]
+ .spacing(10)
+ .width(FillPortion(2));
+
+ row![review, preview].spacing(10).padding(10).into()
+ }
+ }
+ }
+
+ fn theme(&self) -> Theme {
+ Theme::TokyoNightStorm
+ }
+}