diff --git a/app/decorators/asciicast_decorator.rb b/app/decorators/asciicast_decorator.rb index 85170f6..6d56c47 100644 --- a/app/decorators/asciicast_decorator.rb +++ b/app/decorators/asciicast_decorator.rb @@ -35,23 +35,8 @@ class AsciicastDecorator < ApplicationDecorator end def thumbnail(width = THUMBNAIL_WIDTH, height = THUMBNAIL_HEIGHT) - if @thumbnail.nil? - lines = model.snapshot.to_s.split("\n") - - top_lines = lines[0...height] - top_text = prepare_lines(top_lines, width, height).join("\n") - - bottom_lines = lines.reverse[0...height].reverse - bottom_text = prepare_lines(bottom_lines, width, height).join("\n") - - if top_text.gsub(/\s+/, '').size > bottom_text.gsub(/\s+/, '').size - @thumbnail = top_text - else - @thumbnail = bottom_text - end - end - - @thumbnail + thumbnail = model.snapshot.crop(width, height) + SnapshotPresenter.new(thumbnail).to_html end def description diff --git a/app/models/asciicast.rb b/app/models/asciicast.rb index 2a5ff62..e753262 100644 --- a/app/models/asciicast.rb +++ b/app/models/asciicast.rb @@ -6,7 +6,7 @@ class Asciicast < ActiveRecord::Base mount_uploader :stdout, BasicUploader mount_uploader :stdout_timing, BasicUploader - serialize :snapshot, Snapshot::Serializer.new + serialize :snapshot, Snapshot validates :stdout, :stdout_timing, :presence => true validates :terminal_columns, :terminal_lines, :duration, :presence => true diff --git a/app/models/brush.rb b/app/models/brush.rb index 39b2fa0..30f3ef3 100644 --- a/app/models/brush.rb +++ b/app/models/brush.rb @@ -1,5 +1,4 @@ class Brush - attr_reader :attributes def initialize(attributes = {}) @attributes = attributes @@ -24,4 +23,9 @@ class Brush def inverse? !!attributes[:inverse] end + + private + + attr_reader :attributes + end diff --git a/app/models/snapshot.rb b/app/models/snapshot.rb index 54ce0f6..3fbe9c4 100644 --- a/app/models/snapshot.rb +++ b/app/models/snapshot.rb @@ -1,4 +1,5 @@ class Snapshot + attr_reader :lines def initialize(lines = []) @@ -9,13 +10,9 @@ class Snapshot other.lines == lines end - class Serializer - def dump(snapshot) - YAML.dump(snapshot.lines) - end - - def load(value) - value.present? ? Snapshot.new(YAML.load(value)) : Snapshot.new - end + def crop(width, height) + new_lines = lines.drop(lines.size - height).map { |line| line.crop(width) } + self.class.new(new_lines) end + end diff --git a/app/models/snapshot_fragment.rb b/app/models/snapshot_fragment.rb new file mode 100644 index 0000000..8b3d839 --- /dev/null +++ b/app/models/snapshot_fragment.rb @@ -0,0 +1,22 @@ +class SnapshotFragment + + attr_reader :text, :brush + + def initialize(text, brush) + @text = text + @brush = brush + end + + def ==(other) + other.text == text && other.brush == brush + end + + def crop(size) + if size >= text.size + self + else + self.class.new(text[0...size], brush) + end + end + +end diff --git a/app/models/snapshot_line.rb b/app/models/snapshot_line.rb new file mode 100644 index 0000000..c4dcaae --- /dev/null +++ b/app/models/snapshot_line.rb @@ -0,0 +1,31 @@ +class SnapshotLine + + attr_reader :fragments + + def initialize(fragments) + @fragments = fragments + end + + def ==(other) + other.fragments == fragments + end + + def crop(size) + new_fragments = [] + current_size = 0 + + fragments.each do |fragment| + break if current_size == size + + if current_size + fragment.size > size + fragment = fragment.crop(size - current_size) + end + + new_fragments << fragment + current_size += fragment.size + end + + self.class.new(new_fragments) + end + +end diff --git a/app/presenters/brush_presenter.rb b/app/presenters/brush_presenter.rb new file mode 100644 index 0000000..6961988 --- /dev/null +++ b/app/presenters/brush_presenter.rb @@ -0,0 +1,33 @@ +class BrushPresenter < SimpleDelegator + + def to_css_class + if default? + nil + else + [fg_class, bg_class, bold_class, underline_class, inverse_class].compact.join(' ') + end + end + + private + + def fg_class + "fg#{fg}" if fg + end + + def bg_class + "bg#{bg}" if bg + end + + def bold_class + 'bold' if bold? + end + + def underline_class + 'underline' if underline? + end + + def inverse_class + 'inverse' if inverse? + end + +end diff --git a/app/presenters/snapshot_fragment_presenter.rb b/app/presenters/snapshot_fragment_presenter.rb new file mode 100644 index 0000000..6688af4 --- /dev/null +++ b/app/presenters/snapshot_fragment_presenter.rb @@ -0,0 +1,15 @@ +class SnapshotFragmentPresenter < Draper::Decorator + + delegate :text, :brush + + def to_html + h.content_tag(:span, text, :class => css_class) + end + + private + + def css_class + BrushPresenter.new(brush).to_css_class + end + +end diff --git a/app/presenters/snapshot_line_presenter.rb b/app/presenters/snapshot_line_presenter.rb new file mode 100644 index 0000000..d72a51d --- /dev/null +++ b/app/presenters/snapshot_line_presenter.rb @@ -0,0 +1,19 @@ +class SnapshotLinePresenter < Draper::Decorator + + delegate :fragments + + def to_html + h.content_tag(:span, fragment_strings.join.html_safe, :class => 'line') + end + + private + + def fragment_strings + fragments.map { |fragment| fragment_string(fragment) } + end + + def fragment_string(fragment) + SnapshotFragmentPresenter.new(fragment).to_html + end + +end diff --git a/app/presenters/snapshot_presenter.rb b/app/presenters/snapshot_presenter.rb new file mode 100644 index 0000000..8f6a7d5 --- /dev/null +++ b/app/presenters/snapshot_presenter.rb @@ -0,0 +1,19 @@ +class SnapshotPresenter < Draper::Decorator + + delegate :lines + + def to_html + h.content_tag(:pre, line_strings.join.html_safe, :class => 'thumbnail') + end + + private + + def line_strings + lines.map { |line| line_string(line) } + end + + def line_string(line) + SnapshotLinePresenter.new(line).to_html + end + +end diff --git a/spec/decorators/asciicast_decorator_spec.rb b/spec/decorators/asciicast_decorator_spec.rb index 9ee0872..f7996e2 100644 --- a/spec/decorators/asciicast_decorator_spec.rb +++ b/spec/decorators/asciicast_decorator_spec.rb @@ -134,7 +134,24 @@ describe AsciicastDecorator do end describe '#thumbnail' do - pending + let(:snapshot) { double('snapshot', :crop => thumbnail) } + let(:thumbnail) { double('thumbnail') } + let(:presenter) { double('presenter') } + + before do + allow(asciicast).to receive(:snapshot) { snapshot } + allow(SnapshotPresenter).to receive(:new).with(thumbnail) { presenter } + allow(presenter).to receive(:to_html) { '
' }
+    end
+
+    it 'crops the snapshot' do
+      decorated.thumbnail(21, 13)
+      expect(snapshot).to have_received(:crop).with(21, 13)
+    end
+
+    it 'returns html snapshot rendered by SnapshotPresenter#to_html' do
+      expect(decorated.thumbnail).to eq('
')
+    end
   end
 
   describe '#author' do
@@ -230,7 +247,7 @@ describe AsciicastDecorator do
       end
 
       it 'returns avatar_image_tag' do
-        decorated.stub!(:h => h)
+        decorated.stub(:h => h)
         h.should_receive(:avatar_image_tag).with(nil).and_return(avatar_image)
         subject.should == avatar_image
       end
@@ -243,7 +260,7 @@ describe AsciicastDecorator do
 
   describe '#embed_script' do
     before do
-      asciicast.stub!(:id => 123)
+      asciicast.stub(:id => 123)
     end
 
     it 'should be an async script tag including asciicast id' do
diff --git a/spec/models/asciicast_spec.rb b/spec/models/asciicast_spec.rb
index 9c0c270..e3b6430 100644
--- a/spec/models/asciicast_spec.rb
+++ b/spec/models/asciicast_spec.rb
@@ -120,24 +120,31 @@ describe Asciicast do
 
   describe '#snapshot' do
     let(:asciicast) { Asciicast.new }
+    let(:snapshot) {
+      Snapshot.new([
+        SnapshotLine.new([
+          SnapshotFragment.new('foo', { :fg => 1 })
+        ])
+      ])
+    }
 
     it 'is empty Snapshot instance initially' do
       expect(asciicast.snapshot).to eq(Snapshot.new)
     end
 
     it 'is a Snapshot instance before persisting' do
-      asciicast.snapshot = Snapshot.new({ :foo => 1 })
+      asciicast.snapshot = snapshot
 
-      expect(asciicast.snapshot).to eq(Snapshot.new({ :foo => 1 }))
+      expect(asciicast.snapshot).to eq(snapshot)
     end
 
     it 'is a Snapshot instance after persisting and loading' do
       asciicast = build(:asciicast)
-      asciicast.snapshot = Snapshot.new({ :foo => 1 })
+      asciicast.snapshot = snapshot
       asciicast.save!
 
-      expect(asciicast.snapshot).to eq(Snapshot.new({ :foo => 1 }))
-      expect(Asciicast.find(asciicast.id).snapshot).to eq(Snapshot.new({ :foo => 1 }))
+      expect(asciicast.snapshot).to eq(snapshot)
+      expect(Asciicast.find(asciicast.id).snapshot).to eq(snapshot)
     end
   end
 end
diff --git a/spec/models/snapshot_fragment_spec.rb b/spec/models/snapshot_fragment_spec.rb
new file mode 100644
index 0000000..af97e02
--- /dev/null
+++ b/spec/models/snapshot_fragment_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe SnapshotFragment do
+  describe '#==' do
+    let(:snapshot_fragment) { SnapshotFragment.new('foo', brush_1) }
+    let(:brush_1) { double('brush_1') }
+    let(:brush_2) { double('brush_2') }
+
+    subject { snapshot_fragment == other }
+
+    context "when fragments have the same texts and brushes" do
+      let(:other) { SnapshotFragment.new('foo', brush_1) }
+
+      it { should be(true) }
+    end
+
+    context "when fragments have different texts" do
+      let(:other) { SnapshotFragment.new('bar', brush_1) }
+
+      it { should be(false) }
+    end
+
+    context "when fragments have different brushes" do
+      let(:other) { SnapshotFragment.new('foo', brush_2) }
+
+      it { should be(false) }
+    end
+  end
+
+  describe '#crop' do
+    let(:snapshot_fragment) { SnapshotFragment.new('foobar', brush) }
+    let(:brush) { double('brush') }
+
+    context "when size is smaller than fragment's size" do
+      subject { snapshot_fragment.crop(3) }
+
+      it 'returns a new instance of SnapshotFragment' do
+        expect(subject).to be_kind_of(SnapshotFragment)
+        expect(subject).to_not be(snapshot_fragment)
+      end
+
+      it 'trims the text to the requested size' do
+        expect(subject.text).to eq('foo')
+      end
+
+      it 'returns SnapshotFragment with the same brush' do
+        expect(subject.brush).to be(brush)
+      end
+    end
+
+    context "when size is equal or larger than the fragment's size" do
+      it 'returns self' do
+        expect(snapshot_fragment.crop(6)).to be(snapshot_fragment)
+      end
+    end
+  end
+end
diff --git a/spec/models/snapshot_line_spec.rb b/spec/models/snapshot_line_spec.rb
new file mode 100644
index 0000000..43c5bfa
--- /dev/null
+++ b/spec/models/snapshot_line_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe SnapshotLine do
+  describe '#==' do
+    let(:snapshot_line) { SnapshotLine.new([:foo]) }
+
+    subject { snapshot_line == other }
+
+    context "when lines have the same fragments" do
+      let(:other) { SnapshotLine.new([:foo]) }
+
+      it { should be(true) }
+    end
+
+    context "when lines have different fragments" do
+      let(:other) { SnapshotLine.new([:foo, :bar]) }
+
+      it { should be(false) }
+    end
+  end
+
+  describe '#crop' do
+    let(:snapshot_line) { SnapshotLine.new(fragments) }
+    let(:fragments) { [fragment_1, fragment_2] }
+    let(:fragment_1) { double('fragment_1', :size => 2, :crop => nil) }
+    let(:fragment_2) { double('fragment_2', :size => 3,
+                                            :crop => cropped_fragment_2) }
+    let(:fragment_3) { double('fragment_3', :size => 4, :crop => nil) }
+    let(:cropped_fragment_2) { double('cropped_fragment_2', :size => 2) }
+
+    context "when cropping point is at the end of the first fragment" do
+      it 'crops none of the fragments' do
+        snapshot_line.crop(2)
+
+        expect(fragment_1).to_not have_received(:crop)
+        expect(fragment_2).to_not have_received(:crop)
+        expect(fragment_3).to_not have_received(:crop)
+      end
+
+      it 'returns a new SnapshotLine with only the first fragment' do
+        expect(snapshot_line.crop(2)).to eq(SnapshotLine.new([fragment_1]))
+      end
+    end
+
+    context "when cropping point is inside of the second fragment" do
+      it 'crops only the second fragment' do
+        snapshot_line.crop(4)
+
+        expect(fragment_1).to_not have_received(:crop)
+        expect(fragment_2).to have_received(:crop).with(2)
+        expect(fragment_3).to_not have_received(:crop)
+      end
+
+      it 'returns a new SnapshotLine with first two fragments cropped' do
+        expect(snapshot_line.crop(4)).
+          to eq(SnapshotLine.new([fragment_1, cropped_fragment_2]))
+      end
+    end
+  end
+end
diff --git a/spec/models/snapshot_spec.rb b/spec/models/snapshot_spec.rb
index 23359f9..aa80f48 100644
--- a/spec/models/snapshot_spec.rb
+++ b/spec/models/snapshot_spec.rb
@@ -20,4 +20,25 @@ describe Snapshot do
     end
   end
 
+  describe '#crop' do
+    let(:snapshot) { Snapshot.new(lines) }
+    let(:lines) { [line_1, line_2, line_3] }
+    let(:line_1) { double('line_1', :crop => nil) }
+    let(:line_2) { double('line_2', :crop => cropped_line_2) }
+    let(:line_3) { double('line_3', :crop => cropped_line_3) }
+    let(:cropped_line_2) { double('cropped_line_2') }
+    let(:cropped_line_3) { double('cropped_line_3') }
+
+    it 'crops the last "height" lines' do
+      snapshot.crop(3, 2)
+
+      expect(line_1).to_not have_received(:crop)
+      expect(line_2).to have_received(:crop).with(3)
+      expect(line_3).to have_received(:crop).with(3)
+    end
+
+    it 'returns a new Snapshot with cropped lines' do
+      expect(snapshot.crop(3, 2)).to eq(Snapshot.new([cropped_line_2, cropped_line_3]))
+    end
+  end
 end
diff --git a/spec/presenters/brush_presenter_spec.rb b/spec/presenters/brush_presenter_spec.rb
new file mode 100644
index 0000000..87dd0d4
--- /dev/null
+++ b/spec/presenters/brush_presenter_spec.rb
@@ -0,0 +1,91 @@
+require 'spec_helper'
+
+describe BrushPresenter do
+  let(:brush_presenter) { BrushPresenter.new(brush) }
+  let(:brush) { double('brush', :fg => nil, :bg => nil, :bold? => false,
+                                :underline? => false, :inverse? => false) }
+
+  describe '#to_css_class' do
+    subject { brush_presenter.to_css_class }
+
+    context "when brush is a default one" do
+      before do
+        allow(brush).to receive(:default?) { true }
+      end
+
+      it { should be(nil) }
+    end
+
+    context "when brush is not a default one" do
+      before do
+        allow(brush).to receive(:default?) { false }
+      end
+
+      context "when fg is default" do
+        before do
+          allow(brush).to receive(:fg) { nil }
+        end
+
+        it { should_not match(/\bfg/) }
+      end
+
+      context "when fg is non-default" do
+        before do
+          allow(brush).to receive(:fg) { 1 }
+        end
+
+        it { should match(/\bfg1\b/) }
+      end
+
+      context "when bg is default" do
+        before do
+          allow(brush).to receive(:bg) { nil }
+        end
+
+        it { should_not match(/\bbg/) }
+      end
+
+      context "when bg is non-default" do
+        before do
+          allow(brush).to receive(:bg) { 2 }
+        end
+
+        it { should match(/\bbg2\b/) }
+      end
+
+      context "when both fg and bg are non-default" do
+        before do
+          allow(brush).to receive(:fg) { 1 }
+          allow(brush).to receive(:bg) { 2 }
+        end
+
+        it { should match(/\bfg1\b/) }
+        it { should match(/\bbg2\b/) }
+      end
+
+      context "when it's bold" do
+        before do
+          allow(brush).to receive(:bold?) { true }
+        end
+
+        it { should match(/\bbold\b/) }
+      end
+
+      context "when it's underline" do
+        before do
+          allow(brush).to receive(:underline?) { true }
+        end
+
+        it { should match(/\bunderline\b/) }
+      end
+
+      context "when it's inverse" do
+        before do
+          allow(brush).to receive(:inverse?) { true }
+        end
+
+        it { should match(/\binverse\b/) }
+      end
+    end
+  end
+end
diff --git a/spec/presenters/snapshot_fragment_presenter_spec.rb b/spec/presenters/snapshot_fragment_presenter_spec.rb
new file mode 100644
index 0000000..a4bb889
--- /dev/null
+++ b/spec/presenters/snapshot_fragment_presenter_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe SnapshotFragmentPresenter do
+  let(:snapshot_fragment_presenter) { SnapshotFragmentPresenter.new(snapshot_fragment) }
+  let(:snapshot_fragment) { SnapshotFragment.new('foo > bar', brush) }
+  let(:brush) { double('brush') }
+  let(:brush_presenter) { double('brush_presenter', :to_css_class => css_class) }
+  let(:css_class) { 'qux' }
+
+  describe '#to_html' do
+    subject { snapshot_fragment_presenter.to_html }
+
+    before do
+      allow(BrushPresenter).to receive(:new).with(brush).
+        and_return(brush_presenter)
+    end
+
+    it { should be_kind_of(ActiveSupport::SafeBuffer) }
+    it { should eq('foo > bar') }
+
+    context "when css class is nil" do
+      let(:css_class) { nil }
+
+      it { should eq('foo > bar') }
+    end
+  end
+end
diff --git a/spec/presenters/snapshot_line_presenter_spec.rb b/spec/presenters/snapshot_line_presenter_spec.rb
new file mode 100644
index 0000000..5d14c39
--- /dev/null
+++ b/spec/presenters/snapshot_line_presenter_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe SnapshotLinePresenter do
+  let(:snapshot_line_presenter) { SnapshotLinePresenter.new(snapshot_line) }
+  let(:snapshot_line) { SnapshotLine.new([fragment_1, fragment_2]) }
+  let(:fragment_1) { double('fragment_1') }
+  let(:fragment_2) { double('fragment_2') }
+  let(:fragment_1_presenter) { double(:to_html => '') }
+  let(:fragment_2_presenter) { double(:to_html => '') }
+
+  describe '#to_html' do
+    subject { snapshot_line_presenter.to_html }
+
+    before do
+      allow(SnapshotFragmentPresenter).to receive(:new).with(fragment_1).
+        and_return(fragment_1_presenter)
+      allow(SnapshotFragmentPresenter).to receive(:new).with(fragment_2).
+        and_return(fragment_2_presenter)
+    end
+
+    it { should be_kind_of(ActiveSupport::SafeBuffer) }
+    it { should eq('') }
+  end
+end
diff --git a/spec/presenters/snapshot_presenter_spec.rb b/spec/presenters/snapshot_presenter_spec.rb
new file mode 100644
index 0000000..4547fd7
--- /dev/null
+++ b/spec/presenters/snapshot_presenter_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe SnapshotPresenter do
+  let(:snapshot_presenter) { SnapshotPresenter.new(snapshot) }
+  let(:snapshot) { Snapshot.new([line_1, line_2]) }
+  let(:line_1) { double('line_1') }
+  let(:line_2) { double('line_2') }
+  let(:line_1_presenter) { double(:to_html => '') }
+  let(:line_2_presenter) { double(:to_html => '') }
+
+  describe '#to_html' do
+    subject { snapshot_presenter.to_html }
+
+    before do
+      allow(SnapshotLinePresenter).to receive(:new).with(line_1).
+        and_return(line_1_presenter)
+      allow(SnapshotLinePresenter).to receive(:new).with(line_2).
+        and_return(line_2_presenter)
+    end
+
+    it { should be_kind_of(ActiveSupport::SafeBuffer) }
+    it { should eq('
') } + end +end