diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/KeyValueMap.vala | 118 | ||||
-rw-r--r-- | src/util/Util.vala | 17 | ||||
-rw-r--r-- | src/util/file.vala | 241 | ||||
-rw-r--r-- | src/util/image.vala | 364 | ||||
-rw-r--r-- | src/util/misc.vala | 377 | ||||
-rw-r--r-- | src/util/mk/util.mk | 34 | ||||
-rw-r--r-- | src/util/string.vala | 268 | ||||
-rw-r--r-- | src/util/system.vala | 40 | ||||
-rw-r--r-- | src/util/ui.vala | 98 |
9 files changed, 1557 insertions, 0 deletions
diff --git a/src/util/KeyValueMap.vala b/src/util/KeyValueMap.vala new file mode 100644 index 0000000..c1f5a55 --- /dev/null +++ b/src/util/KeyValueMap.vala @@ -0,0 +1,118 @@ +/* Copyright 2011-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class KeyValueMap { + private string group; + private Gee.HashMap<string, string> map = new Gee.HashMap<string, string>(); + + public KeyValueMap(string group) { + this.group = group; + } + + public KeyValueMap copy() { + KeyValueMap clone = new KeyValueMap(group); + foreach (string key in map.keys) + clone.map.set(key, map.get(key)); + + return clone; + } + + public string get_group() { + return group; + } + + public Gee.Set<string> get_keys() { + return map.keys; + } + + public bool has_key(string key) { + return map.has_key(key); + } + + public void set_string(string key, string value) { + assert(key != null); + + map.set(key, value); + } + + public void set_int(string key, int value) { + assert(key != null); + + map.set(key, value.to_string()); + } + + public void set_double(string key, double value) { + assert(key != null); + + map.set(key, value.to_string()); + } + + public void set_float(string key, float value) { + assert(key != null); + + map.set(key, value.to_string()); + } + + public void set_bool(string key, bool value) { + assert(key != null); + + map.set(key, value.to_string()); + } + + public string get_string(string key, string? def) { + string value = map.get(key); + + return (value != null) ? value : def; + } + + public int get_int(string key, int def) { + string value = map.get(key); + + return (value != null) ? int.parse(value) : def; + } + + public double get_double(string key, double def) { + string value = map.get(key); + + return (value != null) ? double.parse(value) : def; + } + + public float get_float(string key, float def) { + string value = map.get(key); + + return (value != null) ? (float) double.parse(value) : def; + } + + public bool get_bool(string key, bool def) { + string value = map.get(key); + + return (value != null) ? bool.parse(value) : def; + } + + // REDEYE: redeye reduction operates on circular regions defined by + // (Gdk.Point, int) pairs, where the Gdk.Point specifies the + // bounding circle's center and the the int specifies the circle's + // radius so, get_point( ) and set_point( ) functions have been + // added here to easily encode/decode Gdk.Points as strings. + public Gdk.Point get_point(string key, Gdk.Point def) { + string value = map.get(key); + + if (value == null) { + return def; + } else { + Gdk.Point result = {0}; + if (value.scanf("(%d, %d)", &result.x, &result.y) == 2) + return result; + else + return def; + } + } + + public void set_point(string key, Gdk.Point point) { + map.set(key, "(%d, %d)".printf(point.x, point.y)); + } +} + diff --git a/src/util/Util.vala b/src/util/Util.vala new file mode 100644 index 0000000..c754ff8 --- /dev/null +++ b/src/util/Util.vala @@ -0,0 +1,17 @@ +/* Copyright 2011-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +namespace Util { + // Use these file attributes when loading file information for a complete FileInfo objects + public const string FILE_ATTRIBUTES = "standard::*,time::*,id::file,id::filesystem,etag::value"; + + public void init() throws Error { + } + + public void terminate() { + } +} + diff --git a/src/util/file.vala b/src/util/file.vala new file mode 100644 index 0000000..1b6bb6c --- /dev/null +++ b/src/util/file.vala @@ -0,0 +1,241 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +// Returns true if the file is claimed, false if it exists, and throws an Error otherwise. The file +// will be created when the function exits and should be overwritten. Note that the file is not +// held open; claiming a file is merely based on its existence. +// +// This function is thread-safe. +public bool claim_file(File file) throws Error { + try { + file.create(FileCreateFlags.NONE, null); + + // created; success + return true; + } catch (Error err) { + // check for file-exists error + if (!(err is IOError.EXISTS)) { + warning("claim_file %s: %s", file.get_path(), err.message); + + throw err; + } + + return false; + } +} + +// This function "claims" a file on the filesystem in the directory specified with a basename the +// same or similar as what has been requested (adds numerals to the end of the name until a unique +// one has been found). The file may exist when this function returns, and it should be +// overwritten. It does *not* attempt to create the parent directory, however. +// +// This function is thread-safe. +public File? generate_unique_file(File dir, string basename, out bool collision) throws Error { + // create the file to atomically "claim" it + File file = dir.get_child(basename); + if (claim_file(file)) { + collision = false; + + return file; + } + + // file exists, note collision and keep searching + collision = true; + + string name, ext; + disassemble_filename(basename, out name, out ext); + + // generate a unique filename + for (int ctr = 1; ctr < int.MAX; ctr++) { + string new_name = (ext != null) ? "%s_%d.%s".printf(name, ctr, ext) : "%s_%d".printf(name, ctr); + + file = dir.get_child(new_name); + if (claim_file(file)) + return file; + } + + warning("generate_unique_filename %s for %s: unable to claim file", dir.get_path(), basename); + + return null; +} + +public void disassemble_filename(string basename, out string name, out string ext) { + long offset = find_last_offset(basename, '.'); + if (offset <= 0) { + name = basename; + ext = null; + } else { + name = basename.substring(0, offset); + ext = basename.substring(offset + 1, -1); + } +} + +// This function is thread-safe. +public uint64 query_total_file_size(File file_or_dir, Cancellable? cancellable = null) throws Error { + FileType type = file_or_dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + if (type == FileType.REGULAR) { + FileInfo info = null; + try { + info = file_or_dir.query_info(FileAttribute.STANDARD_SIZE, + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable); + } catch (Error err) { + if (err is IOError.CANCELLED) + throw err; + + debug("Unable to query filesize for %s: %s", file_or_dir.get_path(), err.message); + + return 0; + } + + return info.get_size(); + } else if (type != FileType.DIRECTORY) { + return 0; + } + + FileEnumerator enumerator; + try { + enumerator = file_or_dir.enumerate_children(FileAttribute.STANDARD_NAME, + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable); + if (enumerator == null) + return 0; + } catch (Error err) { + // Don't treat a permissions failure as a hard failure, just skip the directory + if (err is FileError.PERM || err is IOError.PERMISSION_DENIED) + return 0; + + throw err; + } + + uint64 total_bytes = 0; + + FileInfo info = null; + while ((info = enumerator.next_file(cancellable)) != null) + total_bytes += query_total_file_size(file_or_dir.get_child(info.get_name()), cancellable); + + return total_bytes; +} + +// Does not currently recurse. Could be modified to do so. Does not error out on first file that +// does not delete, but logs a warning and continues. +// Note: if supplying a progress monitor, a file count is also required. The count_files_in_directory() +// function below should do the trick. +public void delete_all_files(File dir, Gee.Set<string>? exceptions = null, ProgressMonitor? monitor = null, + uint64 file_count = 0, Cancellable? cancellable = null) throws Error { + FileType type = dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + if (type != FileType.DIRECTORY) + throw new IOError.NOT_DIRECTORY("%s is not a directory".printf(dir.get_path())); + + FileEnumerator enumerator = dir.enumerate_children("standard::name,standard::type", + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable); + FileInfo info = null; + uint64 i = 0; + while ((info = enumerator.next_file(cancellable)) != null) { + if (info.get_file_type() != FileType.REGULAR) + continue; + + if (exceptions != null && exceptions.contains(info.get_name())) + continue; + + File file = dir.get_child(info.get_name()); + try { + file.delete(cancellable); + } catch (Error err) { + warning("Unable to delete file %s: %s", file.get_path(), err.message); + } + + if (monitor != null && file_count > 0) + monitor(file_count, ++i); + } +} + +public time_t query_file_modified(File file) throws Error { + FileInfo info = file.query_info(FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NOFOLLOW_SYMLINKS, + null); + + return info.get_modification_time().tv_sec; +} + +public bool query_is_directory(File file) { + return file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) == FileType.DIRECTORY; +} + +public bool query_is_directory_empty(File dir) throws Error { + if (dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) != FileType.DIRECTORY) + return false; + + FileEnumerator enumerator = dir.enumerate_children("standard::name", + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + if (enumerator == null) + return false; + + return enumerator.next_file(null) == null; +} + +public string get_display_pathname(File file) { + // attempt to replace home path with tilde in a user-pleasable way + string path = file.get_parse_name(); + string home = Environment.get_home_dir(); + + if (path == home) + return "~"; + + if (path.has_prefix(home)) + return "~%s".printf(path.substring(home.length)); + + return path; +} + +public string strip_pretty_path(string path) { + if (!path.has_prefix("~")) + return path; + + return Environment.get_home_dir() + path.substring(1); +} + +public string? get_file_info_id(FileInfo info) { + return info.get_attribute_string(FileAttribute.ID_FILE); +} + +// Breaks a uint64 skip amount into several smaller skips. +public void skip_uint64(InputStream input, uint64 skip_amount) throws GLib.Error { + while (skip_amount > 0) { + // skip() throws an error if the amount is too large, so check against ssize_t.MAX + if (skip_amount >= ssize_t.MAX) { + input.skip(ssize_t.MAX); + skip_amount -= ssize_t.MAX; + } else { + input.skip((size_t) skip_amount); + skip_amount = 0; + } + } +} + +// Returns the number of files (and/or directories) within a directory. +public uint64 count_files_in_directory(File dir) throws GLib.Error { + if (!query_is_directory(dir)) + return 0; + + uint64 count = 0; + FileEnumerator enumerator = dir.enumerate_children("standard::*", + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + + FileInfo info = null; + while ((info = enumerator.next_file()) != null) + count++; + + return count; +} + +// Replacement for deprecated Gio.file_equal +public bool file_equal(File? a, File? b) { + return (a != null && b != null) ? a.equal(b) : false; +} + +// Replacement for deprecated Gio.file_hash +public uint file_hash(File? file) { + return file != null ? file.hash() : 0; +} + diff --git a/src/util/image.vala b/src/util/image.vala new file mode 100644 index 0000000..e8f93ba --- /dev/null +++ b/src/util/image.vala @@ -0,0 +1,364 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +bool is_color_parsable(string spec) { + Gdk.Color color; + return Gdk.Color.parse(spec, out color); +} + +Gdk.RGBA parse_color(string spec) { + return fetch_color(spec); +} + +Gdk.RGBA fetch_color(string spec) { + Gdk.RGBA rgba = Gdk.RGBA(); + if (!rgba.parse(spec)) + error("Can't parse color %s", spec); + + return rgba; +} + +void set_source_color_from_string(Cairo.Context ctx, string spec) { + Gdk.RGBA rgba = fetch_color(spec); + ctx.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha); +} + +private const int MIN_SCALED_WIDTH = 10; +private const int MIN_SCALED_HEIGHT = 10; + +Gdk.Pixbuf scale_pixbuf(Gdk.Pixbuf pixbuf, int scale, Gdk.InterpType interp, bool scale_up) { + Dimensions original = Dimensions.for_pixbuf(pixbuf); + Dimensions scaled = original.get_scaled(scale, scale_up); + if ((original.width == scaled.width) && (original.height == scaled.height)) + return pixbuf; + + // use sane minimums ... scale_simple will hang if this is too low + scaled = scaled.with_min(MIN_SCALED_WIDTH, MIN_SCALED_HEIGHT); + + return pixbuf.scale_simple(scaled.width, scaled.height, interp); +} + +Gdk.Pixbuf resize_pixbuf(Gdk.Pixbuf pixbuf, Dimensions resized, Gdk.InterpType interp) { + Dimensions original = Dimensions.for_pixbuf(pixbuf); + if (original.width == resized.width && original.height == resized.height) + return pixbuf; + + // use sane minimums ... scale_simple will hang if this is too low + resized = resized.with_min(MIN_SCALED_WIDTH, MIN_SCALED_HEIGHT); + + return pixbuf.scale_simple(resized.width, resized.height, interp); +} + +private const double DEGREE = Math.PI / 180.0; + +void draw_rounded_corners_filled(Cairo.Context ctx, Dimensions dim, Gdk.Point origin, + double radius_proportion) { + context_rounded_corners(ctx, dim, origin, radius_proportion); + ctx.paint(); +} + +void context_rounded_corners(Cairo.Context cx, Dimensions dim, Gdk.Point origin, + double radius_proportion) { + // establish a reasonable range + radius_proportion = radius_proportion.clamp(2.0, 100.0); + + double left = origin.x; + double top = origin.y; + double right = origin.x + dim.width; + double bottom = origin.y + dim.height; + + // the radius of the corners is proportional to the distance of the minor axis + double radius = ((double) dim.minor_axis()) / radius_proportion; + + // create context and clipping region, starting from the top right arc and working around + // clockwise + cx.move_to(left, top); + cx.arc(right - radius, top + radius, radius, -90 * DEGREE, 0 * DEGREE); + cx.arc(right - radius, bottom - radius, radius, 0 * DEGREE, 90 * DEGREE); + cx.arc(left + radius, bottom - radius, radius, 90 * DEGREE, 180 * DEGREE); + cx.arc(left + radius, top + radius, radius, 180 * DEGREE, 270 * DEGREE); + cx.clip(); +} + +inline uchar shift_color_byte(int b, int shift) { + return (uchar) (b + shift).clamp(0, 255); +} + +public void shift_colors(Gdk.Pixbuf pixbuf, int red, int green, int blue, int alpha) { + assert(red >= -255 && red <= 255); + assert(green >= -255 && green <= 255); + assert(blue >= -255 && blue <= 255); + assert(alpha >= -255 && alpha <= 255); + + int width = pixbuf.get_width(); + int height = pixbuf.get_height(); + int rowstride = pixbuf.get_rowstride(); + int channels = pixbuf.get_n_channels(); + uchar *pixels = pixbuf.get_pixels(); + + assert(channels >= 3); + assert(pixbuf.get_colorspace() == Gdk.Colorspace.RGB); + assert(pixbuf.get_bits_per_sample() == 8); + + for (int y = 0; y < height; y++) { + int y_offset = y * rowstride; + + for (int x = 0; x < width; x++) { + int offset = y_offset + (x * channels); + + if (red != 0) + pixels[offset] = shift_color_byte(pixels[offset], red); + + if (green != 0) + pixels[offset + 1] = shift_color_byte(pixels[offset + 1], green); + + if (blue != 0) + pixels[offset + 2] = shift_color_byte(pixels[offset + 2], blue); + + if (alpha != 0 && channels >= 4) + pixels[offset + 3] = shift_color_byte(pixels[offset + 3], alpha); + } + } +} + +public void dim_pixbuf(Gdk.Pixbuf pixbuf) { + PixelTransformer transformer = new PixelTransformer(); + SaturationTransformation sat = new SaturationTransformation(SaturationTransformation.MIN_PARAMETER); + transformer.attach_transformation(sat); + transformer.transform_pixbuf(pixbuf); + shift_colors(pixbuf, 0, 0, 0, -100); +} + +bool coord_in_rectangle(int x, int y, Gdk.Rectangle rect) { + return (x >= rect.x && x < (rect.x + rect.width) && y >= rect.y && y <= (rect.y + rect.height)); +} + +public bool rectangles_equal(Gdk.Rectangle a, Gdk.Rectangle b) { + return (a.x == b.x) && (a.y == b.y) && (a.width == b.width) && (a.height == b.height); +} + +public string rectangle_to_string(Gdk.Rectangle rect) { + return "%d,%d %dx%d".printf(rect.x, rect.y, rect.width, rect.height); +} + +public Gdk.Rectangle clamp_rectangle(Gdk.Rectangle original, Dimensions max) { + Gdk.Rectangle rect = Gdk.Rectangle(); + rect.x = original.x.clamp(0, max.width); + rect.y = original.y.clamp(0, max.height); + rect.width = original.width.clamp(0, max.width); + rect.height = original.height.clamp(0, max.height); + + return rect; +} + +public Gdk.Point scale_point(Gdk.Point p, double factor) { + Gdk.Point result = {0}; + result.x = (int) (factor * p.x + 0.5); + result.y = (int) (factor * p.y + 0.5); + + return result; +} + +public Gdk.Point add_points(Gdk.Point p1, Gdk.Point p2) { + Gdk.Point result = {0}; + result.x = p1.x + p2.x; + result.y = p1.y + p2.y; + + return result; +} + +public Gdk.Point subtract_points(Gdk.Point p1, Gdk.Point p2) { + Gdk.Point result = {0}; + result.x = p1.x - p2.x; + result.y = p1.y - p2.y; + + return result; +} + +// Converts XRGB/ARGB (Cairo)-formatted pixels to RGBA (GDK). +void fix_cairo_pixbuf(Gdk.Pixbuf pixbuf) { + uchar *gdk_pixels = pixbuf.pixels; + for (int j = 0 ; j < pixbuf.height; ++j) { + uchar *p = gdk_pixels; + uchar *end = p + 4 * pixbuf.width; + + while (p < end) { + uchar tmp = p[0]; +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + p[0] = p[2]; + p[2] = tmp; +#else + p[0] = p[1]; + p[1] = p[2]; + p[2] = p[3]; + p[3] = tmp; +#endif + p += 4; + } + + gdk_pixels += pixbuf.rowstride; + } +} + +/** + * Finds the size of the smallest axially-aligned rectangle that could contain + * a rectangle src_width by src_height, rotated by angle. + * + * @param src_width The width of the incoming rectangle. + * @param src_height The height of the incoming rectangle. + * @param angle The amount to rotate by, given in degrees. + * @param dest_width The width of the computed rectangle. + * @param dest_height The height of the computed rectangle. + */ +void compute_arb_rotated_size(double src_width, double src_height, double angle, + out double dest_width, out double dest_height) { + + angle = Math.fabs(degrees_to_radians(angle)); + assert(angle <= Math.PI_2); + dest_width = src_width * Math.cos(angle) + src_height * Math.sin(angle); + dest_height = src_height * Math.cos(angle) + src_width * Math.sin(angle); +} + +/** + * @brief Rotates a pixbuf to an arbitrary angle, given in degrees, and returns the rotated pixbuf. + * + * @param source_pixbuf The source image that needs to be angled. + * @param angle The angle the source image should be rotated by. + */ +Gdk.Pixbuf rotate_arb(Gdk.Pixbuf source_pixbuf, double angle) { + // if the straightening angle has been reset + // or was never set in the first place, nothing + // needs to be done to the source image. + if (angle == 0.0) { + return source_pixbuf; + } + + // Compute how much the corners of the source image will + // move by to determine how big the dest pixbuf should be. + + double x_tmp, y_tmp; + compute_arb_rotated_size(source_pixbuf.width, source_pixbuf.height, angle, + out x_tmp, out y_tmp); + + Gdk.Pixbuf dest_pixbuf = new Gdk.Pixbuf( + Gdk.Colorspace.RGB, true, 8, (int) Math.round(x_tmp), (int) Math.round(y_tmp)); + + Cairo.ImageSurface surface = new Cairo.ImageSurface.for_data( + (uchar []) dest_pixbuf.pixels, + source_pixbuf.has_alpha ? Cairo.Format.ARGB32 : Cairo.Format.RGB24, + dest_pixbuf.width, dest_pixbuf.height, dest_pixbuf.rowstride); + + Cairo.Context context = new Cairo.Context(surface); + + context.set_source_rgb(0, 0, 0); + context.rectangle(0, 0, dest_pixbuf.width, dest_pixbuf.height); + context.fill(); + + context.translate(dest_pixbuf.width / 2, dest_pixbuf.height / 2); + context.rotate(degrees_to_radians(angle)); + context.translate(- source_pixbuf.width / 2, - source_pixbuf.height / 2); + + Gdk.cairo_set_source_pixbuf(context, source_pixbuf, 0, 0); + context.get_source().set_filter(Cairo.Filter.BEST); + context.paint(); + + // prepare the newly-drawn image for use by + // the rest of the pipeline. + fix_cairo_pixbuf(dest_pixbuf); + + return dest_pixbuf; +} + +/** + * @brief Rotates a point around the upper left corner of an image to an arbitrary angle, + * given in degrees, and returns the rotated point, translated such that it, along with its attendant + * image, are in positive x, positive y. + * + * @note May be subject to slight inaccuracy as Gdk points' coordinates may only be in whole pixels, + * so the fractional component is lost. + * + * @param source_point The point to be rotated and scaled. + * @param img_w The width of the source image (unrotated). + * @param img_h The height of the source image (unrotated). + * @param angle The angle the source image is to be rotated by to straighten it. + */ +Gdk.Point rotate_point_arb(Gdk.Point source_point, int img_w, int img_h, double angle, + bool invert = false) { + // angle of 0 degrees or angle was never set? + if (angle == 0.0) { + // nothing needs to be done. + return source_point; + } + + double dest_width; + double dest_height; + compute_arb_rotated_size(img_w, img_h, angle, out dest_width, out dest_height); + + Cairo.Matrix matrix = Cairo.Matrix.identity(); + matrix.translate(dest_width / 2, dest_height / 2); + matrix.rotate(degrees_to_radians(angle)); + matrix.translate(- img_w / 2, - img_h / 2); + if (invert) + assert(matrix.invert() == Cairo.Status.SUCCESS); + + double dest_x = source_point.x; + double dest_y = source_point.y; + matrix.transform_point(ref dest_x, ref dest_y); + + return { (int) dest_x, (int) dest_y }; +} + +/** + * @brief <u>De</u>rotates a point around the upper left corner of an image from an arbitrary angle, + * given in degrees, and returns the de-rotated point, taking into account any translation necessary + * to make sure all of the rotated image stays in positive x, positive y. + * + * @note May be subject to slight inaccuracy as Gdk points' coordinates may only be in whole pixels, + * so the fractional component is lost. + * + * @param source_point The point to be de-rotated. + * @param img_w The width of the source image (unrotated). + * @param img_h The height of the source image (unrotated). + * @param angle The angle the source image is to be rotated by to straighten it. + */ +Gdk.Point derotate_point_arb(Gdk.Point source_point, int img_w, int img_h, double angle) { + return rotate_point_arb(source_point, img_w, img_h, angle, true); +} + + +// Force an axially-aligned box to be inside a rotated rectangle. +Box clamp_inside_rotated_image(Box src, int img_w, int img_h, double angle_deg, + bool preserve_geom) { + + Gdk.Point top_left = derotate_point_arb({src.left, src.top}, img_w, img_h, angle_deg); + Gdk.Point top_right = derotate_point_arb({src.right, src.top}, img_w, img_h, angle_deg); + Gdk.Point bottom_left = derotate_point_arb({src.left, src.bottom}, img_w, img_h, angle_deg); + Gdk.Point bottom_right = derotate_point_arb({src.right, src.bottom}, img_w, img_h, angle_deg); + + double angle = degrees_to_radians(angle_deg); + int top_offset = 0, bottom_offset = 0, left_offset = 0, right_offset = 0; + + int top = int.min(top_left.y, top_right.y); + if (top < 0) + top_offset = (int) ((0 - top) * Math.cos(angle)); + + int bottom = int.max(bottom_left.y, bottom_right.y); + if (bottom > img_h) + bottom_offset = (int) ((img_h - bottom) * Math.cos(angle)); + + int left = int.min(top_left.x, bottom_left.x); + if (left < 0) + left_offset = (int) ((0 - left) * Math.cos(angle)); + + int right = int.max(top_right.x, bottom_right.x); + if (right > img_w) + right_offset = (int) ((img_w - right) * Math.cos(angle)); + + return preserve_geom ? src.get_offset(left_offset + right_offset, top_offset + bottom_offset) + : Box(src.left + left_offset, src.top + top_offset, + src.right + right_offset, src.bottom + bottom_offset); +} + diff --git a/src/util/misc.vala b/src/util/misc.vala new file mode 100644 index 0000000..73ce428 --- /dev/null +++ b/src/util/misc.vala @@ -0,0 +1,377 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public uint int64_hash(int64? n) { + // Rotating XOR hash + uint8 *u8 = (uint8 *) n; + uint hash = 0; + for (int ctr = 0; ctr < (sizeof(int64) / sizeof(uint8)); ctr++) { + hash = (hash << 4) ^ (hash >> 28) ^ (*u8++); + } + + return hash; +} + +public bool int64_equal(int64? a, int64? b) { + int64 *bia = (int64 *) a; + int64 *bib = (int64 *) b; + + return (*bia) == (*bib); +} + +public int int64_compare(int64? a, int64? b) { + int64 diff = *((int64 *) a) - *((int64 *) b); + if (diff < 0) + return -1; + else if (diff > 0) + return 1; + else + return 0; +} + +public int uint64_compare(uint64? a, uint64? b) { + uint64 a64 = *((uint64 *) a); + uint64 b64 = *((uint64 *) b); + + if (a64 < b64) + return -1; + else if (a64 > b64) + return 1; + else + return 0; +} + +public delegate bool ValueEqualFunc(Value a, Value b); + +public bool bool_value_equals(Value a, Value b) { + return (bool) a == (bool) b; +} + +public bool int_value_equals(Value a, Value b) { + return (int) a == (int) b; +} + +public ulong timeval_to_ms(TimeVal time_val) { + return (((ulong) time_val.tv_sec) * 1000) + (((ulong) time_val.tv_usec) / 1000); +} + +public ulong now_ms() { + return timeval_to_ms(TimeVal()); +} + +public ulong now_sec() { + TimeVal time_val = TimeVal(); + + return time_val.tv_sec; +} + +public inline time_t now_time_t() { + return (time_t) now_sec(); +} + +public string md5_binary(uint8 *buffer, size_t length) { + assert(length != 0); + + Checksum md5 = new Checksum(ChecksumType.MD5); + md5.update((uchar []) buffer, length); + + return md5.get_string(); +} + +public string md5_file(File file) throws Error { + Checksum md5 = new Checksum(ChecksumType.MD5); + uint8[] buffer = new uint8[64 * 1024]; + + FileInputStream fins = file.read(null); + for (;;) { + size_t bytes_read = fins.read(buffer, null); + if (bytes_read <= 0) + break; + + md5.update((uchar[]) buffer, bytes_read); + } + + try { + fins.close(null); + } catch (Error err) { + warning("Unable to close MD5 input stream for %s: %s", file.get_path(), err.message); + } + + return md5.get_string(); +} + +// Once generic functions are available in Vala, this could be genericized. +public bool equal_sets(Gee.Set<string>? a, Gee.Set<string>? b) { + if ((a != null && a.size == 0) && (b == null)) + return true; + + if ((a == null) && (b != null && b.size == 0)) + return true; + + if ((a == null && b != null) || (a != null && b == null)) + return false; + + if (a == null && b == null) + return true; + + if (a.size != b.size) + return false; + + // because they're sets and the same size, only need to iterate over one set to know + // it is equal to the other + foreach (string element in a) { + if (!b.contains(element)) + return false; + } + + return true; +} + +// Once generic functions are available in Vala, this could be genericized. +public Gee.Set<string>? intersection_of_sets(Gee.Set<string>? a, Gee.Set<string>? b, + Gee.Set<string>? excluded) { + if (a != null && b == null) { + if (excluded != null) + excluded.add_all(a); + + return null; + } + + if (a == null && b != null) { + if (excluded != null) + excluded.add_all(b); + + return null; + } + + Gee.Set<string> intersection = new Gee.HashSet<string>(); + + foreach (string element in a) { + if (b.contains(element)) + intersection.add(element); + else if (excluded != null) + excluded.add(element); + } + + foreach (string element in b) { + if (a.contains(element)) + intersection.add(element); + else if (excluded != null) + excluded.add(element); + } + + return intersection.size > 0 ? intersection : null; +} + +public uchar[] serialize_photo_ids(Gee.Collection<Photo> photos) { + int64[] ids = new int64[photos.size]; + int ctr = 0; + foreach (Photo photo in photos) + ids[ctr++] = photo.get_photo_id().id; + + size_t bytes = photos.size * sizeof(int64); + uchar[] serialized = new uchar[bytes]; + Memory.copy(serialized, ids, bytes); + + return serialized; +} + +public Gee.List<PhotoID?>? unserialize_photo_ids(uchar* serialized, int size) { + size_t count = (size / sizeof(int64)); + if (count <= 0 || serialized == null) + return null; + + int64[] ids = new int64[count]; + Memory.copy(ids, serialized, size); + + Gee.ArrayList<PhotoID?> list = new Gee.ArrayList<PhotoID?>(); + foreach (int64 id in ids) + list.add(PhotoID(id)); + + return list; +} + +public uchar[] serialize_media_sources(Gee.Collection<MediaSource> media) { + Gdk.Atom[] atoms = new Gdk.Atom[media.size]; + int ctr = 0; + foreach (MediaSource current_media in media) + atoms[ctr++] = Gdk.Atom.intern(current_media.get_source_id(), false); + + size_t bytes = media.size * sizeof(Gdk.Atom); + uchar[] serialized = new uchar[bytes]; + Memory.copy(serialized, atoms, bytes); + + return serialized; +} + +public Gee.List<MediaSource>? unserialize_media_sources(uchar* serialized, int size) { + size_t count = (size / sizeof(Gdk.Atom)); + if (count <= 0 || serialized == null) + return null; + + Gdk.Atom[] atoms = new Gdk.Atom[count]; + Memory.copy(atoms, serialized, size); + + Gee.ArrayList<MediaSource> list = new Gee.ArrayList<MediaSource>(); + foreach (Gdk.Atom current_atom in atoms) { + MediaSource media = MediaCollectionRegistry.get_instance().fetch_media(current_atom.name()); + assert(media != null); + list.add(media); + } + + return list; +} + +public string format_local_datespan(Time from_date, Time to_date) { + string from_format, to_format; + + // Ticket #3240 - Change the way date ranges are pretty- + // printed if the start and end date occur on consecutive days. + if (from_date.year == to_date.year) { + // are these consecutive dates? + if ((from_date.month == to_date.month) && (from_date.day == (to_date.day - 1))) { + // Yes; display like so: Sat, July 4 - 5, 20X6 + from_format = Resources.get_start_multiday_span_format_string(); + to_format = Resources.get_end_multiday_span_format_string(); + } else { + // No, but they're in the same year; display in shortened + // form: Sat, July 4 - Mon, July 6, 20X6 + from_format = Resources.get_start_multimonth_span_format_string(); + to_format = Resources.get_end_multimonth_span_format_string(); + } + } else { + // Span crosses a year boundary, use long form dates + // for both start and end date. + from_format = Resources.get_long_date_format_string(); + to_format = Resources.get_long_date_format_string(); + } + + return String.strip_leading_zeroes("%s - %s".printf(from_date.format(from_format), + to_date.format(to_format))); +} + +public string format_local_date(Time date) { + return String.strip_leading_zeroes(date.format(Resources.get_long_date_format_string())); +} + +public delegate void OneShotCallback(); + +public class OneShotScheduler { + private string name; + private unowned OneShotCallback callback; + private uint scheduled = 0; + + public OneShotScheduler(string name, OneShotCallback callback) { + this.name = name; + this.callback = callback; + } + + ~OneShotScheduler() { +#if TRACE_DTORS + debug("DTOR: OneShotScheduler for %s", name); +#endif + + cancel(); + } + + public bool is_scheduled() { + return scheduled != 0; + } + + public void at_idle() { + at_priority_idle(Priority.DEFAULT_IDLE); + } + + public void at_priority_idle(int priority) { + if (scheduled == 0) + scheduled = Idle.add_full(priority, callback_wrapper); + } + + public void after_timeout(uint msec, bool reschedule) { + priority_after_timeout(Priority.DEFAULT, msec, reschedule); + } + + public void priority_after_timeout(int priority, uint msec, bool reschedule) { + if (scheduled != 0 && !reschedule) + return; + + if (scheduled != 0) + Source.remove(scheduled); + + scheduled = Timeout.add_full(priority, msec, callback_wrapper); + } + + public void cancel() { + if (scheduled == 0) + return; + + Source.remove(scheduled); + scheduled = 0; + } + + private bool callback_wrapper() { + scheduled = 0; + callback(); + + return false; + } +} + +public class OpTimer { + private string name; + private Timer timer = new Timer(); + private long count = 0; + private double elapsed = 0; + private double shortest = double.MAX; + private double longest = double.MIN; + + public OpTimer(string name) { + this.name = name; + } + + public void start() { + timer.start(); + } + + public void stop() { + double time = timer.elapsed(); + + elapsed += time; + + if (time < shortest) + shortest = time; + + if (time > longest) + longest = time; + + count++; + } + + public string to_string() { + if (count > 0) { + return "%s: count=%ld elapsed=%.03lfs min/avg/max=%.03lf/%.03lf/%.03lf".printf(name, + count, elapsed, shortest, elapsed / (double) count, longest); + } else { + return "%s: no operations".printf(name); + } + } +} + +// Dummy function for suppressing 'could not stat file' errors +// generated when saving into a previously non-existent file - +// please see https://bugzilla.gnome.org/show_bug.cgi?id=662814 +// and to work around a spurious warning given by GDK when a +// key press event is passed from a child class' event handler +// to a parent's; (gnome bug pending, but see https://bugzilla.redhat.com/show_bug.cgi?id=665568). +public void suppress_warnings(string? log_domain, LogLevelFlags log_levels, string message) { + // do nothing. +} + +public bool is_twentyfour_hr_time_system() { + // if no AM/PM designation is found, the location is set to use a 24 hr time system + return is_string_empty(Time.local(0).format("%p")); +} + diff --git a/src/util/mk/util.mk b/src/util/mk/util.mk new file mode 100644 index 0000000..86a8bd5 --- /dev/null +++ b/src/util/mk/util.mk @@ -0,0 +1,34 @@ + +# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with +# a init() and terminate() function declared in the namespace. +UNIT_NAME := Util + +# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all +# lowercase. The name of this file should be UNIT_DIR.mk. +UNIT_DIR := util + +# All Vala files in the unit should be listed here with no subdirectory prefix. +# +# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala. +UNIT_FILES := \ + file.vala \ + image.vala \ + misc.vala \ + string.vala \ + system.vala \ + KeyValueMap.vala \ + ui.vala + +# Any unit this unit relies upon (and should be initialized before it's initialized) should +# be listed here using its Vala namespace. +# +# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here. +UNIT_USES := + +# List any additional files that are used in the build process as a part of this unit that should +# be packaged in the tarball. File names should be relative to the unit's home directory. +UNIT_RC := + +# unitize.mk must be called at the end of each UNIT_DIR.mk file. +include unitize.mk + diff --git a/src/util/string.vala b/src/util/string.vala new file mode 100644 index 0000000..9fda007 --- /dev/null +++ b/src/util/string.vala @@ -0,0 +1,268 @@ +/* Copyright 2010-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +extern int64 g_ascii_strtoll(string str, out char *endptr, uint num_base); + +public const int DEFAULT_USER_TEXT_INPUT_LENGTH = 1024; + +public inline bool is_string_empty(string? s) { + return (s == null || s[0] == '\0'); +} + +// utf8 case sensitive compare +public int utf8_cs_compare(void *a, void *b) { + return ((string) a).collate((string) b); +} + +// utf8 case insensitive compare +public int utf8_ci_compare(void *a, void *b) { + return ((string) a).down().collate(((string) b).down()); +} + +// utf8 array to string +public string uchar_array_to_string(uchar[] data, int length = -1) { + if (length < 0) + length = data.length; + + StringBuilder builder = new StringBuilder(); + for (int ctr = 0; ctr < length; ctr++) { + if (data[ctr] != '\0') + builder.append_c((char) data[ctr]); + else + break; + } + + return builder.str; +} + +// string to uchar array +public uchar[] string_to_uchar_array(string str) { + uchar[] data = new uchar[0]; + for (int ctr = 0; ctr < str.length; ctr++) + data += (uchar) str[ctr]; + + return data; +} + +// Markup.escape_text() will crash if the UTF-8 text is not valid; it relies on a call to +// g_utf8_next_char(), which demands that the string be validated before use, which escape_text() +// does not do. This handles this problem by kicking back an empty string if the text is not +// valid. Text should be validated upon entry to the system as well to guard against this +// problem. +// +// Null strings are accepted; they will result in an empty string returned. +public inline string guarded_markup_escape_text(string? plain) { + return (!is_string_empty(plain) && plain.validate()) ? Markup.escape_text(plain) : ""; +} + +public long find_last_offset(string str, char c) { + long offset = str.length; + while (--offset >= 0) { + if (str[offset] == c) + return offset; + } + + return -1; +} + +// Helper function for searching an array of case-insensitive strings. The array should be +// all lowercase. +public bool is_in_ci_array(string str, string[] strings) { + string strdown = str.down(); + foreach (string str_element in strings) { + if (strdown == str_element) + return true; + } + + return false; +} + +[Flags] +public enum PrepareInputTextOptions { + EMPTY_IS_NULL, + VALIDATE, + INVALID_IS_NULL, + STRIP, + STRIP_CRLF, + NORMALIZE, + DEFAULT = EMPTY_IS_NULL | VALIDATE | INVALID_IS_NULL | STRIP_CRLF | STRIP | NORMALIZE; +} + +public string? prepare_input_text(string? text, PrepareInputTextOptions options, int dest_length) { + if (text == null) + return null; + + if ((options & PrepareInputTextOptions.VALIDATE) != 0 && !text.validate()) + return (options & PrepareInputTextOptions.INVALID_IS_NULL) != 0 ? null : ""; + + string prepped = text; + + // Using composed form rather than GLib's default (decomposed) as NFC is the preferred form in + // Linux and WWW. More importantly, Pango seems to have serious problems displaying decomposed + // forms of Korean language glyphs (and perhaps others). See: + // http://trac.yorba.org/ticket/2952 + if ((options & PrepareInputTextOptions.NORMALIZE) != 0) + prepped = prepped.normalize(-1, NormalizeMode.NFC); + + if ((options & PrepareInputTextOptions.STRIP) != 0) + prepped = prepped.strip(); + + // Ticket #3245 - Prevent carriage return mayhem + // in image titles, tag names, etc. + if ((options & PrepareInputTextOptions.STRIP_CRLF) != 0) + prepped = prepped.delimit("\n\r", ' '); + + if ((options & PrepareInputTextOptions.EMPTY_IS_NULL) != 0 && is_string_empty(prepped)) + return null; + + // Ticket #3196 - Allow calling functions to limit the length of the + // string we return to them. Passing any negative value is interpreted + // as 'do not truncate'. + if (dest_length >= 0) { + StringBuilder sb = new StringBuilder(prepped); + sb.truncate(dest_length); + return sb.str; + } + + // otherwise, return normally. + return prepped; +} + +public int64 parse_int64(string str, int num_base) { + return g_ascii_strtoll(str, null, num_base); +} + +namespace String { + +public inline bool contains_char(string haystack, unichar needle) { + return haystack.index_of_char(needle) >= 0; +} + +public inline bool contains_str(string haystack, string needle) { + return haystack.index_of(needle) >= 0; +} + +public inline string? sliced_at(string str, int index) { + return (index >= 0) ? str[index:str.length] : null; +} + +public inline string? sliced_at_first_str(string haystack, string needle, int start_index = 0) { + return sliced_at(haystack, haystack.index_of(needle, start_index)); +} + +public inline string? sliced_at_last_str(string haystack, string needle, int start_index = 0) { + return sliced_at(haystack, haystack.last_index_of(needle, start_index)); +} + +public inline string? sliced_at_first_char(string haystack, unichar ch, int start_index = 0) { + return sliced_at(haystack, haystack.index_of_char(ch, start_index)); +} + +public inline string? sliced_at_last_char(string haystack, unichar ch, int start_index = 0) { + return sliced_at(haystack, haystack.last_index_of_char(ch, start_index)); +} + +// Note that this method currently turns a word of all zeros into empty space ("000" -> "") +public string strip_leading_zeroes(string str) { + StringBuilder stripped = new StringBuilder(); + bool prev_is_space = true; + for (unowned string iter = str; iter.get_char() != 0; iter = iter.next_char()) { + unichar ch = iter.get_char(); + + if (!prev_is_space || ch != '0') { + stripped.append_unichar(ch); + prev_is_space = ch.isspace(); + } + } + + return stripped.str; +} + +public string remove_diacritics(string istring) { + var builder = new StringBuilder (); + unichar ch; + int i = 0; + while(istring.normalize().get_next_char(ref i, out ch)) { + switch(ch.type()) { + case UnicodeType.CONTROL: + case UnicodeType.FORMAT: + case UnicodeType.UNASSIGNED: + case UnicodeType.NON_SPACING_MARK: + case UnicodeType.COMBINING_MARK: + case UnicodeType.ENCLOSING_MARK: + // Ignore those + continue; + } + builder.append_unichar(ch); + } + return builder.str; +} + +public string to_hex_string(string str) { + StringBuilder builder = new StringBuilder(); + + uint8 *data = (uint8 *) str; + while (*data != 0) + builder.append_printf("%02Xh%s", *data++, (*data != 0) ? " " : ""); + + return builder.str; +} + +// A note on the collated_* and precollated_* methods: +// +// A bug report (http://trac.yorba.org/ticket/3152) indicated that two different Hirigana characters +// as Tag names would trigger an assertion. Investigation showed that the characters' collation +// keys computed as equal when the locale was set to anything but the default locale (C) or +// Japanese. A related bug was that another hash table was using str_equal, which does not use +// collation, meaning that in one table the strings were seen as the same and in another as +// different. +// +// The solution we arrived at is to use collation whenever possible, but if two strings have the +// same collation, then fall back on strcmp(), which looks for byte-for-byte comparisons. Note +// that this technique requires that both strings have been properly composed (use +// prepare_input_text() for that task) so that equal UTF-8 strings are byte-for-byte equal as +// well. + +// See note above. +public uint collated_hash(void *ptr) { + string str = (string) ptr; + + return str_hash(str.collate_key()); +} + +// See note above. +public uint precollated_hash(void *ptr) { + return str_hash((string) ptr); +} + +// See note above. +public int collated_compare(void *a, void *b) { + string astr = (string) a; + string bstr = (string) b; + + int result = astr.collate(bstr); + + return (result != 0) ? result : strcmp(astr, bstr); +} + +// See note above. +public int precollated_compare(string astr, string akey, string bstr, string bkey) { + int result = strcmp(akey, bkey); + + return (result != 0) ? result : strcmp(astr, bstr); +} + +// See note above. +public bool collated_equals(void *a, void *b) { + return collated_compare(a, b) == 0; +} + +// See note above. +public bool precollated_equals(string astr, string akey, string bstr, string bkey) { + return precollated_compare(astr, akey, bstr, bkey) == 0; +} + +} diff --git a/src/util/system.vala b/src/util/system.vala new file mode 100644 index 0000000..9407405 --- /dev/null +++ b/src/util/system.vala @@ -0,0 +1,40 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +int number_of_processors() { + int n = (int) ExtendedPosix.sysconf(ExtendedPosix.ConfName._SC_NPROCESSORS_ONLN); + return n <= 0 ? 1 : n; +} + +// Return the directory in which Shotwell is installed, or null if uninstalled. +File? get_sys_install_dir(File exec_dir) { + // guard against exec_dir being a symlink + File exec_dir1 = exec_dir; + try { + exec_dir1 = File.new_for_path( + FileUtils.read_link("/" + FileUtils.read_link(exec_dir.get_path()))); + } catch (FileError e) { + // exec_dir is not a symlink + } + File prefix_dir = File.new_for_path(Resources.PREFIX); + return exec_dir1.has_prefix(prefix_dir) ? prefix_dir : null; +} + +string get_nautilus_install_location() { + return Environment.find_program_in_path("nautilus"); +} + +void sys_show_uri(Gdk.Screen screen, string uri) throws Error { + Gtk.show_uri(screen, uri, Gdk.CURRENT_TIME); +} + +void show_file_in_nautilus(string filename) throws Error { + GLib.Process.spawn_command_line_async(get_nautilus_install_location() + " " + filename); +} + +int posix_wexitstatus(int status) { + return (((status) & 0xff00) >> 8); +} diff --git a/src/util/ui.vala b/src/util/ui.vala new file mode 100644 index 0000000..9f66de8 --- /dev/null +++ b/src/util/ui.vala @@ -0,0 +1,98 @@ +/* Copyright 2011-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public enum AdjustmentRelation { + BELOW, + IN_RANGE, + ABOVE +} + +public enum CompassPoint { + NORTH, + SOUTH, + EAST, + WEST +} + +public enum Direction { + FORWARD, + BACKWARD; + + public Spit.Transitions.Direction to_transition_direction() { + switch (this) { + case FORWARD: + return Spit.Transitions.Direction.FORWARD; + + case BACKWARD: + return Spit.Transitions.Direction.BACKWARD; + + default: + error("Unknown Direction %s", this.to_string()); + } + } +} + +public void spin_event_loop() { + while (Gtk.events_pending()) + Gtk.main_iteration(); +} + +public AdjustmentRelation get_adjustment_relation(Gtk.Adjustment adjustment, int value) { + if (value < (int) adjustment.get_value()) + return AdjustmentRelation.BELOW; + else if (value > (int) (adjustment.get_value() + adjustment.get_page_size())) + return AdjustmentRelation.ABOVE; + else + return AdjustmentRelation.IN_RANGE; +} + +public Gdk.Rectangle get_adjustment_page(Gtk.Adjustment hadj, Gtk.Adjustment vadj) { + Gdk.Rectangle rect = Gdk.Rectangle(); + rect.x = (int) hadj.get_value(); + rect.y = (int) vadj.get_value(); + rect.width = (int) hadj.get_page_size(); + rect.height = (int) vadj.get_page_size(); + + return rect; +} + +// Verifies that only the mask bits are set in the modifier field, disregarding mouse and +// key modifers that are not normally of concern (i.e. Num Lock, Caps Lock, etc.). Mask can be +// one or more bits set, but should only consist of these values: +// * Gdk.ModifierType.SHIFT_MASK +// * Gdk.ModifierType.CONTROL_MASK +// * Gdk.ModifierType.MOD1_MASK (Alt) +// * Gdk.ModifierType.MOD3_MASK +// * Gdk.ModifierType.MOD4_MASK +// * Gdk.ModifierType.MOD5_MASK +// * Gdk.ModifierType.SUPER_MASK +// * Gdk.ModifierType.HYPER_MASK +// * Gdk.ModifierType.META_MASK +// +// (Note: MOD2 seems to be Num Lock in GDK.) +public bool has_only_key_modifier(Gdk.ModifierType field, Gdk.ModifierType mask) { + return (field + & (Gdk.ModifierType.SHIFT_MASK + | Gdk.ModifierType.CONTROL_MASK + | Gdk.ModifierType.MOD1_MASK + | Gdk.ModifierType.MOD3_MASK + | Gdk.ModifierType.MOD4_MASK + | Gdk.ModifierType.MOD5_MASK + | Gdk.ModifierType.SUPER_MASK + | Gdk.ModifierType.HYPER_MASK + | Gdk.ModifierType.META_MASK)) == mask; +} + +public string build_dummy_ui_string(Gtk.ActionGroup[] groups) { + string ui_string = "<ui>"; + foreach (Gtk.ActionGroup group in groups) { + foreach (Gtk.Action action in group.list_actions()) + ui_string += "<accelerator name=\"%s\" action=\"%s\" />".printf(action.name, action.name); + } + ui_string += "</ui>"; + + return ui_string; +} |