From 5567e7b13facc97023134e932ddcd8aa8ec99f68 Mon Sep 17 00:00:00 2001 From: Chris Eppstein Date: Tue, 30 Nov 2010 16:55:53 -0800 Subject: [PATCH] Change the sprite utilities to create a sprite literal object that can retain information about the sprite for use in other function calls. This avoids the need for global storage and allows multiple sprites constructed from the same sprite folder with different values. --- .../compass/utilities/sprites/_base.scss | 29 ++ .../sass_extensions/functions/sprites.rb | 322 +++++++++++++----- lib/compass/sprites.rb | 89 ++--- spec/sprites_spec.rb | 120 +++---- 4 files changed, 330 insertions(+), 230 deletions(-) create mode 100644 frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss diff --git a/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss b/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss new file mode 100644 index 00000000..42bf3b16 --- /dev/null +++ b/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss @@ -0,0 +1,29 @@ +// Set the width and height of an element to the original +// dimensions of an image before it was included in the sprite. +@mixin sprite-dimensions($sprite, $name) { + height: image-height(sprite-file($sprite, $name)); + width: image-width(sprite-file($sprite, $name)); +} + +// Set the background position of the given `$sprite` to display the +// image of the given `$name`. You can move the image relative to its +// natural position by passing `$offset-x` and `$offset-y`. +@mixin sprite-position($sprite, $name, $offset-x: 0, $offset-y: 0) { + background-position: sprite-position($sprite, $name, $offset-x, $offset-y); +} + +@mixin sprite($sprite, $name, $dimensions: false, $offset-x: 0, $offset-y: 0) { + @include sprite-position($sprite, $name, $offset-x, $offset-y); + @if $dimensions { + @include sprite-dimensions($sprite, $name); + } +} + +@mixin sprites($sprite, $sprite-names, $base-class: false, $dimensions: false) { + @each $sprite-name in $sprite-names { + .#{sprite-name($sprite)}-#{$sprite-name} { + @if $base-class { @extend #{$base-class}; } + @include sprite($sprite, $sprite-name, $dimensions); + } + } +} \ No newline at end of file diff --git a/lib/compass/sass_extensions/functions/sprites.rb b/lib/compass/sass_extensions/functions/sprites.rb index 41a7e619..7b1b6d65 100644 --- a/lib/compass/sass_extensions/functions/sprites.rb +++ b/lib/compass/sass_extensions/functions/sprites.rb @@ -1,7 +1,7 @@ require 'chunky_png' module Compass::SassExtensions::Functions::Sprites - SASS_NULL = Sass::Script::Number::new(0) + ZERO = Sass::Script::Number::new(0) # Provides a consistent interface for getting a variable in ruby # from a keyword argument hash that accounts for underscores/dash equivalence @@ -12,111 +12,253 @@ module Compass::SassExtensions::Functions::Sprites end end - def generate_sprite_image(uri, kwargs = {}) - kwargs.extend VariableReader - path, name = Compass::Sprites.path_and_name(uri.value) - last_spacing = 0 - width = 0 - height = 0 + class Sprite < Sass::Script::Literal - # Get image metadata - Compass::Sprites.discover_sprites(uri.value).each do |file| - Compass::Sprites.compute_image_metadata! file, path, name + attr_accessor :image_names, :path, :name, :options + attr_accessor :images, :width, :height + + def self.from_uri(uri, context, kwargs) + path, name = Compass::Sprites.path_and_name(uri.value) + new(Compass::Sprites.discover_sprites(uri.value), path, name, context, kwargs) end - images = Compass::Sprites.sprites(path, name) - - # Calculation - images.each do |image| - current_spacing = number_from_var(kwargs, "#{image[:name]}-spacing", 0) - if height > 0 - height += [current_spacing, last_spacing].max - end - image[:y] = height - height += image[:height] - last_spacing = current_spacing - width = image[:width] if image[:width] > width + def initialize(image_names, path, name, context, options) + @image_names, @path, @name, @options = image_names, path, name, options + @images = nil + @width = nil + @height = nil + @evaluation_context = context + validate! + compute_image_metadata! end - - # Generation - output_png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT) - images.each do |image| - input_png = ChunkyPNG::Image.from_file(image[:file]) - - position = kwargs.get_var("#{image[:name]}-position") || Sass::Script::Number.new(0, ["%"]) - if position.unit_str == "%" - image[:x] = (width - image[:width]) * (position.value / 100) - else - image[:x] = position.value + + def sprite_names + image_names.map{|f| Compass::Sprites.sprite_name(f) } + end + + def validate! + for sprite_name in sprite_names + unless sprite_name =~ /\A#{Sass::SCSS::RX::IDENT}\Z/ + raise Sass::SyntaxError, "#{sprite_name} must be a legal css identifier" + end end - - repeat = if (var = kwargs.get_var("#{image[:name]}-repeat")) + end + + # Calculates the overal image dimensions + # collects image sizes and input parameters for each sprite + def compute_image_metadata! + @images = [] + @width = 0 + image_names.each do |file| + relative_file = file.gsub(Compass.configuration.images_path+"/", "") + width, height = Compass::SassExtensions::Functions::ImageSize::ImageProperties.new(file).size + sprite_name = Compass::Sprites.sprite_name(relative_file) + @width = [@width, width].max + @images << { + :name => sprite_name, + :file => file, + :relative_file => relative_file, + :height => height, + :width => width, + :repeat => repeat_for(sprite_name), + :spacing => spacing_for(sprite_name), + :position => position_for(sprite_name) + } + end + @images.each_with_index do |image, index| + if index == 0 + image[:top] = 0 + else + last_image = @images[index-1] + image[:top] = last_image[:top] + last_image[:height] + [image[:spacing], last_image[:spacing]].max + end + if image[:position].unit_str == "%" + image[:left] = (@width - image[:width]) * (image[:position].value / 100) + else + image[:left] = image[:position].value + end + end + @height = @images.last[:top] + @images.last[:height] + end + + def position_for(name) + options.get_var("#{name}-position") || options.get_var("position") || Sass::Script::Number.new(0, ["px"]) + end + + def repeat_for(name) + if (var = options.get_var("#{name}-repeat")) + var.value + elsif (var = options.get_var("repeat")) var.value else "no-repeat" end - if repeat == "no-repeat" - output_png.replace input_png, image[:x], image[:y] - else - x = image[:x] - (image[:x] / image[:width]).ceil * image[:width] - while x < width do - output_png.replace input_png, x, image[:y] - x += image[:width] - end + end + + def spacing_for(name) + (options.get_var("#{name}-spacing") || + options.get_var("spacing") || + ZERO).value + end + + def image_for(name) + @images.detect{|img| img[:name] == name} + end + + # Calculate the size of the sprite + def size + [width, height] + end + + # Generate a sprite image if necessary + def generate + if generation_required? + save!(construct_sprite) end end - output_png.save File.join(File.join(Compass.configuration.images_path, "#{path}.png")) - - sprite_url(uri) - end - Sass::Script::Functions.declare :generate_sprite_image, [:uri], :var_kwargs => true - - def sprite_image(uri, x_shift = SASS_NULL, y_shift = SASS_NULL, depricated_1 = nil, depricated_2 = nil) - check_spacing_deprecation uri, depricated_1, depricated_2 - url = sprite_url(uri) - position = sprite_position(uri, x_shift, y_shift) - Sass::Script::String.new("#{url} #{position}") - end - - def sprite_url(uri) - path, name = Compass::Sprites.path_and_name(uri.value) - image_url(Sass::Script::String.new("#{path}.png")) + + def generation_required? + !File.exists?(filename) || outdated? + end + + # Returns a PNG object + def construct_sprite + output_png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT) + images.each do |image| + input_png = ChunkyPNG::Image.from_file(image[:file]) + if image[:repeat] == "no-repeat" + output_png.replace input_png, image[:left], image[:top] + else + x = image[:left] - (image[:left] / image[:width]).ceil * image[:width] + while x < width do + output_png.replace input_png, x, image[:top] + x += image[:width] + end + end + end + output_png + end + + # The on-the-disk filename of the sprite + def filename + File.join(File.join(Compass.configuration.images_path, "#{path}.png")) + end + + # saves the sprite for later retrieval + def save!(output_png) + output_png.save filename + end + + # All the full-path filenames involved in this sprite + def image_filenames + image_names.map do |image_name| + File.join(File.join(Compass.configuration.images_path, image_name)) + end + end + + # Checks whether this sprite is outdated + def outdated? + last_update = self.mtime + image_filenames.each do |image| + return true if File.mtime(image) > last_update + end + false + end + + def mtime + File.mtime(filename) + end + + def inspect + to_s + end + + def to_s(options = self.options) + sprite_url(self).value + end + + def method_missing(meth, *args, &block) + if @evaluation_context.respond_to?(meth) + @evaluation_context.send(meth, *args, &block) + else + super + end + end + end - def sprite_position(uri, x_shift = SASS_NULL, y_shift = SASS_NULL, depricated_1 = nil, depricated_2 = nil) - check_spacing_deprecation uri, depricated_1, depricated_2 - path, name = Compass::Sprites.path_and_name(uri.value) - image_name = File.basename(uri.value, '.png') - image = Compass::Sprites.sprites(path, name).detect{ |image| image[:name] == image_name } + def sprite(uri, kwargs = {}) + kwargs.extend VariableReader + Sprite.from_uri(uri, self, kwargs) + end + Sass::Script::Functions.declare :sprite, [:uri], :var_kwargs => true + + def sprite_image(sprite, image = nil, x_shift = ZERO, y_shift = ZERO) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-image") + end + unless image && image.is_a?(Sass::Script::String) + raise Sass::SyntaxError, %Q(The second argument to sprite-image must be a sprite name. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + url = sprite_url(sprite) + position = sprite_position(sprite, image, x_shift, y_shift) + Sass::Script::List.new([url, position], :space) + end + + def sprite_name(sprite) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-name") + end + Sass::Script::String.new(sprite.name) + end + + def sprite_file(sprite, image_name) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-file") + end + if image = sprite.image_for(image_name.value) + Sass::Script::String.new(image[:relative_file]) + else + missing_image!(sprite, image_name) + end + end + + def sprite_url(sprite) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-url") + end + sprite.generate + image_url(Sass::Script::String.new("#{sprite.path}.png")) + end + + def missing_image!(sprite, image_name) + raise Sass::SyntaxError, "No image called #{image_name} found in sprite #{sprite.path}/#{sprite.name}. Did you mean one of: #{sprite.sprite_names.join(", ")}" + end + + def missing_sprite!(function_name) + raise Sass::SyntaxError, %Q(The first argument to #{function_name} must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + + def sprite_position(sprite, image_name = nil, x_shift = ZERO, y_shift = ZERO) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-position") + end + unless image_name && image_name.is_a?(Sass::Script::String) + raise Sass::SyntaxError, %Q(The second argument to sprite-image must be a sprite name. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + image = sprite.image_for(image_name.value) + unless image + missing_image!(sprite, image_name) + end if x_shift.unit_str == "%" - x = x_shift.to_s + x = x_shift # CE: Shouldn't this be a percentage of the total width? else - x = x_shift.value - image[:x] - x = "#{x}px" unless x == 0 + x = x_shift.value - image[:left] + x = Sass::Script::Number.new(x, x == 0 ? [] : ["px"]) end - y = y_shift.value - image[:y] - y = "#{y}px" unless y == 0 - Sass::Script::String.new("#{x} #{y}") + y = y_shift.value - image[:top] + y = Sass::Script::Number.new(y, y == 0 ? [] : ["px"]) + Sass::Script::List.new([x, y],:space) end -private - - def number_from_var(kwargs, var_name, default_value) - if number = kwargs.get_var(var_name) - assert_type number, :Number - number.value - else - default_value - end - end - - def check_spacing_deprecation(uri, spacing_before, spacing_after) - if spacing_before or spacing_after - path, name, image_name = Compass::Sprites.path_and_name(uri.value) - message = %Q(Spacing parameter is deprecated. ) + - %Q(Please add `$#{name}-#{image_name}-spacing: #{spacing_before};` ) + - %Q(before the `@import "#{path}/*.png";` statement.) - raise Compass::Error, message - end - end end diff --git a/lib/compass/sprites.rb b/lib/compass/sprites.rb index 28224a70..ed92a1a3 100644 --- a/lib/compass/sprites.rb +++ b/lib/compass/sprites.rb @@ -4,66 +4,41 @@ module Compass attr_accessor :path class << self - def reset - @@sprites = {} - end - def path_and_name(uri) if uri =~ %r{((.+/)?(.+))/(.+?)\.png} [$1, $3, $4] end end - def sprites(path, name, create = false) - if !defined?(@@sprites) || @@sprites.nil? - @@sprites = {} - end - index = "#{path}/#{name}" - images = @@sprites[index] - if images - images - elsif create - images = @@sprites[index] = [] - else - raise Compass::Error, %Q(`@import` statement missing. Please add `@import "#{path}/*.png";`.) - end - end - def discover_sprites(uri) glob = File.join(Compass.configuration.images_path, uri) Dir.glob(glob).sort end - def compute_image_metadata!(file, path, name) - width, height = Compass::SassExtensions::Functions::ImageSize::ImageProperties.new(file).size - sprite_name = File.basename(file, '.png'); - unless sprite_name =~ /\A#{Sass::SCSS::RX::IDENT}\Z/ - raise Sass::SyntaxError, "#{sprite_name} must be a legal css identifier" - end - sprites(path, name, true) << { - :name => sprite_name, - :file => file, - :height => height, - :width => width - } + def sprite_name(file) + File.basename(file, '.png') end + end - def images - Compass::Sprites.sprites(self.path, self.name, true) + def find_relative(*args) + nil end def find(uri, options) if uri =~ /\.png$/ self.path, self.name = Compass::Sprites.path_and_name(uri) options.merge! :filename => name, :syntax => :scss, :importer => self - image_names = Compass::Sprites.discover_sprites(uri).map{|i| File.basename(i, '.png')} + sprite_files = Compass::Sprites.discover_sprites(uri) + image_names = sprite_files.map {|i| Compass::Sprites.sprite_name(i) } Sass::Engine.new(content_for_images(uri, name, image_names), options) end end def content_for_images(uri, name, images) <<-SCSS +@import "compass/utilities/sprites/base"; + // General Sprite Defaults // You can override them before you import this file. $#{name}-sprite-base-class: ".#{name}-sprite" !default; @@ -82,50 +57,46 @@ $#{name}-#{sprite_name}-repeat: $#{name}-repeat !default; SCSS end.join} +$#{name}-sprite: sprite("#{uri}", +#{images.map do |sprite_name| +%Q{ $#{sprite_name}-position: $#{name}-#{sprite_name}-position, + $#{sprite_name}-spacing: $#{name}-#{sprite_name}-spacing, + $#{sprite_name}-repeat: $#{name}-#{sprite_name}-repeat} +end.join(",\n")}); + // All sprites should extend this class // The #{name}-sprite mixin will do so for you. \#{$#{name}-sprite-base-class} { - background: generate-sprite-image("#{uri}", -#{images.map do |sprite_name| -%Q{ $#{sprite_name}-position: $#{name}-#{sprite_name}-position, - $#{sprite_name}-spacing: $#{name}-#{sprite_name}-spacing, - $#{sprite_name}-repeat: $#{name}-#{sprite_name}-repeat} -end.join(",\n")}) no-repeat; + background: $#{name}-sprite no-repeat; } // Use this to set the dimensions of an element // based on the size of the original image. -@mixin #{name}-sprite-dimensions($sprite) { - height: image-height("#{name}/\#{$sprite}.png"); - width: image-width("#{name}/\#{$sprite}.png"); +@mixin #{name}-sprite-dimensions($name) { + @include sprite-dimensions($#{name}-sprite, $name) } // Move the background position to display the sprite. -@mixin #{name}-sprite-position($sprite, $x: 0, $y: 0) { - background-position: sprite-position("#{path}/\#{$sprite}.png", $x, $y); +@mixin #{name}-sprite-position($name, $offset-x: 0, $offset-y: 0) { + @include sprite-position($#{name}-sprite, $name, $offset-x, $offset-y) } // Extends the sprite base class and set the background position for the desired sprite. // It will also apply the image dimensions if $dimensions is true. -@mixin #{name}-sprite($sprite, $dimensions: $#{name}-sprite-dimensions, $x: 0, $y: 0) { +@mixin #{name}-sprite($name, $dimensions: $#{name}-sprite-dimensions, $offset-x: 0, $offset-y: 0) { @extend \#{$#{name}-sprite-base-class}; - @include #{name}-sprite-position($sprite, $x, $y); - @if $dimensions { - @include #{name}-sprite-dimensions($sprite); - } + @include sprite($#{name}-sprite, $name, $dimensions, $offset-x, $offset-y) +} + +@mixin #{name}-sprites($sprite-names, $dimensions: $#{name}-sprite-dimensions) { + @include sprites($#{name}-sprite, $sprite-names, $#{name}-sprite-base-class, $dimensions) } // Generates a class for each sprited image. -@mixin all-#{name}-sprites { -#{images.map do |sprite_name| -<<-SCSS - .#{name}-#{sprite_name} { - @include #{name}-sprite("#{sprite_name}"); - } -SCSS -end.join} +@mixin all-#{name}-sprites($dimensions: $#{name}-sprite-dimensions) { + @include #{name}-sprites(#{images.join(" ")}, $dimensions); } - SCSS +SCSS end def key(uri, options) diff --git a/spec/sprites_spec.rb b/spec/sprites_spec.rb index 8ec5edcb..36481339 100644 --- a/spec/sprites_spec.rb +++ b/spec/sprites_spec.rb @@ -10,7 +10,6 @@ describe Compass::Sprites do FileUtils.cp_r @images_src_path, @images_tmp_path Compass.configuration.images_path = @images_tmp_path Compass.configure_sass_plugin! - Compass::Sprites.reset end after :each do @@ -247,19 +246,21 @@ describe Compass::Sprites do it "should use position adjustments in functions" do css = render <<-SCSS - $squares-position: 100%; - @import "squares/*.png"; + $squares-sprite: sprite("squares/*.png", $position: 100%); + .squares-sprite { + background: $squares-sprite no-repeat; + } .adjusted-percentage { - background-position: sprite-position("squares/ten-by-ten.png", 100%); + background-position: sprite-position($squares-sprite, ten-by-ten, 100%); } .adjusted-px-1 { - background-position: sprite-position("squares/ten-by-ten.png", 4px); + background-position: sprite-position($squares-sprite, ten-by-ten, 4px); } .adjusted-px-2 { - background-position: sprite-position("squares/twenty-by-twenty.png", -3px, 2px); + background-position: sprite-position($squares-sprite, twenty-by-twenty, -3px, 2px); } SCSS css.should == <<-CSS @@ -289,15 +290,15 @@ describe Compass::Sprites do @import "squares/*.png"; .adjusted-percentage { - @include squares-sprite("ten-by-ten", $x: 100%); + @include squares-sprite("ten-by-ten", $offset-x: 100%); } .adjusted-px-1 { - @include squares-sprite("ten-by-ten", $x: 4px); + @include squares-sprite("ten-by-ten", $offset-x: 4px); } .adjusted-px-2 { - @include squares-sprite("twenty-by-twenty", $x: -3px, $y: 2px); + @include squares-sprite("twenty-by-twenty", $offset-x: -3px, $offset-y: 2px); } SCSS css.should == <<-CSS @@ -344,89 +345,46 @@ describe Compass::Sprites do image_md5('squares.png').should == '0187306f3858136feee87d3017e7f307' end - it "should use the sprite-image and sprite-url function as in lemonade" do - css = render <<-SCSS - @import "squares/*.png"; - - .squares-1 { - background: sprite-image("squares/twenty-by-twenty.png") no-repeat; - } - - .squares-2 { - background: sprite-image("squares/twenty-by-twenty.png", 100%) no-repeat; - } - - .squares-3 { - background: sprite-image("squares/twenty-by-twenty.png", -4px, 3px) no-repeat; - } - - .squares-4 { - background-image: sprite-url("squares/twenty-by-twenty.png"); - } - - .squares-5 { - background-image: sprite-url("squares/*.png"); - } - SCSS - css.should == <<-CSS - .squares-sprite { - background: url('/squares.png') no-repeat; - } - - .squares-1 { - background: url('/squares.png') 0 -10px no-repeat; - } - - .squares-2 { - background: url('/squares.png') 100% -10px no-repeat; - } - - .squares-3 { - background: url('/squares.png') -4px -7px no-repeat; - } - - .squares-4 { - background-image: url('/squares.png'); - } - - .squares-5 { - background-image: url('/squares.png'); - } - CSS - end - - it "should raise deprecation errors for lemonade's spacing syntax" do + it "should provide a nice errors for lemonade's old users" do proc do render <<-SCSS - @import "squares/*.png"; - .squares { - background: sprite-image("squares/twenty-by-twenty.png", 0, 0, 11px) no-repeat; + background: sprite-url("squares/*.png") no-repeat; } SCSS - end.should raise_error Compass::Error, - %q(Spacing parameter is deprecated. Please add `$squares-twenty-by-twenty-spacing: 11px;` before the `@import "squares/*.png";` statement.) - proc do - render <<-SCSS - @import "squares/*.png"; - - .squares { - background: sprite-position("squares/twenty-by-twenty.png", 0, 0, 11px) no-repeat; - } - SCSS - end.should raise_error Compass::Error, - %q(Spacing parameter is deprecated. Please add `$squares-twenty-by-twenty-spacing: 11px;` before the `@import "squares/*.png";` statement.) - end - - it "should raise an error if @import is missing" do + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-url must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) proc do render <<-SCSS .squares { background: sprite-image("squares/twenty-by-twenty.png") no-repeat; } SCSS - end.should raise_error Compass::Error, - %q(`@import` statement missing. Please add `@import "squares/*.png";`.) + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-image must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + proc do + render <<-SCSS + @import "squares/*.png"; + + .squares { + background: sprite-position("squares/twenty-by-twenty.png") no-repeat; + } + SCSS + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-position must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + + it "should work even if @import is missing" do + actual_css = render <<-SCSS + .squares { + background: sprite-image(sprite("squares/*.png"), twenty-by-twenty) no-repeat; + } + SCSS + actual_css.should == <<-CSS + .squares { + background: url('/squares.png') 0 -10px no-repeat; + } + CSS end end \ No newline at end of file