core/
graphics.rs

1// Copyright (C) 2017 Steve Sprang
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program.  If not, see <http://www.gnu.org/licenses/>.
15
16#![allow(clippy::cast_lossless)]
17
18use cairo::{Context, Rectangle};
19use crate::card::{Card, Color, Shading, Shape};
20use crate::geometry::RectangleExt;
21use rand::{Rng, thread_rng};
22use std::f64::consts::{PI, FRAC_PI_2};
23use std::f64;
24
25const CORNER_RADIUS_PERCENTAGE: f64 = 0.08;
26const BADGE_BACKGROUND_GRAY: f64 = 0.68;
27const CARD_LABEL_GRAY: f64 = 0.75;
28const PLACEHOLDER_GRAY: f64 = 0.75;
29const TABLEAU_BACKGROUND_GRAY: f64 = 0.8;
30const MOCK_STRIPE_TRANSLUCENCY: f64 = 0.4;
31
32#[inline]
33pub fn card_corner_radius(Rectangle { height, .. }: Rectangle) -> f64 {
34    CORNER_RADIUS_PERCENTAGE * height
35}
36
37////////////////////////////////////////////////////////////////////////////////
38// ColorScheme
39////////////////////////////////////////////////////////////////////////////////
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub enum ColorScheme {
43    CMYK,
44    Classic,
45}
46
47impl ColorScheme {
48    pub fn card_color(self, card: Card) -> (f64, f64, f64) {
49        let (r,g,b) = match self {
50            // This scheme is intended to be friendlier to those with
51            // color vision deficiencies
52            ColorScheme::CMYK => match card.color() {
53                Color::A => (0, 200, 220), // cyan
54                Color::B => (192, 0, 192), // magenta
55                Color::C => (220, 200, 0), // yellow
56            },
57
58            ColorScheme::Classic => match card.color() {
59                Color::A => (0, 151, 0),   // green
60                Color::B => (130, 0, 140), // purple
61                Color::C => (240, 0, 0),   // red
62            },
63        };
64
65        (r as f64 / 255., g as f64 / 255., b as f64 / 255.)
66    }
67}
68
69////////////////////////////////////////////////////////////////////////////////
70// ContextExt
71////////////////////////////////////////////////////////////////////////////////
72
73pub trait ContextExt {
74    /// Perform transform operations around a pivot point.
75    fn with_pivot<F>(&self, pivot: (f64, f64), f: F) where F: Fn() -> ();
76
77    fn set_source_gray(&self, g: f64);
78    fn set_source_random_rgb(&self);
79
80    fn rounded_rect(&self, rect: Rectangle, radius: f64);
81    fn diamond_in_rect(&self, rect: Rectangle);
82    fn squiggle_in_rect(&self, rect: Rectangle);
83
84    fn draw_badge(&self, rect: Rectangle, count: usize, label: &str);
85    fn draw_card_background(&self, rect: Rectangle, label: Option<&str>, gray: f64);
86    fn draw_card_placeholder(&self, rect: Rectangle);
87    fn draw_card_selection(&self, rect: Rectangle);
88    fn draw_card(&self, card: Card, rect: Rectangle, label: Option<&str>, scheme: ColorScheme);
89}
90
91impl ContextExt for Context {
92    fn with_pivot<F>(&self, (px, py): (f64, f64), f: F) where F: Fn() -> () {
93        self.translate(px, py);
94        f();
95        self.translate(-px, -py);
96    }
97
98    fn set_source_gray(&self, g: f64) {
99        self.set_source_rgb(g, g, g);
100    }
101
102    fn set_source_random_rgb(&self) {
103        let mut rng = thread_rng();
104        let r = rng.gen_range(0.0, 1.0);
105        let g = rng.gen_range(0.0, 1.0);
106        let b = rng.gen_range(0.0, 1.0);
107
108        self.set_source_rgb(r, g, b);
109    }
110
111    fn rounded_rect(&self, rect: Rectangle, radius: f64) {
112        let Rectangle {x, y, width, height} = rect;
113        let r = f64::min(radius, f64::min(width / 2., height / 2.));
114
115        self.new_sub_path();
116        self.arc(x + width - r, y + r, r, -FRAC_PI_2, 0.);
117        self.arc(x + width - r, y + height - r, r, 0., FRAC_PI_2);
118        self.arc(x + r, y + height - r, r, FRAC_PI_2, PI);
119        self.arc(x + r, y + r, r, PI, FRAC_PI_2 * 3.);
120        self.close_path();
121    }
122
123    fn diamond_in_rect(&self, rect: Rectangle) {
124        let Rectangle {x, y, width, height} = rect;
125        let half_width = width / 2.;
126        let half_height = height / 2.;
127
128        self.new_sub_path();
129        self.move_to(x + half_width, y);
130        self.line_to(x + width, y + half_height);
131        self.line_to(x + half_width, y + height);
132        self.line_to(x, y + half_height);
133        self.close_path();
134    }
135
136    fn squiggle_in_rect(&self, rect: Rectangle) {
137        let Rectangle {x, y, width, height} = rect;
138
139        self.new_sub_path();
140        self.move_to(x + width / 3., y);
141
142        self.curve_to(x + width * 4. / 5., y,
143                      x + width, y + height / 6.,
144                      x + width, y + height / 3.);
145
146        self.curve_to(x + width, y + height / 2.,
147                      x + width * 5. / 6., y + height / 2.,
148                      x + width * 5. / 6., y + height * 2. / 3.);
149
150        self.curve_to(x + width * 5. / 6., y + height * 5. / 6.,
151                      x + width, y + height * 5. / 6.,
152                      x + width, y + height * 11. / 12.);
153
154        self.curve_to(x + width, y + height * 23. / 24.,
155                      x + width * 5. / 6., y + height,
156                      x + width * 2. / 3., y + height);
157
158        self.curve_to(x + width / 5., y + height,
159                      x, y + height * 5. / 6.,
160                      x, y + height * 2. / 3.);
161
162        self.curve_to(x, y + height / 2.,
163                      x + width / 6., y + height / 2.,
164                      x + width / 6., y + height / 3.);
165
166        self.curve_to(x + width / 6., y + height / 6.,
167                      x, y + height / 6.,
168                      x, y + height / 12.);
169
170        self.curve_to(x, y + height / 24.,
171                      x + width / 6., y,
172                      x + width / 3., y);
173
174        self.close_path();
175    }
176
177    fn draw_badge(&self, rect: Rectangle, count: usize, label: &str) {
178        let badge_height = rect.height * (2. / 3.);
179        let label_height = rect.height - badge_height;
180        let count_string = count.to_string();
181
182        let padding = rect.width * 0.2;
183        let badge_rect = Rectangle {
184            x: rect.x,
185            y: rect.y,
186            width: rect.width,
187            height: badge_height
188        }.inset(padding, padding / 8.);
189
190        // draw badge background
191        self.set_source_gray(BADGE_BACKGROUND_GRAY);
192        self.rounded_rect(badge_rect.round(), f64::INFINITY);
193        self.fill();
194
195        // draw the label (same gray as badge background)
196        self.set_font_size(label_height * 0.9);
197        let extents = self.text_extents(label);
198        let x = rect.x + (rect.width - extents.width) / 2.;
199        let y = rect.max_y() - (label_height - extents.height) / 3.;
200
201        self.move_to(x, y);
202        self.show_text(label);
203
204        // draw count
205        self.set_font_size(badge_height * 0.75);
206        let extents = self.text_extents(&count_string);
207        let x = rect.x + (rect.width - extents.width) / 2. - extents.x_bearing;
208        let y = badge_rect.max_y() - (badge_rect.height - extents.height) / 2.;
209
210        self.move_to(x, y);
211        self.set_source_gray(TABLEAU_BACKGROUND_GRAY);
212        self.show_text(&count_string);
213    }
214
215    fn draw_card_background(&self, rect: Rectangle, label: Option<&str>, gray: f64) {
216        let corner_radius = card_corner_radius(rect);
217        self.rounded_rect(rect, corner_radius);
218        self.set_source_gray(gray);
219        self.fill();
220
221        if let Some(text) = label {
222            let font_size = f64::min(rect.height * 0.15, 24.);
223            self.set_font_size(font_size);
224            self.move_to(rect.x + corner_radius, rect.max_y() - corner_radius);
225            self.set_source_gray(CARD_LABEL_GRAY);
226            self.show_text(text);
227        }
228    }
229
230    fn draw_card_placeholder(&self, rect: Rectangle) {
231        self.draw_card_background(rect, None, PLACEHOLDER_GRAY);
232    }
233
234    fn draw_card_selection(&self, rect: Rectangle) {
235        let Rectangle { height, .. } = rect;
236        let corner_radius = card_corner_radius(rect);
237        let selection_width = (height * 0.035).round() * 2.;
238
239        self.rounded_rect(rect, corner_radius);
240        self.set_source_gray(0.);
241        self.set_line_width(selection_width);
242        self.stroke();
243    }
244
245    fn draw_card(&self, card: Card, rect: Rectangle, label: Option<&str>, scheme: ColorScheme) {
246        let Rectangle { x, y, width, height } = rect;
247        // render the background
248        self.draw_card_background(rect, label, 1.0);
249
250        // calculate shape bounds and margins
251        let vertical_margin = 0.15 * height;
252        let spacing = vertical_margin / 2.;
253        let shape_height = height - (vertical_margin * 2.);
254        let shape_width = shape_height / 2.1;
255
256        let count = card.count();
257        // total width of all shapes including spacing
258        let shape_extent = (count as f64) * (shape_width + spacing) - spacing;
259        let horizontal_margin = (width - shape_extent) / 2.;
260
261        // bounds of a single shape
262        let mut shape_rect = Rectangle {
263            x: x + horizontal_margin,
264            y: y + vertical_margin,
265            width: shape_width,
266            height: shape_height
267        };
268
269        // add the shapes to the context
270        for _ in 0..count {
271            match card.shape() {
272                Shape::Oval => self.rounded_rect(shape_rect, f64::INFINITY),
273                Shape::Squiggle => self.squiggle_in_rect(shape_rect),
274                Shape::Diamond => self.diamond_in_rect(shape_rect)
275            }
276            shape_rect = shape_rect.offset(shape_width + spacing, 0.);
277        }
278
279        // determine card color
280        let (r, g, b) = scheme.card_color(card);
281        self.set_source_rgb(r, g, b);
282
283        // compute base outline width
284        let stroke_width = shape_width / 11.;
285
286        // finally, do the rendering based on the shading
287        match card.shading() {
288            Shading::Solid => self.fill(),
289            Shading::Outlined => {
290                // clip to the path so that the stroked shape has the
291                // same footprint as the filled shape
292                self.clip_preserve();
293                // double the width since half the stroke is clipped away
294                self.set_line_width(stroke_width * 2.);
295                self.stroke();
296                self.reset_clip();
297            }
298            Shading::Striped => {
299                // a translucent fill is more attractive than stripes
300                self.set_source_rgba(r, g, b, MOCK_STRIPE_TRANSLUCENCY);
301                self.fill_preserve();
302
303                // draw a white band between the stroke and the translucent fill
304                self.set_source_gray(1.0);
305                self.set_line_width(stroke_width * 3.);
306                self.stroke_preserve();
307
308                // draw the outside stroke in the card color
309                self.set_source_rgb(r, g, b);
310                self.set_line_width(stroke_width * 4. / 3.);
311                // clip to the path so that the stroked shape has the
312                // same footprint as the filled shape
313                self.clip_preserve();
314                self.stroke();
315                self.reset_clip();
316            }
317        }
318    }
319}